Design: AWS Security Bulletins JSON Processor

Overview

Fetches AWS Security Bulletins via the public aws.amazon.com directory JSON API, then per-item HTML body fetch from server-rendered bulletin pages, parses CVE/GHSA aliases and AWS service references, stores per-bulletin rows under source="aws-security-bulletins", and (when --emit-crit) stages CRIT candidate envelopes per (vulnID × AWS service).

Distinct from alas-rss-processor — covers service-level AWS bulletins (Bedrock / RDS / EKS / Lambda / Cloud Cam / FreeRTOS), not Amazon Linux package CVEs. Both are provider=aws but source and bulletin-ID shapes are disjoint, so they coexist without natural-key collision.

Source identifier: aws-security-bulletins Data type: json (JSON API for discovery + per-item HTML body) ECS task name: go-aws-security-bulletins-json-processor Schedule: Daily 13:00 UTC (cron(0 13 * * ? *)) Pattern: A — inline per-record CRIT staging Phase: 1.6


Data Source

Discovery — JSON API

GET https://aws.amazon.com/api/dirs/items/search
    ?item.directoryId=security-bulletins
    &sort_by=item.dateCreated&sort_order=desc
    &size=2000
    &item.locale=en_US

Response shape:

{
  "items": [
    {
      "item": {
        "id": "security-bulletins#aws-2025-013",
        "name": "AWS-2025-013",
        "directoryId": "security-bulletins",
        "dateCreated": "2025-06-12T17:27:17+0000",
        "dateUpdated": "2025-06-12T17:30:18+0000",
        "additionalFields": {
          "bulletinDateSort": "2025-06-12",
          "bulletinDate": "12-jun-25",
          "bulletinId": "AWS-2025-013",
          "bulletinSubjectUrl": "https://aws.amazon.com/security/security-bulletins/AWS-2025-013/",
          "bulletinSubject": "CVE-2025-6031 - Insecure device pairing in end-of-life Amazon Cloud Cam"
        }
      },
      "tags": [
        {"tagNamespaceId": "security-bulletins#flag", "name": "amazon"},
        {"tagNamespaceId": "security-bulletins#flag", "name": "informational"},
        {"tagNamespaceId": "security-bulletins#year", "name": "2025"}
      ]
    }
  ],
  "metadata": {"count": 156, "totalHits": 156}
}

Volume: 156 bulletins (full coverage; no pagination needed at size=2000). The processor warns when totalHits > size so paging can be added when AWS’s volume grows.

Per-bulletin HTML body

GET https://aws.amazon.com/security/security-bulletins/AWS-YYYY-NNN/
    User-Agent: Mozilla/5.0 (vdb-manager-aws-security-bulletins/0.1.0)

Server-rendered HTML with stable data-rg-n attributes (CSS classes are obfuscated and volatile — ignored).

Stable anchorUse
<h1 data-rg-n="HeadingText" ...>TITLE</h1>Title
<div data-rg-n="BodyText" ...>...</div>Body container
<p><b>Resolution:</b></p>Remediation paragraph start
Publication Date: YYYY/MM/DD / Initial Publication Date: YYYY/MM/DD / Month D, YYYYBody-level publish date
Content Type: Important (requires attention) / ... Informational ...Severity (cross-checks API tag)

Fallback: when data-rg-n="BodyText" is missing, fall back to <main> slice; if that fails, fall back to the full document. ExtractFallbackUsed flag is logged.


Architecture / Data Flow

sequenceDiagram participant ECS participant Proc as aws-security-bulletins-json-processor participant API as aws.amazon.com/api/dirs participant HTML as aws.amazon.com/security/... participant DB as PostgreSQL participant S3 ECS->>Proc: cron(0 13 * * ? *) Proc->>API: GET /items/search?...size=2000 API-->>Proc: 156 items + tags Proc->>DB: LoadProcessedHashes(source=aws-security-bulletins) par 4 fetch workers (250 ms gap) loop per item Proc->>HTML: GET bulletinSubjectUrl HTML-->>Proc: server-rendered HTML Proc->>Proc: ExtractHTML → HTMLContext (title/body/refs/CVEs/GHSAs/date) end end loop per result Proc->>Proc: CombineEntry → ParsedEntry alt hash unchanged AND not --force Proc->>Proc: skip else Proc->>DB: storeBulletin (CVEMetadata + Aliases + References + Description) Proc->>S3: archive {sha256}/{bulletinId}.json opt --emit-crit AND service map resolves Proc->>Proc: stageCRIT (per vulnID × match) Proc->>S3: PUT crit-candidates/pending/... Proc->>DB: RegisterS3QueueObject(status=pending) end end end Proc->>Proc: critpublisher.DrainKeys Note over Proc,S3: see crit-publisher.design.md

Source → Database Mappings

CVEMetadata

Source fieldColumnNotes
item.namecveIdVerbatim. AWS-YYYY-NNN OR slug (e.g. microsoft-windows-rdp-vulnerability).
"aws-security-bulletins"sourceConstant — distinct from ALAS’s amazon.
Body-extracted date OR item.dateCreateddatePublishedUnix seconds.
Body <h1> (else bulletinSubject)titleThe body title is canonical.
"PUBLISHED"stateConstant.
"5.0"dataVersionConstant.
bulletinSubjectUrlsourceAdvisoryRefArticle URL.
sha1(bulletinID | dateUpdated | sha256(rawHTML)[:8])sourceFileHashResume detection.

CVEAlias

CVE-* and GHSA-* extracted from tag-stripped body text. Always passed through db.InsertAliases (empty list still triggers same-cveId backfill).

CVEMetadataReferences

The bulletin URL is always the first reference (advisory type). All <a href> URLs inside the BodyText div extracted, then filtered:

Whitelist (always keep — precedence):

  • cve.org/CVERecord / cve.org/cverecord / cve.mitre.org
  • github.com/.../security/advisories/GHSA-
  • github.com/.../releases/tag/
  • nvd.nist.gov/vuln/detail/CVE-
  • Vendor advisory hostnames: msrc.microsoft.com, support.apple.com, chromereleases.googleblog.com, lists.debian.org, usn.ubuntu.com, access.redhat.com, bugzilla.redhat.com, kb.cert.org, oracle.com/security-alerts, cisco.com, support.f5.com, psirt.global.sonicwall.com
  • docs.aws.amazon.com/

Denylist:

  • *.awsstatic.com
  • aws.amazon.com/{contact-us,partners,blogs,whitepapers,solutions,training,what-is}
  • Locale paths: aws.amazon.com/{cn,de,es,fr,it,jp,ko,pt,tr,ar,id,zh}/
  • amazonwebservicesinc.tt.omtrdc.net, *.demdex.net
  • self-link to bulletin URL

CVEDescription

  • Body text (first 1200 chars) → row 1.
  • Resolution paragraph (when present, prefixed with "Resolution: ", 1500 chars) → row 2.

S3 archive

aws-security-bulletins/files/{sha256}/{bulletinID}.json

Service Mapping

40+ synonyms covering top AWS services. Order: longer / more specific first. Examples:

Body/title contains (lc)ServiceResource typeDict
“elastic kubernetes service” / “amazon eks”eksclusterspec
“amazon ecs” / “elastic container service” / “fargate”ecsclusterspec
“amazon ec2” / “elastic compute cloud” / “ec2 instance”ec2instancespec
“aws lambda” / “lambda function”lambdafunctionspec
“amazon rds” / “rds for postgresql” / “aurora”rdsdbspec
“amazon redshift” / “redshift jdbc”redshiftclusterspec
“amazon dynamodb”dynamodbtablespec
“dynamodb local”dynamodb_localinstanceextended
“amazon bedrock” / “deepjavalibrary” / " djl "bedrockmodelspec
“freertos-plus-tcp” / “freertos”freertosdeviceextended
“amazon cloud cam” / “cloudcam”cloud_camdeviceextended
“iot device defender”iot_device_defenderauditextended
“amazon q business” / “amazon q "qapplicationextended
“data.all”data_alltenantextended
“aws verified access”verified_accessendpointextended
… (full table in service_map.go)
Amazon-Linux-only mentionskip — ALAS owns this

Extended-dict layer (internal/critutil/dictionaries/extended/aws.json): 10 entries — freertos:device, data_all:tenant, dynamodb_local:instance, cloud_cam:device, iot_device_defender:audit, verified_access:endpoint, q:application, lake_formation:data-lake, supply_chain:instance, clean_rooms:collaboration. All template_format=aws_arn.


Business Rules

Discovery (api.go:FetchList, main.go)

RuleConditional
R1 Single-page list at size=2000q.Set("size", "2000")
R2 Warn when totalHits > size capif list.Metadata.TotalHits > listSize { logger.Warn("pagination required") }
R3 Soft-deadline 5-minute bufferif !softDeadline.IsZero() && time.Now().After(softDeadline.Add(-5*time.Minute))

HTML extraction (html.go:ExtractHTML)

RuleConditional
R4 Anchor on data-rg-n not CSS classesbodyDivRe = <div...data-rg-n="BodyText"...> (CSS class names are obfuscated/volatile)
R5 Three-tier fallback: BodyText → main → full docif BodyText match → use; else if <main> match → fallback="main-only"; else use full doc → fallback="full-doc"
R6 Extract date from “Publication Date:” then fall back to “Month D, YYYY”if publishDateRe match { ... } else if oldDateRe match { ... }

Reference filtering (html.go:filterReferences)

RuleConditional
R7 Whitelist precedes denylistif isAdvisoryHost(u) { kept = append; continue }; if isDenyHost(u) { continue }
R8 Self-link dropped from references`if u == selfURL
R9 Locale-prefixed AWS paths deniedif strings.Contains(lc, "aws.amazon.com"+loc) for cn/de/es/fr/it/jp/ko/pt/tr/ar/id/zh

Service mapping (service_map.go:Resolve)

RuleConditional
R10 First synonym match wins per (service, resource_type) keyif !seen[key] { seen[key] = true; matches = append(...) }
R11 Amazon-Linux-only bulletins skippedLooksALASOnly(title)alasOnly=true and matches stays empty (CVEMetadata still lands; CRIT path suppressed). Avoids double-count vs ALAS.

Per-bulletin keying (parser.go:CombineEntry)

RuleConditional
R12 Slug bulletins keyed verbatimpe.IsSlugKey = !looksAWSID(pe.BulletinID) — no prefix transform; microsoft-windows-rdp-vulnerability stored as-is.
R13 Body title preferred over API subjectTitle: coalesce(html.Title, api.bulletinSubject)
R14 Always include bulletin URL as first referenceif pe.URL != "" { ... pe.RefURLs = append([]{pe.URL}, pe.RefURLs...) } — guarantees ≥1 reference per row.

CRIT mapper (crit_mapper.go:mapAWSBulletinToCRIT)

RuleConditional
R15 Reject empty vulnIDif vulnID == "" { return ok=false }
R16 Prefer canonical CVE pubdateif lookup != nil && strings.HasPrefix(vulnID, "CVE-") { canonicalDate, found = lookup(vulnID) } — falls back to bulletin pubdate, then time.Now().
R17 ServiceAvailableDate fallback to S3-launch (2006-03-14)if !serviceavail.Found("aws", svc) { saDate = "2006-03-14" }
R18 Reject when dictionary entry missingif template == "" { return ok=false } — surfaces unauthored extended-dict entries.
R19 vex_status under_investigation when no resolution AND severity=Importantif resolution == "" && severity == "Important" { vexStatus = "under_investigation" }

fix_propagation inference (crit_mapper.go:inferFixPropagation)

Body signal (lc)fix_propagationRule
“no action is required of aws customers” / “no customer action is required” / “aws customers were not impacted” / “no action required”automaticR20
“all amazon eks kubernetes clusters are now running” / “aws has applied” / “deployed across all regions” / “automatically updated”automaticR21
“replace all worker nodes” / “use the latest eks-optimized ami” / “create new fleets to pick up the updated ami” / “rebuild your image”rebuild_and_redeployR22
“should ensure their” + “security groups” / “configure them to block” / “configuration change”config_changeR23
“rotate” + (“credentials” | “keys” | “tokens”)credential_rotationR24
“we recommend upgrading to” / “users should upgrade to” / “we recommend customers upgrade to” / “we recommend you upgrade to” / “upgrade to driver version” / “upgrade to version”version_updateR25
Default when severity=Importantversion_updateR26
Default when severity=InformationalautomaticR27
Default otherwiseversion_updateR28

Confidence ladder (crit_mapper.go:confidenceFor)

RuleConditions
R29 low for slug keys, missing aliases, or extended-dict entriesisSlugKey || !bodyHasAliases || dictSource == "extended"
R30 high when text-pinned + spec dictfromText && dictSource == "spec"
R31 medium otherwise.

Coupling rules

  • R32 automaticprovider_only.
  • R33 existing_deployments_remain_vulnerable = !(provider_only && automatic).

Drain (main.go)

  • R34 Drain skipped on CRIT_DISABLE_INPROCESS_DRAIN.
  • R35 Drain workers fixed at 4.

Verification Queries

SELECT count(*) FROM "CVEMetadata" WHERE source='aws-security-bulletins';
-- expected: ~156

-- AWS-* primary keys (excluding any historical slug bulletins)
SELECT count(*) FROM "CVEMetadata"
 WHERE source='aws-security-bulletins' AND "cveId" LIKE 'AWS-%';
-- expected: ~156

-- GHSA aliases (AWS-2025-012 has GHSA-5x4f-fvv8-wr65)
SELECT count(*) FROM "CVEAlias"
 WHERE "discoveredFrom"='aws-security-bulletins' AND "aliasCveId" LIKE 'GHSA-%';
-- expected: ≥30

-- Reference coverage
SELECT count(*) FROM "CVEMetadataReferences"
 WHERE source='aws-security-bulletins'
   AND "cveId" IN (SELECT "cveId" FROM "CVEMetadata" WHERE source='aws-security-bulletins')
 GROUP BY "cveId" HAVING count(*) = 0;
-- expected: 0 rows

-- Service distribution: distinct from ALAS's (aws,ec2,instance) cohort
SELECT service, count(*) FROM "CritRecord"
 WHERE provider='aws'
   AND "critJSON"->'provider_advisory'->>'advisory_id' LIKE 'AWS-%'
 GROUP BY 1 ORDER BY 2 DESC;

-- Pending should be empty
SELECT "processingStatus", count(*) FROM "S3QueueObject"
 WHERE source='aws-security-bulletins-json-processor' GROUP BY 1;

Resume, notification and exit semantics

  • Resume is per-record only: db.LoadProcessedHashes keyed on the bulletin id versus sha1(bulletinID | dateUpdated | sha256(rawHTML)[:8]). There is no BulkDataDumpTracker row for this processor — the full 2000-item directory listing plus every changed bulletin’s HTML is fetched on every run.
  • Notification: notifier.Completed is called unconditionally at the end of the run (main.go:273) even when totalErrors > 0, and the process always exits 0. Per-item failures are recorded via notifier.RecordError but never escalate to Errored, so a run in which every HTML fetch failed still reports success and EventBridge sees a healthy task.
  • No AI enrichment — no aienrich.Enricher is constructed.
  • Reference / problem-type duplication: db.InsertReferences issues a bare ON CONFLICT DO NOTHING against a table with no natural-key unique index, so every re-store of a bulletin appends duplicate reference rows.

Risk Surface

RiskGuard
AWS changes the JSON API endpointaws.amazon.com/api/dirs/items/search is the public API every aws.amazon.com directory page uses — high stability. Processor fails fast on 404.
AWS changes data-rg-n attributeTests fixture against captured HTML; CI catches breakage. Three-tier fallback (data-rg-n<main> → full doc) keeps text extraction working at lower fidelity.
Bulletin double-counts an Amazon Linux package CVE that ALAS coveredLooksALASOnly filter; CRIT staging skipped for AL-only bulletins.
API exceeds 2000-item cap in the futureLogger warns; pagination needs to be added.
Slot ABNF violation on extended-dict templatesSchema enum + ValidateSlotABNF at stage time and publish time; fixture tests cover each entry.

S3 filenames

The unit of work is an API item plus a scraped HTML body, so the archived payload is the combined ParsedEntry marshalled by s3client.MarshalRecord and {filename} is {bulletinID}.json (main.go:220-233). The raw HTML is not archived. store-error is the only quarantine reason emitted — an HTML fetch failure has no payload bytes to store, so it is counted and reported only.

S3 Persistence

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

See the S3 Persistence Contract for the full reason taxonomy.