S3 Persistence Contract
Every processor MUST persist the raw feed payload to S3 — both on success and on failure — so we never lose source data and can audit/replay any processing decision.
This rule applies to every processor in scripts/go-processors/cmd/, regardless of source type (json, csaf, cvrf, rss, fetch, git, snort, gsheet, csv, enrich-*). It applies to scheduled ECS runs and to local backfill runs alike. Skip the upload only when S3_BUCKET_NAME is unset (local development without AWS credentials).
See the Compliance Matrix for the current status of every processor.
Two paths, one bucket
The same S3_BUCKET_NAME bucket holds both:
| Concern | Path | When |
|---|---|---|
| Archive (source of truth for raw payloads) | {source}/files/{sha256}/{filename} | After a record/file is successfully stored to the database |
| Quarantine (failed-feed review queue) | failed-feeds/{processor}/{YYYY-MM-DD}/{reason}/{filename} | Whenever a record/file fails any pre-processing or storage step |
{source}is the canonical short slug used inCVEMetadata.source(e.g.osv,mitre-cve,vulncheck-nvd,ghsa,siemens,certfr).{processor}is thecmd/directory name (e.g.osv-json-processor).{sha256}is the lowercase hex content hash of the raw payload.{filename}is the original feed-relative path (preserve nested directories from the archive —vulns/PYSEC-2024-123.yaml, not just the basename).
NVD / GHSA / OSV / CSAF / RSS / git / fetch"] --> PROC["Processor
cmd/{name}-processor"] PROC -->|"success
after DB commit"| ARCHIVE[("S3 archive
{source}/files/{sha256}/{filename}")] PROC -->|"failure
at any step"| Q[("S3 quarantine
failed-feeds/{processor}/{date}/{reason}/{filename}")] PROC --> DB[("PostgreSQL
CVEMetadata + children")] style ARCHIVE fill:#1b6e58,color:#fff style Q fill:#7a3d1f,color:#fff style DB fill:#2a3b5a,color:#fff
Reason tags (kebab-case)
Use these tags consistently so the quarantine bucket stays queryable:
| Tag | Meaning |
|---|---|
schema-violation | Required field missing, enum value out of range, structural validation failed |
parse-error | JSON / YAML / XML / CVRF / CSAF deserialisation failed |
decode-error | Charset, base64, gzip, or zip extraction failed |
map-error | Advisory-to-CVEMetadata mapping returned a non-skip error |
store-error | Database UPSERT or transaction failed after retry budget exhausted |
enrich-error | Enrichment-only processors: enrichment lookup failed |
fetch-error | Upstream fetch returned non-2xx after retries (only when payload bytes are available) |
charset / gzip / zip?"} DECODE -->|fail| Q1["quarantine:
decode-error"] DECODE -->|ok| PARSE{"Parse
JSON / YAML / XML?"} PARSE -->|fail| Q2["quarantine:
parse-error"] PARSE -->|ok| SCHEMA{"Schema valid?"} SCHEMA -->|fail| Q3["quarantine:
schema-violation"] SCHEMA -->|ok| MAP{"Map to CVEMetadata?"} MAP -->|fail| Q4["quarantine:
map-error"] MAP -->|ok| STORE{"DB UPSERT
after retries?"} STORE -->|fail| Q5["quarantine:
store-error"] STORE -->|ok| ARCHIVE["archive:
{source}/files/{sha256}/{filename}"]
Helpers
Use the shared methods on s3client.Uploader — do not hand-roll either path in a processor.
u, err := s3client.NewUploader(ctx, os.Getenv("S3_BUCKET_NAME"), "application/json", logger)
// ...
u.Archive(ctx, "osv", fileSHA256, zf.name, zf.data) // success path
u.Quarantine(ctx, "osv-json-processor", "schema-violation", zf.name, zf.data) // failure path
Both methods are no-ops when the receiver is nil, so processors can hold an *Uploader field that is nil when S3 is not configured without scattering nil checks at every call site.
Helpers live in:
scripts/go-processors/internal/s3client/uploader.go—Uploader.Archive,Uploader.Quarantinescripts/go-processors/internal/s3client/recordsink.go—MarshalRecord,ArchiveRecord,QuarantineRecordfor record-based (non-file) processorsscripts/go-processors/internal/s3client/feedsink.go—NewFromEnv,PipelineHooksfor the processor pipelinescripts/go-processors/internal/enrichment/s3upload.go— enrichment-specific helpers
Idempotency and batching
- The archive path is content-addressed by
{sha256}so re-uploading the same payload is a free no-op on S3. - Successful archive uploads SHOULD be recorded on the per-source file tracker row (e.g.
OsvFile.r2Path,OsvFile.r2StoredAt). Resume runs consult these columns and skip re-uploading already-archived files. - Archive uploads run inside the existing batch-processing concurrency (typically 4 workers, 200 files per batch). Do not introduce a separate uploader goroutine pool — reuse the batch worker that just stored the record.
- Quarantine uploads run synchronously at the failure site, before logging the warn/error line. Best-effort: a failed
Quarantinecall MUST NOT block the pipeline.
Processors whose unit-of-work is not a file
Some processors operate on records that did not arrive as standalone files (RSS feed entries, paginated JSON arrays, CSV rows, individual records from a single bulk download). In that case:
- Synthesise a stable
{filename}from the record identity:{advisoryId}.jsonor{cveId}-{source}.json— never a UUID, never an index. The same record on a re-run MUST produce the same filename. - Re-serialise the record to canonical JSON (
encoding/jsonwith sorted keys via the standard library) and use that as the payload bytes. - Compute SHA256 over those payload bytes for the archive path.
The contract is the same: archive on success, quarantine on failure.
Compliance audit
The Compliance Matrix audits every processor. Every PR that adds a new processor MUST add a row. Every PR that adds a new failure path in an existing processor MUST keep the matrix accurate.