Design: Akamai Fetch Processor

Overview

Fetches Akamai PSIRT advisories published as blog posts at www.akamai.com/blog/security/advisory-cve-* and www.akamai.com/blog/security-research/advisory-cve-* and stores the parsed vulnerability data as CVEMetadata.source = "akamai".

Source identifier: akamai Data type: fetch ECS task name: go-akamai-fetch-processor Schedule: Runs weekly on Sundays at 05:00 UTC (cron(0 5 ? * SUN *)).

Why not a CSV / RSS / sitemap processor? Akamai’s edge blocks every listing surface (CSV index, sitemap.xml, robots.txt, /blog/security, /blog/feed, /api/blog/posts) with HTTP 403 Access Denied at the URL-pattern level — not via the Bot Manager, but via a hard edge ACL. Only specific per-advisory blog URLs return 200, and only if you already know the slug. This processor therefore operates over a maintained seed list and grows it via related-advisory link extraction.

How the anti-bot gate is handled

Akamai Bot Manager (the same product Akamai sells to its customers) sits in front of every page on www.akamai.com. The challenge fires at the TLS layer — Go’s stdlib crypto/tls ClientHello is fingerprinted as non-browser and returns 403 regardless of User-Agent.

This processor uses httpclient.NewChromeImpersonating (see internal/httpclient/utls.go), which performs a Chrome 120 ClientHello via refraction-networking/utls. Combined with:

  • a cookiejar.Jar (the edge sets a challenge cookie on first contact that subsequent requests must echo)
  • sec-ch-ua* / sec-fetch-* Client-Hints headers via httpclient.SetBrowserFetchHeaders
  • sec-fetch-site=none on cold landings, same-origin + Referer on subsequent in-session navigations
  • 6-second pacing between fetches

…this defeats the gate reliably. The same client pattern is applicable to any WAF that does TLS-fingerprint gating (Cloudflare Bot Fight Mode, PerimeterX, DataDome).


Data Source

ItemValue
Originhttps://www.akamai.com/blog/security/ and /blog/security-research/
Listing URLNone reachable — every index/feed/sitemap 403s
Slug patternadvisory-cve-{YYYY-NNNN}[-{descriptive-suffix}]
FormatHTML with embedded JSON-LD BlogPosting
Cadence5–15 first-party advisories per year (light feed)
IdentifierCVE-YYYY-NNNN+ in URL slug, page title, and body
LicencePublic (subject to Bot Manager challenge)
Anti-botAkamai Bot Manager — defeated by Chrome uTLS ClientHello

Per-advisory page structure

Every page exposes a JSON-LD <script type="application/ld+json"> block containing a BlogPosting:

{
  "@type": "BlogPosting",
  "@id": "https://www.akamai.com/content/akamai/en/blog/security/2025/dec/advisory-cve-2025-53841-...",
  "mainEntityOfPage": "...",
  "headline": "CVE-2025-53841: Guardicore Local Privilege Escalation Vulnerability",
  "description": "Get technical details about a security vulnerability...",
  "datePublished": "2025-12-09T10:00:00Z",
  "dateModified": "2025-12-09T20:38:57Z"
}

CVSS vectors are not consistently exposed in the HTML — Akamai posts often state only a textual severity (“Medium impact”). The parser captures a vector + numeric score when present and otherwise emits no CVSS metric row rather than synthesise one.


Architecture

graph TD SEEDS["internal/akamai/seeds.go SeedSlugs"] --> Q[Run queue] Q --> FETCH["akamai.Client.Fetch uTLS Chrome 120"] FETCH -->|200| PARSE["akamai.ParseAdvisory"] FETCH -->|403| QUAR1[("S3 quarantine: fetch-error")] FETCH -->|404 on both prefixes| QUAR2[("S3 quarantine: fetch-error")] PARSE -->|JSON-LD + h1 + CVE regex| ADV[Advisory] PARSE -->|extracted advisory-cve hrefs| Q ADV --> MAP["akamai.MapToSourceData"] MAP --> RESUME{"sourceFileHash match?"} RESUME -->|yes| SKIP[Skip — unchanged] RESUME -->|no| STORE["WithTx + StoreCVESourceData"] STORE --> CVE[(CVEMetadata + CVEAlias + CVEDescription + CVEReferences)] STORE --> ARCH[("S3 archive akamai/files/sha256/filename")] STORE --> ENRICH["aienrich.RunBatch post-commit"] ENRICH --> CVE

Data Mapping

CVEMetadata (one row per advisory)

FieldSource
cveIdFirst CVE-YYYY-NNNN+ match in title or page body
source"akamai"
dataVersion"1.0"
state"PUBLISHED"
titleJSON-LD headline (falls back to <h1 class="cmp-title__text">)
sourceAdvisoryRefFetched blog URL
affectedVendor"Akamai"
affectedProductDerived from title — Guardicore, App & API Protector, Kona Site Defender, Edge DNS, EdgeWorkers, Bot Manager, Linode, or Akamai fallback
datePublishedJSON-LD datePublished → Unix seconds
dateUpdatedJSON-LD dateModified → Unix seconds
vectorStringFirst CVSS:x.y/... or AV:N/... match in body (often empty)
rawDataJSONJSON-serialised parsed Advisory for audit
sourceFileHashSHA-256 over the advisory-stable field subset {slug, title, description, datePublished, dateModified, CVEs} — not the raw HTML. Akamai injects per-request nonces and rotates the “Latest blog posts” sidebar, so neither raw bytes nor the full parsed Advisory are stable across fetches. (The raw-bytes SHA is still computed, but only as the S3 archive key.)

CVEAlias

  • Every additional CVE-* ID extracted from the page beyond the primary.
  • The Akamai slug itself as akamai-{slug} (e.g. akamai-advisory-cve-2025-53841-guardicore-local-privilege-escalation). Because this is a non-CVE identifier, db.InsertAliases also mints a placeholder CVEMetadata row for it under source='akamai' so the alias edge has an FK target. Those placeholder rows are content-free by design — exclude them from any field-coverage measurement of this source.

Aliases flow through osv.CVESourceData.Aliasesprocessor.StoreCVESourceDatadb.InsertAliases, which writes canonical edges and runs same-cveId cross-source backfill.

CVE harvesting is page-wide. ParseAdvisory collects every CVE-\d{4}-\d{4,} match in the title and then every match anywhere in the document, in that order. Practically this means: the primary cveId is the title’s CVE (the slug shape advisory-cve-YYYY-NNNN-* guarantees one is present on real PSIRT posts), and any CVE merely mentioned in the body would become an alias. That fan-out is neutralised by db.InsertAliases’ bundle suppression — when the primary id is CVE-prefixed and more than one CVE alias is present, all CVE aliases are dropped — so no CVE↔CVE false edges reach CVEAlias. The akamai-{slug} alias is non-CVE and always survives.

CVEDescription

Single row: containerType=cna, lang=en, value= JSON-LD description (Akamai’s SEO meta blurb — short but accurate).

CVEMetadataReferences

One row keyed referenceSource = "akamai", type = "advisory", url = sourceAdvisoryRef. Additional vendor/patch references are not present in the source HTML in a stable location and so are not synthesised.

CVEMetric

Emitted only when a CVSS vector string is detected in the page. The score is best-effort (regex over the body); severity falls back to a textual Critical|High|Medium|Low token when found. Most Akamai posts produce no metric row.

CVEAffected

Not currently emitted. Akamai advisories describe vendor product families (Guardicore Agent, App & API Protector policy, etc.) rather than versioned packages; mapping these to PURL/CPE would be guesswork. The affectedProduct column captures the family for filtering.


Discovery: seed list + crawl growth

The processor starts each run with internal/akamai/seeds.go::SeedSlugs and pushes the queue forward by extracting every href="...advisory-cve-..." href from each successfully parsed page. New slugs (not previously seen this run) are appended to the queue and fetched in the same execution, subject to --limit and the soft deadline.

sequenceDiagram autonumber participant Sch as EventBridge Sun 05:00 UTC participant T as ECS task participant A as www.akamai.com participant DB as PostgreSQL participant S3 as S3 archive and quarantine Sch->>T: Start akamai-fetch-processor T->>T: Load SeedSlugs and LoadProcessedHashes akamai loop for each slug in queue T->>A: GET blog security slug via uTLS Chrome 120 with cookies alt 200 OK A-->>T: HTML T->>T: ParseAdvisory then Advisory T->>T: Extract related slugs and append to queue alt sourceFileHash unchanged T->>T: Skip else changed T->>DB: StoreCVESourceData inside WithTx T->>S3: Archive akamai files sha slug html end else 403 Bot Manager or 404 both prefixes T->>S3: Quarantine fetch-error end T->>T: sleep 6s human pacing end T->>DB: aienrich.RunBatch post-commit T->>DB: UpsertTracker source akamai

Persisting newly discovered slugs across runs is intentionally not implemented — committing dynamic state to seeds.go from a scheduled task would require a self-PR loop. Instead, the in-run growth keeps coverage current within a single execution; out-of-band PRs to seeds.go extend the persistent baseline.


Incremental Strategy

Two-tier resume:

TierMechanismStorage
Per-pageSHA-256 of the advisory-stable field subset compared to CVEMetadata.sourceFileHash on the read replicaLoaded once at startup via db.LoadProcessedHashes("akamai") (5-minute budget)
Feed-levelBulkDataDumpTracker row keyed source = "akamai" records last successful rundb.GetTracker / db.UpsertTracker

--force bypasses the per-page resume and refetches everything. There is no feed-level SHA to compare against (no canonical index exists), so the tracker is informational only.


Flags

FlagDefaultDescription
--forcefalseRefetch every seed regardless of sourceFileHash match
--limit0Stop after N successful upserts (0 = no cap)
--probe-onlyfalseFetch the first seed, log status, exit. Used to verify the Bot Manager gate before running with DB writes.

Decision tree

flowchart TD START[Slug from queue] --> CTX{ctx cancelled or soft deadline?} CTX -->|yes| END[Exit] CTX -->|no| LIMIT{limit reached?} LIMIT -->|yes| END LIMIT -->|no| FETCH[client.Fetch slug] FETCH -->|edge 403| QF[Quarantine fetch-error] FETCH -->|404 both prefixes| QF FETCH -->|other err| QF FETCH -->|200| PARSE[ParseAdvisory] PARSE -->|err| QP[Quarantine parse-error] PARSE -->|ok| RELATED[Append unseen related slugs to queue] RELATED --> MAP[MapToSourceData] MAP -->|no CVE in page| SKIPNP[Log non-PSIRT post] MAP -->|ok| HASH{sourceFileHash matches and not --force?} HASH -->|yes| SKIP[Skip — unchanged] HASH -->|no| STORE[WithTx StoreCVESourceData] STORE -->|err| QS[Quarantine store-error] STORE -->|ok| ARCH[Archive akamai files sha slug html] ARCH --> NEXT[sleep 6s next slug] QF --> NEXT QP --> NEXT QS --> NEXT SKIP --> NEXT SKIPNP --> NEXT

Files

PathPurpose
scripts/go-processors/cmd/akamai-fetch-processor/main.goBinary entrypoint, seed loop, S3 wiring, aienrich call
scripts/go-processors/internal/akamai/types.goAdvisory record + Source, ProcessorName, BaseURL constants
scripts/go-processors/internal/akamai/seeds.goSeedSlugs []string — extend via PR
scripts/go-processors/internal/akamai/parse.goJSON-LD + <h1> + CVE regex + related-href extraction
scripts/go-processors/internal/akamai/mapper.goMapToSourceDataosv.CVESourceData
scripts/go-processors/internal/akamai/client.gouTLS Chrome + cookie jar + same-origin Referer state
scripts/go-processors/internal/akamai/parse_test.goUnit tests against captured testdata/*.html
scripts/go-processors/internal/httpclient/utls.goNewChromeImpersonating constructor (shared)

Local Development

# Verify the Bot Manager gate is passable from this network — no DB writes
just go-akamai-fetch-probe

# Full backfill against local PostgreSQL (.env)
just go-akamai-fetch-backfill TARGET=local LIMIT=5

# Force refetch of all seeds even if sourceFileHash matches
just go-akamai-fetch-backfill TARGET=local FORCE=true

# Production (.env.production)
just go-akamai-fetch-backfill TARGET=prod

# Inspect upserted rows
psql "$DATABASE_URL" -c "SELECT \"cveId\", \"title\", \"dateUpdated\" FROM \"CVEMetadata\" WHERE source = 'akamai' ORDER BY \"dateUpdated\" DESC LIMIT 10;"

Deployment

  • Containerfile target: akamai-fetch-processor (scratch + certs + binary).
  • Terraform: module "akamai_fetch_processor" in terraform/go-schedules.tf (cpu=256, memory=512, weekly Sundays at 05:00 UTC).
  • ECR tag: go-akamai-fetch-processor-${tag}.
  • Hook: listed in .claude/hooks/post-push-ecr.sh::TARGETS.
  • Dashboard: work-stats case in scripts/task-dashboard/db.go counts CVEMetadata WHERE source = 'akamai'.

Known Limitations

  • Discovery is bounded by the seed list and per-run crawl. Advisories Akamai publishes that are not linked from any seeded page will not be picked up automatically. Extend seeds.go to cover new product families.
  • Resume is approximate. sourceFileHash is computed over {slug, title, description, datePublished, dateModified, CVEs} — the only stable bits across fetches, since Akamai injects per-request nonces into the HTML and rotates the “Latest blog posts” sidebar. On a tiny feed (2–15 advisories/year) this is acceptable, but in practice the LoadProcessedHashes read-replica lookup often returns stale data and a fresh UPSERT happens anyway. The UPSERT is idempotent at the DB level, so the cost is one wasted write per advisory per run — negligible at this scale. Re-evaluate if seed coverage ever expands past ~100 advisories.
  • CVSS data is rare. Akamai posts cite a severity word but seldom an AV vector. The processor emits no metric row rather than fabricate one. Downstream consumers that require CVSS should derive from the description via the standard cvss.DeriveV4FromDescription pipeline (enabled by default).
  • No CVEAffected rows. Versioned product mapping is left to first-party Akamai catalogs that don’t currently exist publicly. The affectedVendor / affectedProduct columns are sufficient for filtering by family.
  • Bot Manager is a moving target. If a future Akamai edge-config update fingerprints HelloChrome_120 specifically, swap utls.HelloChrome_120 for HelloChrome_Auto or a newer pinned profile in internal/httpclient/utls.go.
  • Linode coverage. Akamai’s PSIRT now covers post-acquisition Linode advisories under the same blog surface; this processor tags them via productFromTitle()affectedProduct = "Linode". See Linode (Superseded by Akamai) for the inheritance rationale and the SQL to query Linode-tagged rows. Add new Linode-specific advisory slugs to seeds.go as they are discovered.

S3 Persistence

  • Archive path: akamai/files/{sha256}/{filename}
  • Quarantine path: failed-feeds/akamai-fetch-processor/{YYYY-MM-DD}/{reason}/{filename}
  • Failure reasons emitted: fetch-error, parse-error, 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[akamai-fetch-processor] PROC -->|success| ARCHIVE[("S3: akamai/files/{sha256}/{filename}")] PROC -->|failure| Q[("S3: failed-feeds/akamai-fetch-processor/{date}/{reason}/{filename}")] PROC --> DB[(PostgreSQL)]

See the S3 Persistence Contract for the full reason taxonomy.