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:

GuardApplies toRationale
Short tokenservice/resource tokens ≤ 3 chars (ecs, s3, rds, iam)Too short to be evidence on their own
Ambiguous tokena 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_DERIVE enum. Any required field carrying it discards the candidate.
  • The reply must end with exactly one of PIX_CRIT_DERIVED or PIX_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

OutcomeS3 destinationS3QueueObject
Derived and publishedcrit-inference/processed/inserted
Model declinedcrit-inference/rejected/cannot-derive/rejected
Failed validation twicecrit-inference/rejected/spec-invalid/rejected
CVE already has a CritRecordcrit-inference/skipped/already-recorded/superseded
Unparseable candidatecrit-inference/rejected/parse-error/ + quarantinerejected
Gateway error / inference disabledstays pendingunchanged

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)