Design: Exploit Defence Processor

Overview

Sweeps Exploit rows that carry an archived PoC body and did not come from Vulnetix’s own PoC generation, and produces the set of deployable defences each exploit actually admits, in two families:

FamilyTableFormats
Virtual patch (block)VirtualPatchSNORT, SURICATA, MODSECURITY, NGINX, AWS_WAF, CLOUDFLARE_WAF, REGEX_L7
Countermeasure (detect)CountermeasureSIGMA, YARA, STIX, OPENIOC

Source identifier: vulnetix (artifacts carry source = 'vulnetix') Data type: consumer of ingested Exploit rows ECS task name: go-aienrich-defence-processor Schedule: cron(0 */6 * * ? *) — six-hourly, trailing the exploit ingest cluster Backfill: cmd/aienrich-corpus-backfill --passes=vulnetix.exploitdefence

The staging, the per-stage model selection and the routing table are internal; .repo/EXPLOIT-DEFENCE-plan.md in this repository is the reference for those. This page documents the contract, the storage and the operational surface.


Why this exists

The corpus holds a PoC body for a large share of Exploit rows, archived to S3 by the ingest processors. Until now that was a terminal artifact: a customer could read it, and nothing downstream consumed it.

Meanwhile the console already presents SnortRule and YaraRule as “the compensating control while the fix is still pending” — but those rules are ingested from third-party catalogues, so the exploit we hold and the defence we suggest are unrelated rows. Nothing connected the two.


Population

Selection is an anti-join against CveEnrichmentOutcome on the pass key, so an exploit that has produced a terminal verdict is never selected again. Two additional predicates narrow it:

AND e."source" <> 'vulnetix'
AND e."r2Key" IS NOT NULL

source <> 'vulnetix' excludes the PoCs the GHSA PoC pass generated. Deriving a defence from one would be reasoning about our own earlier inference rather than about evidence.

r2Key IS NOT NULL excludes rows with no archived body. The pass declines those anyway once it has loaded the row, but that costs a query and a PixLog row per exploit to discover, and a large majority of the table has no archived body. Without the filter a sweep spends its whole run producing not_attempted.


Contract

The pass answers one question per exploit — where can this be caught? — and that answer selects the formats to write. The answer is stored on every artifact as routingSentinel:

SentinelMeaning
NETWORK_HTTPCarried in an HTTP request; visible to anything that inspects layer 7
NETWORK_NONHTTPOn the wire but not HTTP — raw TCP/UDP, TLS, DNS, SMB
ONDEVICE_HOSTObservable only as host behaviour: process, file, registry, memory
ONDEVICE_FILEThe exploit IS an artifact; caught by scanning content
NETWORK_AND_ONDEVICEDelivered over the network, then acts on the device
INSUFFICIENT_EXPLOIT_FOR_DEFENCEAdmits no defence — terminal, and a correct answer

The refusal sentinel is deliberately common. A logic flaw with no signature, an authenticated action indistinguishable from ordinary use, or a PoC too thin to build from all produce it. A rule that never fires reads to a customer as coverage they do not have, which is worse than recording that there is none.

A sentinel may be narrowed but never extended: the format set it admits is the ceiling. That is what stops an on-device exploit acquiring a Cloudflare expression it could never match.


Validation

internal/defence/validate checks every artifact before persist. Nothing unvalidated reaches the database. A rejected artifact gets a corrective pass, at most twice; if it is still invalid it is dropped, and the other formats produced in the same reply still persist.

Schema-backed (santhosh-tekuri/jsonschema/v6, embedded under internal/defence/validate/schemas/): AWS_WAF, STIX.

Parser-backed: SNORT and SURICATA reuse internal/emergingthreats.ParseV2Rule and YARA reuses internal/yara.Parse — the same parsers the ingest processors use, so a generated rule is held to the same bar as an imported one. The rest are grammar checks written for the format.

The checks that matter are the ones a tool accepts silently and then never fires on:

  • Sigma — every identifier the condition names must be declared in detection, and vice versa. A condition naming an undeclared search converts cleanly on every backend and matches nothing.
  • YARA — every $identifier the condition references must be declared, or yarac rejects the whole file rather than just that rule.
  • STIX — every relationship’s source_ref/target_ref must name an object present in the same bundle.
  • AWS WAF — a rule with no terminating Action is evaluated and then does nothing.
  • REGEX_L7 — nested unbounded quantifiers are refused. This artifact is the one intended to run against attacker-controlled bytes on every request, and Go’s RE2 does not backtrack, so a successful compile proves nothing about the PCRE engines in ModSecurity, nginx and the WAF products.

Reserved id ranges

Checked, never assumed:

FormatRangeWhy
Snort / Suricata sid3,000,000–3,999,999Unassigned by both Sourcefire (1–999,999) and Emerging Threats (2,000,000–2,999,999)
ModSecurity id30,000,000–39,999,999Outside every reserved OWASP Core Rule Set block

source='vulnetix' already shares the SnortRule table with 2,245 imported community rules, so an out-of-range sid could silently replace one of theirs in a subscriber’s ruleset.

validate.Version is stored on every row as validatorVersion, so tightening a validator can find and re-check what was written under the looser one.


Storage

The body is stored twice, following YaraRule: inline rawText so a read that only needs the rule avoids an S3 round trip, and the canonical object at vulnetix/files/{sha256}/{filename} with an Artifact + Link pair and fileLinkId on the row — the same idiom CVEMetadata and Exploit use.

artifactId is deterministic (vulnetix-vp-<exploitUuid>-<kind> / vulnetix-cm-<exploitUuid>-<kind>), so a re-run upserts on (artifactId, source) rather than accumulating a row per run. Same idempotency contract Exploit gets from (exploitId, source).

Advisory linkage uses the ExploitCVE shape — (cveId, source) with a real composite FK onto CVEMetadatanot the FK-less SnortRuleCVE shape. One junction row per CVEMetadata.source carrying the id, resolved the way linkGHSAPoCExploitCVE already does for exploits.

Each artifact commits in its own transaction. One transaction for all of them would let a single unwritable artifact discard the rest, and these are independent products: a Sigma rule is not less useful because the YARA rule beside it hit a constraint.


Verdicts

The pass ends in notePersisted with the number of artifacts actually committed, so a reply that stored nothing lands as malformed and stays retryable rather than being retired as enriched. See the feedback_verdict_vs_persistence precedent: an ok verdict written from the reply retired 4,788 advisories that stored nothing.

Only the triage stage writes an ActivityKey. The later stages write their PixLog rows with an empty one, which writeEnrichmentOutcome treats as “audit this call, do not roll it up” — otherwise a corrective turn would bump attemptCount on a verdict the chain had not finished producing.


Not replayable

aienrich-pixlog-replay cannot recover this pass. The reply that machinery hands a replay is the one PixLog row its outcome points at, and for this chain that is the triage answer — a routing verdict and a set of match primitives, not the rules. The rule bodies are in a later row that by design carries no activityKey and is therefore invisible to buildReplayCandidateQuery. Recovering them is a different query shape and belongs in its own tool.

This is documented in ReplayablePasses alongside the reasons snortrule and crit are absent.


Operations

# ongoing, scheduled: cron(0 */6 * * ? *), --limit=400
just AIENRICH=true go-aienrich-defence-processor prod LIMIT=5

# whole-corpus sweep
just AIENRICH=true go-aienrich-corpus-backfill prod \
    --passes=vulnetix.exploitdefence

# after a schema change, push the prompts into Pix.systemPromptText
just pix-prompts-sync prod

S3_BUCKET_NAME is mandatory. The PoC bodies this pass reasons about live in S3; without an uploader every exploit would look bodyless and the run would retire the corpus with a wrong not_attempted verdict. The binary refuses to start rather than doing that.

Local runs are inference-free by default — the justfile strips the gateway credentials unless AIENRICH=true is set, so a prod-target run does not enrich from a dev machine by accident.

Flags

FlagDefaultPurpose
--limit0 (ECS passes 400)Stop after N exploits
--workers4Concurrent exploits
--wave200Candidates selected per wave
--afterResume the keyset cursor from an Exploit.uuid
--per-exploit-timeout-minutes10Budget for one exploit’s whole chain
--max-attempts3Stop selecting after N non-terminal attempts
--retry-cooldown-hours168Do not retry a failure inside this window
--dry-runfalseSelect and log, call nothing, write nothing

The sweep stops early on any of: the soft deadline derived from EXPECTED_DURATION_MINUTES, the run cap, SIGTERM, or the gateway circuit breaker. Every stop logs the cursor to resume from.


Consumers

  • Console/resolve/virtual-patching and /resolve/countermeasures, plus the Defence tab on every finding detail page.
  • FeedsDetectionRuleFeed gained a feedType discriminator so a feed is homogeneous by output format; the publisher emits the native file set for each.
  • Public APIGET /v2/vuln/{id}/virtual-patches, /countermeasures, the two catalogue searches and the two archive routes.
  • CLIvulnetix vdb virtual-patches|countermeasures get|list|fetch.