Repository navigation
AiSOC v21.0.0
BREAKING
- Correlation grouping narrows on missing identifiers (the Changed
entry below). On upgrade, future alerts that carry no tactic group
per rule instead of per entity, and alerts with no identifiers at all
stop cross-rule merging entirely. Existing incidents are untouched;
the measured effect on a detection-shaped cohort is reduction moving
42.6% → 24.5% because unrelated alerts stop counting as grouped.
There is no API change in this release; the major marks this grouping
change plus the redefinition of published evaluation semantics
(serving-model provenance, full-cohort denominators, closure
contamination) that older scorecards do not carry.
Added
-
Serving-model attribution, end to end. Every published verdict-eval
row hadresolved_models: []— 72 calls placed, answered, and
unattributable, because the only serving-identity reader was the LiteLLM
gateway header and the measured runs talked to an OpenAI-compatible
endpoint directly. The extractor now reads the gateway header first
(verified) and the response body's ownmodelsecond
(self-reported), credited only with evidence it came from the body —
langchain falls back to the requested name when the body carries none,
and a requested alias is never copied into serving metadata. Scorecards
carry aprovenance_status(verified/self_reported/
unattributed/serving_mismatch/no_calls); a live run whose
serving identity mismatches the requested model is refused as that
model's performance and preserved as a diagnostic; the benchmark table
gained an Attribution column with the five historical rows reading
"unverified" rather than receiving invented attribution; and a measured
auto-closure precision can no longer be bound to a call whose serving
model no party vouched for — a clearing measurement over an unattributed
call now blocks instead of authorising. -
Full-cohort and closure accounting in the replay scorer. Selective
malicious recall silently dropped errored cases from its denominator; the
scorer now also publishes full-cohort recall over every labelled
malicious case with the four buckets that sum to it (caught, abstained,
errored, answered-as-negative), the four proposed-closure cuts
(malicious closure rate, closure contamination, benign closure coverage,
unknown-label share —closures_executedpinned at zero because replay
executes nothing), request reliability as five visible counts
(requested / attempted / completed / failed / unattempted), per-severity
segments, and per-source attribution counts. -
The reflex path reports its stages and its decision contract.
Auto-triage summaries, logs and replay decisions now carry named stage
timings (queue delay, business context, context fetch, decide,
verification, persist, escalate, approvals, writeback) plus a structured
decision contract — verdict, operational disposition, supporting and
contradictory decisive claims, groundedness, missing prerequisites, and
the escalation reason — assembled from signals that already ran, with
Nonefor a signal that never ran rather than an empty list pretending
the check happened. -
Retro-hunt rehearsal mode.
RETRO_HUNT_DRY_RUNruns every sweep
exactly as live (the warehouse scan still consumes the tenant's sweep
budget) and withholds every mutation at the write boundary inside
sweep_tenant: matches come back as recorded proposals, no alert opens,
no sighting row lands, and the dedup branch leaves its counters
untouched — so a re-hunt can be rehearsed over history with no ability
to touch production state. -
A live-Redis proof for the correlator, and the suite that was wired to
nothing.integration.ymlgains afusion-redis-livejob (keyed on
the existing spine filter) runningservices/fusion/tests/integration/
against a real server — window expiry, tenant boundaries, restart
continuity, per-rule fallback grouping and concurrent-creator
convergence — which also gives the windowed engine's claim-protocol
suite its first workflow. An all-skipped run is refused rather than
green. -
The verdict model matrix.
run_model_matrix.py --task verdict
grades the balanced corpus per model throughrun_verdict_eval.py
(still a thin collator defining no scoring), records each row's
configuration — pins, endpoint, prompt provenance, corpus digest,
provenance status, serving models — and the runtime in
comparable-but-unidentifying terms, and renders the constant-answer
baselines beside the table. The committed verdict scorecard gained the
deterministic-fallback baseline row: balanced accuracy exactly at the
0.250 constant floor, zero of 36 malicious cases caught — the rules
floor every measured model is read against. -
An annotated unsupported-claim measurement. Sixteen hand-annotated
(rationale, evidence, label) cases measurescore_claim_supportwith an
exactly-pinned confusion matrix (TP 7, FP 0, FN 1, TN 8), a recorded
known miss that must stay missed until the pins move with it, and the
annotation-independence limit stated where the number lives. The
measurement found and drove a real detector fix: the claim pattern
recognised "scheduled scan" while the support keys did not, so an alert
whose evidence recordedscheduled_scancould state the claim and still
be refused support. -
Agentmetry ingest template: AI coding-agent activity as an input
(GitHub Discussion #1577). Agentmetry
records the tool calls Claude Code, Cursor, Codex and Copilot make through
their own hooks and correlates them into ATT&CK/ATLAS-mapped detections;
its webhook sink posts one canonical JSON event per request, routine
activity and detections down one URL. The discussion asked whether
generic-json "should land" them — checked against the real template with
real captured events: they land, but severity flattens to Unknown on every
event including critical detections (generic-json reads a top-level
severity; theirs lives ataction.outcome/detection.severity),
every routine tool call is promoted to an alert (class 2001 is always
promoted — one busy agent is a queue flood), and titles, technique ids and
the event timestamp are lost. The bundledagentmetrytemplate fixes
exactly that: the template language gainsclass_field/class_map(one
URL, per-event-kind class: tool calls stay 6003 API Activity in the lake,
action.type: detectionroutes to 2001 Security Finding, always promoted)
andtechnique_fields(vendor-supplied ATT&CK ids carried as supplied
through the samemitre_attckblock the connector path emits, shared via
the newly exportedEnrichTechniqueIDs/TechniqueIDsFromPaths; ATLAS
AML.T****ids are preserved in evidence fields and refused from
ATT&CK-typed ones, matching Agentmetry's own warning). The five-tier
severity ladder survives end to end — critical maps to its own tier,
never high. Agentmetry-observed coding agents feed the AI-estate
inventory and graph (agentmetryjoins theai-runtime/ai-finding
source-type branch in both consumers), the template is mintable from the
console catalog, and the docs page states plainly what was verified
against captured events versus what remains unexercised live. Also fixes
theai-estate.mdtable row that claimedai-findingis class 6003 —
the template is and was 2001. -
Asset criticality reaches fusion confidence and action approval —
tighten-only. Fusion stamps what each alert landed on at fuse time
(enrichments["asset_context"], resolved from the tenant's ownassets
inventory by name/FQDN/IP with the highest criticality winning) and the
confidence scorer gains a boundedasset_criticalityfactor (weight 0.06,
weights rebalanced to keep summing to 1.0) — an absent stamp contributes
exactly 0.0 and the rationale row saysunknown (no inventory match)
rather than being omitted, because "we do not know what this host is" is a
fact an analyst needs. On the response side,evaluate_contractgrades a
verb one impact rank higher when the target asset is critical/high in the
tenant's inventory — never lower, with two deliberate non-raises:
READ_ONLY stays READ_ONLY (a read against a domain controller is still a
read) and SEVERE never becomes IRREVERSIBLE (context can demand a human,
it cannot make an undoable action undoable). The positive control is the
point: unknown criticality grades every verb identically, so a deployment
with no CMDB loses nothing. Both approval doors thread the resolution;
the console's vendored copy stays byte-identical. -
Business context reaches the triage model, and impact statements must
cite it. The business-context applier's result used to die in the
worker's local variable: a tenantset_severitymutated the alert
silently, the model explained a severity nobody chose in the evidence,
and the persisted verdict carried no record of which rules fired. The
triage prompt now names the rules that fired and the mutation they made;
the verdict may carry one optionalbusiness_impactsentence whose
citations must resolve to those rule ids (uncited or fabricated elements
are dropped and counted, never rendered — and the instruction is injected
only when context exists, so an uninformed model never sees the element's
name); both travel to the ledger event and surface in the Investigation
Rail's new Business context section, which renders nothing when no
rule fired. Rule ids join the injection-scanned prompt channels. -
Asset criticality orders the investigation queue. The priority scorer
(severity as base, asset criticality / exploited-vulnerability / internet
exposure as bounded multipliers) existed with zero callers, so two alerts
of equal severity — one on a domain controller, one on a meeting-room
display — arrived in the order they were raised.build_queuenow
resolves each page against the tenant's ownassetsinventory in two
batched queries (hostname/FQDN/IP-overlap match;is_exploited AND NOT remediatedas the KEV signal) and orders within (bucket, SLA) by the
score. Every row carriespriority_scoreand a factor-by-factor
priority_basis; no inventory means severity alone, and the basis says
so. Proven on live Postgres with a negative control that removes the
resolver and watches the order collapse. -
An opt-in ack-clock escalation bridge (
services/escalations, compose
profileescalations; externally contributed). The native outbound-webhook
subsystem is complete but itsenqueue_eventhas no production callers, so
a CRITICAL case nobody acknowledged produced no notification at all. The
bridge watchesaisoc_cases.acknowledged_at: unacknowledged CRITICAL cases
get a Teams card after a grace period with capped re-nags, unacknowledged
HIGH cases arrive as one daily digest with a pipeline-health line, and
dedup/nag state survives restarts. Deployment-wide by design (one
operator-configured webhook; per-tenant routing stays the native
subsystem's job, into which this retires when the emitters are wired) and
the webhook must be https. The sweep's cross-tenant reads are recorded on
the tenant-predicate ratchet with that reasoning, the service is
SLO-exempt with its reason, and its status filter is pinned against the
case table's own CHECK constraint by test.
Changed
-
A missing identifier narrows the correlation group instead of widening
it. Neither detection engine has ever emittedmitre_tactics(the
ruleset carries technique ids only), so every detection alert's
correlation key fell back to the literalunknowntactic and all of an
entity's detection alerts collapsed into one incident per window;
entity-less alerts merged tenant-wide. The fallback now scopes to the
detection rule with an entity, never groups across rules without one,
and groups only exact fingerprint repeats with no identifiers at all.
Measured on a fixed detection-shaped cohort: 42.6 % reduction with 36
cross-rule incident groups before, 24.5 % with zero after — the honest
direction, because the difference was unrelated alerts counted as
grouped. The tactic-carrying headline cohort is unchanged at 33.3 %. The
correlator's creation race is closed withSET NX(concurrent first
arrivals converge on one incident instead of orphaning an alert); the
narrower merge/merge window is documented, not closed. -
Verdict scorecards disclose the model-knowledge limit. Evidence
cutoffs constrain supplied context only; each scorecard now records the
model's training cutoff when the operator states one andunknown
otherwise, with the disclosure that pretrained weights may postdate the
replayed events.
Fixed
-
The rail's business-context leg reads the event the writer actually
writes. Its query filteredkind LIKE 'auto_triage:%'while the ledger's
verdict INSERT hardcodeskind = 'triage_verdict'(theauto_triage:{tier}
string lives in theagentcolumn) — so the section could never render on
a real deployment while every suite stayed green, because the live test had
seeded the assumed shape rather than the writer's. Found by the end-to-end
QA slice (console rule row -> real worker -> real ledger -> rail), fixed,
and the literal is now pinned on both sides: the writer's recording-pool
test asserts the executed INSERT carries'triage_verdict'and the
business keys land in the payload, and the reader's live test seeds that
same literal. -
The context graph's business dimension authenticates. The bundle's
incident-context fetch sentX-Tenant-ID(a header the API's auth never
reads) with an optionalAISOC_AGENTS_API_KEYbearer that no compose
file,.env.exampleor Helm value delivers — so the graph's
identity/asset/cloud/business/threat dimensions answered 401 on every
default deployment and the prompt said "unavailable" forever. It now
presentsAISOC_AGENTS_SERVICE_TOKEN(orAISOC_SERVICE_TOKEN) with
X-AiSOC-Tenant-ID, and an unconfigured token refuses without dialing so
the dimension reads unavailable, never empty. A vestigial copy of the
dead credential block was removed from the investigation tool client. -
The rule builder could only author rules that never match. Every
field it suggested was prefixedalert.against engines that resolve
plain dotted paths (hostname,severity,raw_event.*), its starter
rule used an inventedtarget/timestructure, it offered aneq
operator neither engine implements (the term isne) — and the preview
endpoint manufactured a nestedalert.{target,time}shape so the
broken paths previewed as working and then never fired in production.
The suggestion list is now the fused alert's real vocabulary, pinned on
both sides (a web test and an api test naming the same literals), and
the preview sample is shaped exactly like the alert the worker evaluates
(raw_eventunder its real key, notmetadata).
Security
-
Dependencies: the Oct-31
minimatch/globadvisory wave worked
across all three npm lockfiles (root pnpm,apps/mobilepnpm,
services/realtimenpm). Both overrides are range-scoped to>=9.0.0,
which is the crux of the fix rather than a detail:minimatchfloors at
11.0.1 (resolves 11.0.3) for
GHSA-g3hv-75wg-m249
(CVE-2026-118764, medium — globparts-amplification ReDoS) andglobat
14.0.2 (resolves 14.0.3) for
GHSA-3g3m-2fjr-6pw4
(medium) — but the legacyglob@7.2.3(not vulnerable; <9) and its
minimatch@3.1.5are left in place, becauseglob@7 + minimatch@11is
broken (a blanket minimatch floor forced that pairing and broke
react-native 0.81's iOS codegen with acharAtTypeError). The
still-<11minimatchcopies that remain (3.1.5, 5.1.9) are all
lint/build tooling — eslint 9 and its plugins,@redocly/openapi-core,
glob@7via react-native/expo codegen,test-exclude,serve-handler—
none shipped in a runtime bundle, none fed attacker-controlled patterns,
and none upgradable without breaking the toolchain that pins them;
tracked for a tolerable-risk dismissal pending upstream support. Verified:
all three frozen installs pass andservices/realtime(the one runtime
service touched) builds clean onminimatch@11. -
Dependencies: the Oct-30 npm advisory wave cleared across both
lockfiles. Root:follow-redirectsfloored at 1.16.2
(GHSA-mrwp-f3q8-3g9p,
high) andjoiat 17.13.9
(GHSA-4528-x5fx-h6hg,
high) — both already carried overrides from earlier waves, so the existing
floors move rather than new keys landing (a duplicate key in
pnpm.overridesis silently discarded by last-key-wins, which is exactly
how a first attempt at this fix produced an unchanged lockfile). Mobile:
node-forgeoverridden to 1.4.1+ (resolves 1.4.2), clearing five high
and three low advisories
(GHSA-7wch-p9px-956x
et al., all fixed in 1.4.1). Published aftermain's last audit pass, so
they reddened the push-eventSecurity Auditat the v20.4.0 release
commit through no change of its own.