Skip to content

Commit aeea237

Browse files
author
Simon Morley
committed
Mechanism taxonomy: nullable family, and stop grouping the pending
Tracks the registry's contract change. `family` is now the published mechanism taxonomy and is nullable: a technique the registry has not classified is served null with classification "pending". The producer's own clustering label moves to `producer_family`. The fix that matters is the grouping bug. `techniques_by_family()` and `family_siblings` compared families directly, and `None == None` is true, so every unclassified technique became a sibling of every other one - 323 of 420 against the live registry, presented to a caller as a real relationship. The same shape was in the search haystack, which joined a None into a string and raised. Grouping now requires a known mechanism, and the index simply does not key on None. A null family is also no longer a validation issue. Flagging it made the registry's deliberate, published gap look like a data defect. Both axes stay queryable and separate: families() is the mechanism axis (all five, zero counts included), producer_families() the producer vocabulary, and FamilyCount carries `axis` so a count is never read against the wrong one. `memory_amp` means one thing on each and the counts differ. STIX export verified byte-identical to the Rust backend on every x_nrdax property including the pending technique's nulls. 142 tests, ruff and mypy clean. Checked against the live registry: 97 classified / 323 pending, family counts match, a pending technique has zero siblings, and validate() reports no issues.
1 parent 33983a5 commit aeea237

22 files changed

Lines changed: 629 additions & 137 deletions

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,44 @@ compatibility policy.
1010

1111
## [Unreleased]
1212

13+
## [0.4.0] - 2026-07-25
14+
15+
### Changed
16+
17+
- **Breaking (tracks the API):** `Technique.family` is now the published *mechanism*
18+
taxonomy and is `str | None`. It is `None` while the registry has not classified a
19+
technique, which on the live registry is 323 of 420. The producing pipeline's own
20+
label moves to the new `producer_family` field. Code doing `t.family == "..."` or
21+
grouping on `t.family` must handle `None`; `Technique.is_classified` is provided
22+
for the intent.
23+
- `NRDAX.families()` now returns the five mechanism families (always all five,
24+
zero counts included). The producer vocabulary moved to `NRDAX.producer_families()`.
25+
`FamilyCount` gains `axis` (`"mechanism"` or `"producer-class"`), because a name
26+
such as `memory_amp` occurs on both axes with different counts.
27+
- `by_family()` and `--family` filter the mechanism axis. `by_producer_family()` and
28+
`--producer-family` filter the producer axis.
29+
- Search matches both axes; STIX export carries `x_nrdax_producer_family`,
30+
`x_nrdax_surface`, `x_nrdax_bound_failure` and `x_nrdax_classification` alongside
31+
`x_nrdax_family`, byte-identical to the backend emitter.
32+
33+
### Fixed
34+
35+
- **`techniques_by_family()` and `family_siblings` no longer group unclassified
36+
techniques together.** Both compared families directly, and since `None == None`
37+
every pending technique was a "sibling" of every other one - 323 of them against
38+
the live registry. Grouping now requires a known mechanism.
39+
- A `null` family is no longer reported as a validation issue. A pending technique is
40+
well-formed, and flagging it made the registry's own honest gap look like a data
41+
defect.
42+
- The CLI renders `(pending classification)` rather than a bare `None`.
43+
44+
### Added
45+
46+
- `MECHANISM_FAMILIES`, `SURFACES`, `BOUND_FAILURES` and `CLASSIFICATION_STATES`
47+
vocabularies; `Technique.surface`, `.bound_failure`, `.dual_with`, `.classification`
48+
and `.is_classified`; `NRDAX.classified()`, `.unclassified()` and
49+
`.techniques_by_producer_family()`.
50+
1351
## [0.3.0] - 2026-07-16
1452

1553
### Changed

src/nrdax/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77

88
from __future__ import annotations
99

10-
__version__ = "0.3.0"
10+
__version__ = "0.4.0"

src/nrdax/cli/formatting.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ def technique_detail(t: Technique) -> str:
5757
f"{t.id} {t.display}",
5858
"=" * (len(t.id) + 2 + len(t.display)),
5959
f"slug (name) {t.name}",
60-
f"family {t.family}",
60+
f"family {t.family or '(pending classification)'}",
61+
f"producer family {t.producer_family or '-'}",
62+
f"surface {t.surface or '-'}",
63+
f"bound failure {t.bound_failure or '-'}",
6164
f"status {t.status}",
6265
f"reproduction {t.reproduction_status}",
6366
f"first seen {t.first_seen}",
@@ -111,7 +114,15 @@ def search_table(results: list[SearchResult], *, explain: bool = False) -> str:
111114
rows = []
112115
for r in results:
113116
t = r.technique
114-
row = [f"{r.score:g}", t.id, truncate(t.display, 50), t.family, t.reproduction_status]
117+
# `family` is None while a technique is pending classification; render the
118+
# state rather than a bare "None" in the table cell.
119+
row = [
120+
f"{r.score:g}",
121+
t.id,
122+
truncate(t.display, 50),
123+
t.family or "(pending)",
124+
t.reproduction_status,
125+
]
115126
if explain:
116127
row.append(",".join(r.matched_fields))
117128
rows.append(row)

src/nrdax/cli/main.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
DISCOVERY_ORIGINS,
3434
FAMILIES,
3535
FIDELITY_CLASSES,
36+
MECHANISM_FAMILIES,
3637
NRDAX_SCHEMA_VERSION,
3738
REFERENCE_KINDS,
3839
REPRODUCTION_STATUSES,
@@ -104,6 +105,7 @@ def _predicate_kwargs(args: argparse.Namespace) -> dict[str, Any]:
104105
kw: dict[str, Any] = {}
105106
for name in (
106107
"family",
108+
"producer_family",
107109
"chain",
108110
"status",
109111
"fidelity",
@@ -380,6 +382,11 @@ def cmd_cache(args: argparse.Namespace) -> int:
380382
"display_name",
381383
"mechanism",
382384
"family",
385+
"producer_family",
386+
"surface",
387+
"bound_failure",
388+
"dual_with",
389+
"classification",
383390
"status",
384391
"first_seen",
385392
"instances",
@@ -456,7 +463,16 @@ def _add_source_arg(p: argparse.ArgumentParser) -> None:
456463

457464

458465
def _add_filter_args(p: argparse.ArgumentParser) -> None:
459-
p.add_argument("--family", choices=FAMILIES, help="exact family")
466+
p.add_argument(
467+
"--family",
468+
choices=MECHANISM_FAMILIES,
469+
help="exact mechanism family (the published taxonomy)",
470+
)
471+
p.add_argument(
472+
"--producer-family",
473+
choices=FAMILIES,
474+
help="exact producer family (the producing pipeline's own label)",
475+
)
460476
p.add_argument("--chain", help="techniques with a reproduced instance on this chain")
461477
p.add_argument("--status", choices=STATUSES, help="lifecycle status")
462478
p.add_argument("--fidelity", choices=FIDELITY_CLASSES, help="any instance with this fidelity")

src/nrdax/exporters/stix_exporter.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
Reproduces the backend's deterministic scheme (``stix/mod.rs``): ids are UUIDv5
44
under a fixed namespace, timestamps come from ``first_seen`` (no wall-clock), the
55
NRDAX id is anchored in ``external_references`` (``source_name: "nrdax"``), and the
6-
NRDAX-specific fields ride as custom properties ``x_nrdax_family`` /
7-
``x_nrdax_status`` / ``x_nrdax_chains``. Keys are alphabetically sorted and the
6+
NRDAX-specific fields ride as custom properties: ``x_nrdax_family`` (the published
7+
mechanism taxonomy, null while pending), ``x_nrdax_producer_family``,
8+
``x_nrdax_surface``, ``x_nrdax_bound_failure``, ``x_nrdax_classification``,
9+
``x_nrdax_status`` and ``x_nrdax_chains``. Keys are alphabetically sorted and the
810
document is 2-space-indented with a trailing newline, matching the feed's
911
``stix.json`` exactly — so a bundle exported here is identical to the one served.
1012
"""
@@ -61,6 +63,10 @@ def attack_pattern(technique: Technique) -> dict[str, Any]:
6163
"description": technique.mechanism,
6264
"external_references": refs,
6365
"x_nrdax_family": technique.family,
66+
"x_nrdax_producer_family": technique.producer_family,
67+
"x_nrdax_surface": technique.surface,
68+
"x_nrdax_bound_failure": technique.bound_failure,
69+
"x_nrdax_classification": technique.classification,
6470
"x_nrdax_status": technique.status,
6571
"x_nrdax_chains": chains,
6672
}

src/nrdax/models.py

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@
2323

2424
from .errors import IssueCollector
2525
from .vocab import (
26+
BOUND_FAILURES,
2627
DISCOVERY_ORIGINS,
2728
FAMILIES,
2829
FIDELITY_CLASSES,
30+
MECHANISM_FAMILIES,
2931
NRDAX_SITE,
3032
REFERENCE_KINDS,
3133
STATUSES,
34+
SURFACES,
3235
TECHNIQUE_ID_PATTERN,
3336
fidelity_strength,
3437
)
@@ -172,26 +175,56 @@ def fidelity_strength(self) -> int:
172175
class Technique:
173176
"""The citable unit. Opaque stable ``id``; ``family`` is an attribute (a
174177
technique can be reclassified without its id changing); zero or more instances
175-
and external references."""
178+
and external references.
179+
180+
``family`` is the published MECHANISM taxonomy and is ``None`` while the registry
181+
has not classified the technique - it is never inferred from ``producer_family``,
182+
because the producer's surface-defined labels have no honest mechanical target.
183+
Anything grouping by family must treat ``None`` as unknown rather than as a shared
184+
value; see :meth:`is_classified`.
185+
"""
176186

177187
id: str
178188
name: str
179189
mechanism: str
180-
family: str
190+
#: Published mechanism family, or None while pending classification.
191+
family: str | None
181192
status: str
182193
first_seen: str
183194
display_name: str | None = None
195+
#: The producing pipeline's own clustering label. Provenance, not the taxonomy.
196+
producer_family: str | None = None
197+
#: Where the attacker's input enters. Present only when classified.
198+
surface: str | None = None
199+
#: Why the node's bound failed to apply. Present only when classified.
200+
bound_failure: str | None = None
201+
#: Secondary family where a technique is genuinely dual and no reproduction
202+
#: measured which resource binds first.
203+
dual_with: str | None = None
204+
#: ``curated`` or ``pending``; always present on a current registry response.
205+
classification: str = "pending"
184206
instances: list[Instance] = field(default_factory=list)
185207
external_references: list[ExternalReference] = field(default_factory=list)
186208
provenance_note: str | None = None
187209
extra: dict[str, Any] = field(default_factory=dict)
188210

211+
@property
212+
def is_classified(self) -> bool:
213+
"""Whether this technique carries a mechanism family. Prefer this over
214+
truth-testing ``family``: it states the intent at the call site."""
215+
return self.family is not None
216+
189217
_KNOWN: ClassVar[set[str]] = {
190218
"id",
191219
"name",
192220
"display_name",
193221
"mechanism",
194222
"family",
223+
"producer_family",
224+
"surface",
225+
"bound_failure",
226+
"dual_with",
227+
"classification",
195228
"status",
196229
"first_seen",
197230
"instances",
@@ -211,9 +244,34 @@ def from_dict(
211244
issues.add(f"{locator}.id", f"id does not match {TECHNIQUE_ID_PATTERN}")
212245
name = _require_str(data, "name", issues, locator)
213246
mechanism = _require_str(data, "mechanism", issues, locator)
214-
family = _require_str(data, "family", issues, locator)
215-
if issues is not None and family and family not in FAMILIES:
216-
issues.add(f"{locator}.family", f"unknown family {family!r}", "warning")
247+
# `family` is nullable by contract: a technique the registry has not
248+
# classified is served null with classification "pending". That is a
249+
# well-formed record, not a malformed one, so an absent family is never
250+
# reported as an issue - only a present-but-unknown one.
251+
family = _optional_str(data, "family", issues, locator)
252+
if issues is not None and family and family not in MECHANISM_FAMILIES:
253+
issues.add(f"{locator}.family", f"unknown mechanism family {family!r}", "warning")
254+
producer_family = _optional_str(data, "producer_family", issues, locator)
255+
if issues is not None and producer_family and producer_family not in FAMILIES:
256+
issues.add(
257+
f"{locator}.producer_family",
258+
f"unknown producer family {producer_family!r}",
259+
"warning",
260+
)
261+
surface = _optional_str(data, "surface", issues, locator)
262+
if issues is not None and surface and surface not in SURFACES:
263+
issues.add(f"{locator}.surface", f"unknown surface {surface!r}", "warning")
264+
bound_failure = _optional_str(data, "bound_failure", issues, locator)
265+
if issues is not None and bound_failure and bound_failure not in BOUND_FAILURES:
266+
issues.add(
267+
f"{locator}.bound_failure",
268+
f"unknown bound failure {bound_failure!r}",
269+
"warning",
270+
)
271+
dual_with = _optional_str(data, "dual_with", issues, locator)
272+
classification = _optional_str(data, "classification", issues, locator) or (
273+
"curated" if family else "pending"
274+
)
217275
status = _require_str(data, "status", issues, locator)
218276
if issues is not None and status and status not in STATUSES:
219277
issues.add(f"{locator}.status", f"unknown status {status!r}", "warning")
@@ -244,6 +302,11 @@ def from_dict(
244302
status=status,
245303
first_seen=first_seen,
246304
display_name=display_name,
305+
producer_family=producer_family,
306+
surface=surface,
307+
bound_failure=bound_failure,
308+
dual_with=dual_with,
309+
classification=classification,
247310
instances=instances,
248311
external_references=refs,
249312
provenance_note=provenance_note,
@@ -257,6 +320,15 @@ def to_dict(self) -> dict[str, Any]:
257320
out["display_name"] = self.display_name
258321
out["mechanism"] = self.mechanism
259322
out["family"] = self.family
323+
if self.producer_family is not None:
324+
out["producer_family"] = self.producer_family
325+
if self.surface is not None:
326+
out["surface"] = self.surface
327+
if self.bound_failure is not None:
328+
out["bound_failure"] = self.bound_failure
329+
if self.dual_with is not None:
330+
out["dual_with"] = self.dual_with
331+
out["classification"] = self.classification
260332
out["status"] = self.status
261333
out["first_seen"] = self.first_seen
262334
out["instances"] = [i.to_dict() for i in self.instances]
@@ -410,7 +482,13 @@ def to_dict(self) -> dict[str, Any]:
410482

411483
@dataclass(frozen=True)
412484
class FamilyCount:
413-
"""A family and how many techniques currently carry it."""
485+
"""A family and how many techniques currently carry it.
486+
487+
``axis`` says which taxonomy the name belongs to: ``mechanism`` for the published
488+
families, ``producer-class`` for the producing pipeline's own labels. A name such
489+
as ``memory_amp`` occurs on both axes with different counts, so a count read
490+
without its axis is meaningless."""
414491

415492
name: str
416493
technique_count: int
494+
axis: str = "mechanism"

src/nrdax/queries/filters.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,21 @@ def _validate(value: str, allowed: tuple[str, ...], what: str) -> str:
3535

3636

3737
def by_family(name: str) -> Predicate:
38-
return lambda t: t.family == name
38+
"""Match on the published MECHANISM family. A technique pending classification
39+
has ``family is None`` and matches nothing here - never every other pending
40+
technique, which a bare ``==`` comparison would give you."""
41+
return lambda t: t.family is not None and t.family == name
42+
43+
44+
def by_producer_family(name: str) -> Predicate:
45+
"""Match on the producing pipeline's own label, a different axis from
46+
:func:`by_family`."""
47+
return lambda t: t.producer_family is not None and t.producer_family == name
48+
49+
50+
def unclassified() -> Predicate:
51+
"""Match techniques with no mechanism family yet."""
52+
return lambda t: t.family is None
3953

4054

4155
def by_status(status: str) -> Predicate:
@@ -111,6 +125,7 @@ def all_of(*predicates: Predicate) -> Predicate:
111125
def build_predicate(
112126
*,
113127
family: str | None = None,
128+
producer_family: str | None = None,
114129
status: str | None = None,
115130
chain: str | None = None,
116131
fidelity: str | None = None,
@@ -126,6 +141,8 @@ def build_predicate(
126141
preds: list[Predicate] = []
127142
if family is not None:
128143
preds.append(by_family(family))
144+
if producer_family is not None:
145+
preds.append(by_producer_family(producer_family))
129146
if status is not None:
130147
preds.append(by_status(status))
131148
if chain is not None:

src/nrdax/queries/search.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ def _field_texts(t: Technique) -> dict[str, list[str]]:
5757
"id": [t.id],
5858
"name": [t.name],
5959
"display_name": [t.display_name] if t.display_name else [],
60-
"family": [t.family],
60+
# Both taxonomy axes are searchable. `family` is None while a technique is
61+
# pending classification, so it is filtered rather than joined blindly.
62+
"family": [x for x in (t.family, t.producer_family) if x],
6163
"chain": [i.chain for i in t.instances],
6264
"primitive_id": [i.primitive_id for i in t.instances],
6365
"reference": [ref.id for _, ref in t.iter_references()]

0 commit comments

Comments
 (0)