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:
| Family | Table | Formats |
|---|---|---|
| Virtual patch (block) | VirtualPatch | SNORT, SURICATA, MODSECURITY, NGINX, AWS_WAF, CLOUDFLARE_WAF, REGEX_L7 |
| Countermeasure (detect) | Countermeasure | SIGMA, 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:
| Sentinel | Meaning |
|---|---|
NETWORK_HTTP | Carried in an HTTP request; visible to anything that inspects layer 7 |
NETWORK_NONHTTP | On the wire but not HTTP — raw TCP/UDP, TLS, DNS, SMB |
ONDEVICE_HOST | Observable only as host behaviour: process, file, registry, memory |
ONDEVICE_FILE | The exploit IS an artifact; caught by scanning content |
NETWORK_AND_ONDEVICE | Delivered over the network, then acts on the device |
INSUFFICIENT_EXPLOIT_FOR_DEFENCE | Admits 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
conditionnames must be declared indetection, and vice versa. A condition naming an undeclared search converts cleanly on every backend and matches nothing. - YARA — every
$identifierthe condition references must be declared, oryaracrejects the whole file rather than just that rule. - STIX — every relationship’s
source_ref/target_refmust name an object present in the same bundle. - AWS WAF — a rule with no terminating
Actionis 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:
| Format | Range | Why |
|---|---|---|
Snort / Suricata sid | 3,000,000–3,999,999 | Unassigned by both Sourcefire (1–999,999) and Emerging Threats (2,000,000–2,999,999) |
ModSecurity id | 30,000,000–39,999,999 | Outside 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 CVEMetadata — not 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
| Flag | Default | Purpose |
|---|---|---|
--limit | 0 (ECS passes 400) | Stop after N exploits |
--workers | 4 | Concurrent exploits |
--wave | 200 | Candidates selected per wave |
--after | — | Resume the keyset cursor from an Exploit.uuid |
--per-exploit-timeout-minutes | 10 | Budget for one exploit’s whole chain |
--max-attempts | 3 | Stop selecting after N non-terminal attempts |
--retry-cooldown-hours | 168 | Do not retry a failure inside this window |
--dry-run | false | Select 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-patchingand/resolve/countermeasures, plus the Defence tab on every finding detail page. - Feeds —
DetectionRuleFeedgained afeedTypediscriminator so a feed is homogeneous by output format; the publisher emits the native file set for each. - Public API —
GET /v2/vuln/{id}/virtual-patches,/countermeasures, the two catalogue searches and the two archive routes. - CLI —
vulnetix vdb virtual-patches|countermeasures get|list|fetch.