Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions src/nrdax/exporters/stix_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
under a fixed namespace, timestamps come from ``first_seen`` (no wall-clock), the
NRDAX id is anchored in ``external_references`` (``source_name: "nrdax"``), and the
NRDAX-specific fields ride as custom properties: ``x_nrdax_family`` (the published
mechanism taxonomy, null while pending), ``x_nrdax_producer_family``,
mechanism taxonomy, omitted while pending), ``x_nrdax_producer_family``,
``x_nrdax_surface``, ``x_nrdax_bound_failure``, ``x_nrdax_classification``,
``x_nrdax_status`` and ``x_nrdax_chains``. Keys are alphabetically sorted and the
``x_nrdax_status`` and ``x_nrdax_chains``. A property with no value (a pending
technique's classification axes, the chains of one with no instance) is omitted, since
STIX 2.1 forbids null properties and empty lists. Keys are alphabetically sorted and the
document is 2-space-indented with a trailing newline, matching the feed's
``stix.json`` exactly — so a bundle exported here is identical to the one served.
"""
Expand All @@ -25,6 +27,28 @@
#: Advertised STIX content type.
CONTENT_TYPE = "application/stix+json;version=2.1"

#: The registry data license, carried as a STIX statement marking-definition that every
#: attack-pattern references (matches the backend's ``LICENSE_STATEMENT``).
LICENSE_STATEMENT = (
"CC-BY-4.0 (https://creativecommons.org/licenses/by/4.0/). Attribution: NullRabbit Labs."
)

#: Deterministic id of the license marking-definition (UUIDv5, as the backend).
LICENSE_MARKING_ID = f"marking-definition--{uuid.uuid5(NAMESPACE, 'license:cc-by-4.0')}"


def license_marking() -> dict[str, Any]:
"""The CC-BY-4.0 marking-definition. ``created`` is fixed (the registry's epoch), so
the bundle carries no wall-clock and stays byte-identical to the backend's."""
return {
"type": "marking-definition",
"spec_version": "2.1",
"id": LICENSE_MARKING_ID,
"created": "2025-01-01T00:00:00.000Z",
"definition_type": "statement",
"definition": {"statement": LICENSE_STATEMENT},
}


def _reference(kind: str, ref_id: str, url: str | None) -> dict[str, Any]:
obj: dict[str, Any] = {"source_name": kind, "external_id": ref_id}
Expand Down Expand Up @@ -53,7 +77,7 @@ def attack_pattern(technique: Technique) -> dict[str, Any]:

chains = sorted({inst.chain for inst in technique.instances})

return {
obj: dict[str, Any] = {
"type": "attack-pattern",
"spec_version": "2.1",
"id": f"attack-pattern--{ap_uuid}",
Expand All @@ -62,6 +86,7 @@ def attack_pattern(technique: Technique) -> dict[str, Any]:
"name": technique.display,
"description": technique.mechanism,
"external_references": refs,
"object_marking_refs": [LICENSE_MARKING_ID],
"x_nrdax_family": technique.family,
"x_nrdax_producer_family": technique.producer_family,
"x_nrdax_surface": technique.surface,
Expand All @@ -70,11 +95,16 @@ def attack_pattern(technique: Technique) -> dict[str, Any]:
"x_nrdax_status": technique.status,
"x_nrdax_chains": chains,
}
# STIX 2.1 forbids null properties and empty lists: a pending technique omits its
# classification axes (x_nrdax_classification says why) and one with no instance
# omits its chains, exactly as the backend does.
return {k: v for k, v in obj.items() if v is not None and v != []}


def stix_bundle(techniques: list[Technique], version: str) -> dict[str, Any]:
"""A STIX bundle of the given techniques, with a deterministic bundle id."""
objects = [attack_pattern(t) for t in techniques]
# The license marking leads the bundle; every attack-pattern references it.
objects = [license_marking(), *(attack_pattern(t) for t in techniques)]
seed = version + "".join(f"|{t.id}" for t in techniques)
bundle_id = uuid.uuid5(NAMESPACE, seed)
return {
Expand Down Expand Up @@ -124,6 +154,7 @@ def validate_bundle(bundle: dict[str, Any]) -> list[str]:
if not isinstance(obj, dict):
errors.append(f"objects[{i}] must be an object")
continue
_null_and_empty_list_violations(obj, f"objects[{i}]", errors)
otype = obj.get("type")
if not otype:
errors.append(f"objects[{i}].type is required")
Expand All @@ -143,6 +174,23 @@ def validate_bundle(bundle: dict[str, Any]) -> list[str]:
return errors


def _null_and_empty_list_violations(value: Any, path: str, errors: list[str]) -> None:
"""STIX 2.1 core rules: no property may be null and no list may be empty, at any
depth (a property is omitted instead)."""
if isinstance(value, dict):
for key, v in value.items():
here = f"{path}.{key}"
if v is None:
errors.append(f"{here} must not be null")
elif isinstance(v, list) and not v:
errors.append(f"{here} must not be an empty list")
else:
_null_and_empty_list_violations(v, here, errors)
elif isinstance(value, list):
for j, v in enumerate(value):
_null_and_empty_list_violations(v, f"{path}[{j}]", errors)


def _is_stix_id(value: Any, expected_type: str) -> bool:
if not isinstance(value, str) or "--" not in value:
return False
Expand Down
24 changes: 20 additions & 4 deletions tests/fixtures/feed/stix.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
{
"id": "bundle--67557f41-343e-51af-9835-f00aa26a370f",
"objects": [
{
"created": "2025-01-01T00:00:00.000Z",
"definition": {
"statement": "CC-BY-4.0 (https://creativecommons.org/licenses/by/4.0/). Attribution: NullRabbit Labs."
},
"definition_type": "statement",
"id": "marking-definition--42394f69-0f7f-5bdf-b0de-c272325fae94",
"spec_version": "2.1",
"type": "marking-definition"
},
{
"created": "2025-01-15T00:00:00.000Z",
"description": "A single unauthenticated JSON-RPC getBlock request elicits a response many times the request size, amplifying an attacker's uplink against the validator's RPC surface.",
Expand Down Expand Up @@ -29,6 +39,9 @@
"id": "attack-pattern--b3bcddec-2c42-5bfd-a0fa-f75afef5d7d1",
"modified": "2025-01-15T00:00:00.000Z",
"name": "RPC getBlock response amplification",
"object_marking_refs": [
"marking-definition--42394f69-0f7f-5bdf-b0de-c272325fae94"
],
"spec_version": "2.1",
"type": "attack-pattern",
"x_nrdax_bound_failure": "no-bound",
Expand Down Expand Up @@ -60,17 +73,17 @@
"id": "attack-pattern--7822d5dc-db48-5d5e-9e3c-ead08205c8ad",
"modified": "2025-02-20T00:00:00.000Z",
"name": "TCP connection table exhaustion",
"object_marking_refs": [
"marking-definition--42394f69-0f7f-5bdf-b0de-c272325fae94"
],
"spec_version": "2.1",
"type": "attack-pattern",
"x_nrdax_bound_failure": null,
"x_nrdax_chains": [
"sui"
],
"x_nrdax_classification": "pending",
"x_nrdax_family": null,
"x_nrdax_producer_family": "connection_exhaustion",
"x_nrdax_status": "active",
"x_nrdax_surface": null
"x_nrdax_status": "active"
},
{
"created": "2024-11-02T00:00:00.000Z",
Expand All @@ -90,6 +103,9 @@
"id": "attack-pattern--65a8e474-ff40-588b-a8a1-11cce1526876",
"modified": "2024-11-02T00:00:00.000Z",
"name": "Costly RPC handler CPU amplification",
"object_marking_refs": [
"marking-definition--42394f69-0f7f-5bdf-b0de-c272325fae94"
],
"spec_version": "2.1",
"type": "attack-pattern",
"x_nrdax_bound_failure": "no-bound",
Expand Down
71 changes: 71 additions & 0 deletions tests/test_stix.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import dataclasses
import uuid

from nrdax.exporters import stix_bundle, stix_dumps, technique_ids, validate_bundle
Expand Down Expand Up @@ -52,3 +53,73 @@ def test_bundle_id_is_deterministic(fixture_registry):
b = stix_bundle(list(fixture_registry), "v1.0")["id"]
c = stix_bundle(list(fixture_registry), "v2.0")["id"]
assert a == b and a != c


def test_validate_flags_null_properties_and_empty_lists():
# STIX 2.1 (and the OASIS validator): no property may be null and no list empty,
# at any depth. The backend's validate_bundle enforces the same rule.
def bundle(**extra):
ap = {
"type": "attack-pattern",
"spec_version": "2.1",
"id": "attack-pattern--b3bcddec-2c42-5bfd-a0fa-f75afef5d7d1",
"created": "2025-01-01T00:00:00.000Z",
"modified": "2025-01-01T00:00:00.000Z",
"name": "n",
**extra,
}
return {
"type": "bundle",
"id": "bundle--b3bcddec-2c42-5bfd-a0fa-f75afef5d7d1",
"objects": [ap],
}

assert validate_bundle(bundle()) == []
assert any("x_nrdax_family" in e for e in validate_bundle(bundle(x_nrdax_family=None)))
assert any("x_nrdax_chains" in e for e in validate_bundle(bundle(x_nrdax_chains=[])))
assert validate_bundle(bundle(external_references=[{"source_name": "s", "url": None}])), (
"the rule holds inside nested objects too"
)


def test_pending_technique_without_instances_omits_rather_than_nulls(fixture_registry):
# The dominant production shape: classification pending, no reproduced instance,
# and here no producer label either. Each such property is omitted, not null/[].
t = dataclasses.replace(
fixture_registry.get("NRDAX-T0002"),
family=None,
surface=None,
bound_failure=None,
producer_family=None,
classification="pending",
instances=[],
)
ap = attack_pattern(t)
for absent in (
"x_nrdax_family",
"x_nrdax_surface",
"x_nrdax_bound_failure",
"x_nrdax_producer_family",
"x_nrdax_chains",
):
assert absent not in ap, f"{absent} is omitted, not null/empty"
assert ap["x_nrdax_classification"] == "pending"
bundle = {
"type": "bundle",
"id": "bundle--b3bcddec-2c42-5bfd-a0fa-f75afef5d7d1",
"objects": [ap],
}
assert validate_bundle(bundle) == []


def test_bundle_carries_the_cc_by_license_marking_every_technique_references(fixture_registry):
# Same as the backend: a statement marking-definition leads the bundle and every
# attack-pattern references it, so TIP consumers see the CC-BY-4.0 reuse terms.
bundle = stix_bundle(list(fixture_registry), fixture_registry.version)
marking = bundle["objects"][0]
assert marking["type"] == "marking-definition"
assert marking["definition_type"] == "statement"
assert "CC-BY-4.0" in marking["definition"]["statement"]
for ap in bundle["objects"][1:]:
assert ap["object_marking_refs"] == [marking["id"]]
assert validate_bundle(bundle) == []
Loading