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 anchor | Use |
|---|---|
<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, YYYY | Body-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
Source → Database Mappings
CVEMetadata
| Source field | Column | Notes |
|---|---|---|
item.name | cveId | Verbatim. AWS-YYYY-NNN OR slug (e.g. microsoft-windows-rdp-vulnerability). |
"aws-security-bulletins" | source | Constant — distinct from ALAS’s amazon. |
Body-extracted date OR item.dateCreated | datePublished | Unix seconds. |
Body <h1> (else bulletinSubject) | title | The body title is canonical. |
"PUBLISHED" | state | Constant. |
"5.0" | dataVersion | Constant. |
bulletinSubjectUrl | sourceAdvisoryRef | Article URL. |
sha1(bulletinID | dateUpdated | sha256(rawHTML)[:8]) | sourceFileHash | Resume 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.orggithub.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.comaws.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) | Service | Resource type | Dict |
|---|---|---|---|
| “elastic kubernetes service” / “amazon eks” | eks | cluster | spec |
| “amazon ecs” / “elastic container service” / “fargate” | ecs | cluster | spec |
| “amazon ec2” / “elastic compute cloud” / “ec2 instance” | ec2 | instance | spec |
| “aws lambda” / “lambda function” | lambda | function | spec |
| “amazon rds” / “rds for postgresql” / “aurora” | rds | db | spec |
| “amazon redshift” / “redshift jdbc” | redshift | cluster | spec |
| “amazon dynamodb” | dynamodb | table | spec |
| “dynamodb local” | dynamodb_local | instance | extended |
| “amazon bedrock” / “deepjavalibrary” / " djl " | bedrock | model | spec |
| “freertos-plus-tcp” / “freertos” | freertos | device | extended |
| “amazon cloud cam” / “cloudcam” | cloud_cam | device | extended |
| “iot device defender” | iot_device_defender | audit | extended |
| “amazon q business” / “amazon q " | q | application | extended |
| “data.all” | data_all | tenant | extended |
| “aws verified access” | verified_access | endpoint | extended |
… (full table in service_map.go) | |||
| Amazon-Linux-only mention | skip — 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)
| Rule | Conditional |
|---|---|
| R1 Single-page list at size=2000 | q.Set("size", "2000") |
| R2 Warn when totalHits > size cap | if list.Metadata.TotalHits > listSize { logger.Warn("pagination required") } |
| R3 Soft-deadline 5-minute buffer | if !softDeadline.IsZero() && time.Now().After(softDeadline.Add(-5*time.Minute)) |
HTML extraction (html.go:ExtractHTML)
| Rule | Conditional |
|---|---|
R4 Anchor on data-rg-n not CSS classes | bodyDivRe = <div...data-rg-n="BodyText"...> (CSS class names are obfuscated/volatile) |
| R5 Three-tier fallback: BodyText → main → full doc | if 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)
| Rule | Conditional |
|---|---|
| R7 Whitelist precedes denylist | if isAdvisoryHost(u) { kept = append; continue }; if isDenyHost(u) { continue } |
| R8 Self-link dropped from references | `if u == selfURL |
| R9 Locale-prefixed AWS paths denied | if 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)
| Rule | Conditional |
|---|---|
R10 First synonym match wins per (service, resource_type) key | if !seen[key] { seen[key] = true; matches = append(...) } |
| R11 Amazon-Linux-only bulletins skipped | LooksALASOnly(title) → alasOnly=true and matches stays empty (CVEMetadata still lands; CRIT path suppressed). Avoids double-count vs ALAS. |
Per-bulletin keying (parser.go:CombineEntry)
| Rule | Conditional |
|---|---|
| R12 Slug bulletins keyed verbatim | pe.IsSlugKey = !looksAWSID(pe.BulletinID) — no prefix transform; microsoft-windows-rdp-vulnerability stored as-is. |
| R13 Body title preferred over API subject | Title: coalesce(html.Title, api.bulletinSubject) |
| R14 Always include bulletin URL as first reference | if pe.URL != "" { ... pe.RefURLs = append([]{pe.URL}, pe.RefURLs...) } — guarantees ≥1 reference per row. |
CRIT mapper (crit_mapper.go:mapAWSBulletinToCRIT)
| Rule | Conditional |
|---|---|
| R15 Reject empty vulnID | if vulnID == "" { return ok=false } |
| R16 Prefer canonical CVE pubdate | if 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 missing | if template == "" { return ok=false } — surfaces unauthored extended-dict entries. |
| R19 vex_status under_investigation when no resolution AND severity=Important | if resolution == "" && severity == "Important" { vexStatus = "under_investigation" } |
fix_propagation inference (crit_mapper.go:inferFixPropagation)
| Body signal (lc) | fix_propagation | Rule |
|---|---|---|
| “no action is required of aws customers” / “no customer action is required” / “aws customers were not impacted” / “no action required” | automatic | R20 |
| “all amazon eks kubernetes clusters are now running” / “aws has applied” / “deployed across all regions” / “automatically updated” | automatic | R21 |
| “replace all worker nodes” / “use the latest eks-optimized ami” / “create new fleets to pick up the updated ami” / “rebuild your image” | rebuild_and_redeploy | R22 |
| “should ensure their” + “security groups” / “configure them to block” / “configuration change” | config_change | R23 |
| “rotate” + (“credentials” | “keys” | “tokens”) | credential_rotation | R24 |
| “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_update | R25 |
| Default when severity=Important | version_update | R26 |
| Default when severity=Informational | automatic | R27 |
| Default otherwise | version_update | R28 |
Confidence ladder (crit_mapper.go:confidenceFor)
| Rule | Conditions |
|---|---|
R29 low for slug keys, missing aliases, or extended-dict entries | isSlugKey || !bodyHasAliases || dictSource == "extended" |
R30 high when text-pinned + spec dict | fromText && dictSource == "spec" |
R31 medium otherwise. |
Coupling rules
- R32
automatic⇒provider_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.LoadProcessedHasheskeyed on the bulletin id versussha1(bulletinID | dateUpdated | sha256(rawHTML)[:8]). There is noBulkDataDumpTrackerrow for this processor — the full 2000-item directory listing plus every changed bulletin’s HTML is fetched on every run. - Notification:
notifier.Completedis called unconditionally at the end of the run (main.go:273) even whentotalErrors > 0, and the process always exits 0. Per-item failures are recorded vianotifier.RecordErrorbut never escalate toErrored, so a run in which every HTML fetch failed still reports success and EventBridge sees a healthy task. - No AI enrichment — no
aienrich.Enricheris constructed. - Reference / problem-type duplication:
db.InsertReferencesissues a bareON CONFLICT DO NOTHINGagainst a table with no natural-key unique index, so every re-store of a bulletin appends duplicate reference rows.
Risk Surface
| Risk | Guard |
|---|---|
| AWS changes the JSON API endpoint | aws.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 attribute | Tests 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 covered | LooksALASOnly filter; CRIT staging skipped for AL-only bulletins. |
| API exceeds 2000-item cap in the future | Logger warns; pagination needs to be added. |
| Slot ABNF violation on extended-dict templates | Schema 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).
See the S3 Persistence Contract for the full reason taxonomy.