Design: CRIT Inference Processor
Overview
Drains s3://{bucket}/crit-inference/pending/...: advisories whose per-source
CRIT mapper attributed nothing, but whose text still carried a CRIT dictionary
token that survived the internal/critprep precision guards. Each candidate
runs through the vulnetix.crit inference pass, and a derived record is
published via the same envelope → crit-publisher path every deterministic
producer uses.
Source identifier: <source>:ai (records carry the originating feed’s slug plus an :ai marker)
Data type: consumer of producer output
ECS task name: go-crit-inference-processor
Schedule: cron(30 */6 * * ? *) — six-hourly, trailing the producer cluster
Why this exists
The retired internal/crit package attributed advisories by substring-matching
free text against a hardcoded registry, with a fallback that scanned every
provider when no provider keyword was present. CVE-2024-7591 — a Progress
LoadMaster command-injection flaw whose description names Progress’s own “ECS”
product — matched the token ecs under both aws and alibaba, minting an
aws/ecs/task-definition CRIT record. cloudscan then matched that record
against a customer’s real ECS task-definition ARN and raised a finding for a
product the customer does not run.
Every layer of this pipeline exists to make that specific failure structurally impossible, not merely discouraged.
Pipeline
1. Gate — internal/critprep
Producers scan each unattributed advisory once against every dictionary triple
plus the provider vendor synonyms. Matching is whole-word by construction: text
and dictionary identifiers are both reduced to space-joined runs of [a-z0-9],
so secs, specs, and ECShop cannot match ecs, while aws_ecs_cluster
still can.
Two guards require the owning provider to be independently present — named in
the text, or supplied as an ImplicitProviders option by a provider-scoped feed:
| Guard | Applies to | Rationale |
|---|---|---|
| Short token | service/resource tokens ≤ 3 chars (ecs, s3, rds, iam) | Too short to be evidence on their own |
| Ambiguous token | a maintained set of service names that are also ordinary English (lambda, config, batch, connect, vault) | Common prose collides with product names |
Resource types always require both provider context and a surviving service hit — a bare “instance” or “bucket” carries no attribution.
Suppressed hits stay in the report so the model and any reviewer see what was rejected and why. An advisory with zero surviving hits is never staged, so it never costs a model call.
A provider hit alone is enough to stage. That is deliberate: the token matcher
only sees dictionary spellings, so an advisory saying “Elastic Container
Service” rather than “ECS” produces a provider hit and no service hit — and
that is exactly the case inference exists to cover. The cost is that
provider-scoped advisories about non-cloud products (an AWS bulletin on Amazon
Payments, say) also reach the model and come back
PIX_CRIT_CANNOT_DERIVE. That is a wasted call, not a wrong record; the
dictionary and the validator, not the gate, are what keep precision. If the
cannot-derive rate on a given source proves too expensive, tighten that
producer rather than the shared gate.
2. Staging
s3://{bucket}/crit-inference/pending/{cve}/{source}/{sha256}.json
Candidates are content-addressed over their own content with the staging
timestamp excluded, so a producer re-run over unchanged text re-PUTs the same
key rather than growing the queue. A changed advisory gets a new key and is
re-inferred. Each staged object registers an S3QueueObject row.
3. Inference — vulnetix.crit
Model: deepseek/deepseek-v4-flash-0731 via the Cloudflare AI Gateway,
temperature 0. Config lives in the Pix row for activityKey='vulnetix.crit';
the prompt is synced from internal/aienrich/prompts/crit.md.
The model receives the advisory, the prep evidence spans, and allowed_triples
— the closed list of dictionary entries for providers the prep stage actually
saw evidence for. Constraints:
- It may choose only from
allowed_triples. An out-of-scope provider is structurally unavailable, not merely discouraged. - It never supplies the locator template; templates come from the dictionary.
- Every field accepts a
CANNOT_DERIVEenum. Any required field carrying it discards the candidate. - The reply must end with exactly one of
PIX_CRIT_DERIVEDorPIX_CRIT_CANNOT_DERIVE. Neither, or both, is a failure — not a silent yes.
4. Validation gate
The assembled candidate must pass critutil.ValidateRecord (JSON Schema,
vector recompute, slot ABNF, template format, and the spec’s conditional
rules). On failure the model gets exactly one corrective turn carrying the
validator’s own error strings, plus a restated instruction to decline rather
than invent. A second failure discards the candidate.
5. Publish
Derived records are staged as ordinary CRIT envelopes and drained through
critpublisher, so Revalidate remains the single gate on what reaches
CritRecord. The envelope’s producer string carries an :ai marker, which the
publisher writes to CritRecord.source — inferred and deterministic records
stay separable in efficacy queries:
SELECT source, count(*) FROM "CritRecord" GROUP BY 1 ORDER BY 2 DESC;
Terminal states
| Outcome | S3 destination | S3QueueObject |
|---|---|---|
| Derived and published | crit-inference/processed/ | inserted |
| Model declined | crit-inference/rejected/cannot-derive/ | rejected |
| Failed validation twice | crit-inference/rejected/spec-invalid/ | rejected |
| CVE already has a CritRecord | crit-inference/skipped/already-recorded/ | superseded |
| Unparseable candidate | crit-inference/rejected/parse-error/ + quarantine | rejected |
| Gateway error / inference disabled | stays pending | unchanged |
The last row is deliberate: an outage or an unseeded Pix row must not drain
the queue into a rejected prefix. Those candidates wait for a healthy run.
Operating
# Local dry run (inference is off by default locally)
just AIENRICH=true go-crit-inference-backfill prod DRY_RUN=true LIMIT=1
# Rejection-rate check
SELECT "rejected", count(*) FROM "PixLog"
WHERE "pixUuid" = (SELECT uuid FROM "Pix" WHERE "activityKey"='vulnetix.crit')
GROUP BY 1;
One-time setup: the Pix row must be seeded by hand —
just pix-prompts-sync and just pix-model-set are both UPDATE-only and
cannot create it. Run
internal/aienrich/sql/pix-seed-crit.sql, then just pix-prompts-sync prod.
Until then the processor logs no enabled Pix row for activityKey=vulnetix.crit
and stages nothing.
S3 Persistence
Not used. This processor does not currently archive payloads or quarantine failures to S3. Per the S3 Persistence Contract this is non-compliant — see the compliance matrix for the implementation roadmap.
⚠ Not in the compliance matrix — status needs verification.
Expected paths when implemented:
- Archive:
crit-inference/files/{sha256}/{filename} - Quarantine:
failed-feeds/crit-inference/{YYYY-MM-DD}/{reason}/{filename} - Likely reasons: (none documented)