diff --git a/src/nrdax/exporters/stix_exporter.py b/src/nrdax/exporters/stix_exporter.py index 2e673cc..7b1870a 100644 --- a/src/nrdax/exporters/stix_exporter.py +++ b/src/nrdax/exporters/stix_exporter.py @@ -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. """ @@ -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} @@ -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}", @@ -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, @@ -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 { @@ -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") @@ -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 diff --git a/tests/fixtures/feed/stix.json b/tests/fixtures/feed/stix.json index 0d51b96..8c5a7a6 100644 --- a/tests/fixtures/feed/stix.json +++ b/tests/fixtures/feed/stix.json @@ -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.", @@ -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", @@ -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", @@ -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", diff --git a/tests/test_stix.py b/tests/test_stix.py index 50c28d6..7c65b4a 100644 --- a/tests/test_stix.py +++ b/tests/test_stix.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses import uuid from nrdax.exporters import stix_bundle, stix_dumps, technique_ids, validate_bundle @@ -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) == []