nrdax 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nrdax/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """nrdax - the open-source Python library and CLI for the NRDAX registry.
2
+
3
+ NRDAX (NullRabbit Decentralised Attack indeX) is NullRabbit's canonical,
4
+ chain-agnostic registry of techniques for attacks on decentralised infrastructure.
5
+ This package is the standard programmatic interface to the public NRDAX dataset:
6
+ load it, search and filter it, traverse relationships, export it (JSON / CSV /
7
+ STIX), cite it, and inspect changes.
8
+
9
+ Quick start::
10
+
11
+ from nrdax import NRDAX
12
+ registry = NRDAX.load() # bundled snapshot, offline
13
+ technique = registry.get("NRDAX-T0006")
14
+ results = registry.search("rpc exhaustion")
15
+ related = registry.related("NRDAX-T0006")
16
+
17
+ The CLI (``nrdax``) is built entirely on this library.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from ._version import __version__
23
+ from .citations import CitationData, cite
24
+ from .errors import (
25
+ DataFormatError,
26
+ ExitCode,
27
+ InvalidArgumentError,
28
+ NotFoundError,
29
+ NrdaxError,
30
+ SchemaVersionError,
31
+ SourceError,
32
+ ValidationError,
33
+ ValidationIssue,
34
+ )
35
+ from .models import (
36
+ CoverageCell,
37
+ CoverageMatrix,
38
+ ExternalReference,
39
+ FamilyCount,
40
+ Instance,
41
+ KnownCell,
42
+ KnownCoverage,
43
+ Technique,
44
+ )
45
+ from .queries.search import SearchResult
46
+ from .registry import NRDAX
47
+ from .relationships import RelatedResult
48
+ from .sources import RawDataset, Source, SourceMeta
49
+ from .sources.api import ApiSource
50
+ from .sources.bundled import BundledSource
51
+ from .sources.feed import FeedSource
52
+ from .sources.file import FileSource
53
+ from .sources.memory import MemorySource
54
+ from .sources.stix import StixSource
55
+ from .vocab import (
56
+ DISCOVERY_ORIGINS,
57
+ FAMILIES,
58
+ FIDELITY_CLASSES,
59
+ NRDAX_API,
60
+ NRDAX_SCHEMA_VERSION,
61
+ NRDAX_SITE,
62
+ REFERENCE_KINDS,
63
+ REPRODUCTION_STATUSES,
64
+ STATUSES,
65
+ )
66
+
67
+ __all__ = [
68
+ "__version__",
69
+ # facade
70
+ "NRDAX",
71
+ # models
72
+ "Technique",
73
+ "Instance",
74
+ "ExternalReference",
75
+ "KnownCoverage",
76
+ "CoverageMatrix",
77
+ "CoverageCell",
78
+ "KnownCell",
79
+ "FamilyCount",
80
+ "SearchResult",
81
+ "RelatedResult",
82
+ "CitationData",
83
+ "cite",
84
+ # sources
85
+ "Source",
86
+ "RawDataset",
87
+ "SourceMeta",
88
+ "BundledSource",
89
+ "FeedSource",
90
+ "ApiSource",
91
+ "FileSource",
92
+ "StixSource",
93
+ "MemorySource",
94
+ # errors
95
+ "NrdaxError",
96
+ "NotFoundError",
97
+ "InvalidArgumentError",
98
+ "SourceError",
99
+ "DataFormatError",
100
+ "SchemaVersionError",
101
+ "ValidationError",
102
+ "ValidationIssue",
103
+ "ExitCode",
104
+ # vocab
105
+ "FAMILIES",
106
+ "STATUSES",
107
+ "FIDELITY_CLASSES",
108
+ "DISCOVERY_ORIGINS",
109
+ "REFERENCE_KINDS",
110
+ "REPRODUCTION_STATUSES",
111
+ "NRDAX_SCHEMA_VERSION",
112
+ "NRDAX_SITE",
113
+ "NRDAX_API",
114
+ ]
nrdax/_version.py ADDED
@@ -0,0 +1,10 @@
1
+ """Single source of truth for the package version.
2
+
3
+ Kept distinct from the *dataset* version (which comes from the loaded registry's
4
+ ``index.json``) and the *schema* version (``nrdax.vocab.NRDAX_SCHEMA_VERSION``).
5
+ See ``docs/data-model.md`` for the versioning policy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.1.0"
nrdax/cache.py ADDED
@@ -0,0 +1,141 @@
1
+ """Local snapshot cache for offline and pinned use.
2
+
3
+ ``nrdax update`` writes the fetched dataset here; subsequent commands load it so
4
+ work continues offline. Nothing is hidden: :func:`info` reports the cached version
5
+ and fetch time so stale data is always visible, and ``nrdax cache clear`` removes
6
+ it. Location precedence: ``$NRDAX_CACHE_DIR`` → ``$XDG_CACHE_HOME/nrdax`` →
7
+ ``~/.cache/nrdax`` (``%LOCALAPPDATA%\\nrdax`` on Windows).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import shutil
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .sources import RawDataset, SourceMeta
19
+
20
+ _META = "meta.json"
21
+ _JSONL = "registry.jsonl"
22
+ _KNOWN = "known_coverage.json"
23
+ _FAMILIES = "families.json"
24
+
25
+
26
+ def cache_dir() -> Path:
27
+ override = os.environ.get("NRDAX_CACHE_DIR")
28
+ if override:
29
+ return Path(override)
30
+ if os.name == "nt": # pragma: no cover - platform dependent
31
+ win_base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
32
+ return Path(win_base) / "nrdax"
33
+ xdg = os.environ.get("XDG_CACHE_HOME")
34
+ base = Path(xdg) if xdg else Path.home() / ".cache"
35
+ return base / "nrdax"
36
+
37
+
38
+ def snapshot_dir() -> Path:
39
+ return cache_dir() / "snapshot"
40
+
41
+
42
+ def has_snapshot() -> bool:
43
+ d = snapshot_dir()
44
+ return (d / _META).is_file() and (d / _JSONL).is_file()
45
+
46
+
47
+ def write_snapshot(raw: RawDataset) -> Path:
48
+ """Persist ``raw`` to the cache and return the snapshot directory."""
49
+ d = snapshot_dir()
50
+ d.mkdir(parents=True, exist_ok=True)
51
+
52
+ with (d / _JSONL).open("w", encoding="utf-8") as fh:
53
+ for tech in raw.techniques:
54
+ fh.write(json.dumps(tech, separators=(",", ":"), ensure_ascii=False))
55
+ fh.write("\n")
56
+
57
+ (d / _KNOWN).write_text(json.dumps(raw.known_coverage, ensure_ascii=False), encoding="utf-8")
58
+ if raw.families is not None:
59
+ (d / _FAMILIES).write_text(
60
+ json.dumps({"families": raw.families}, ensure_ascii=False), encoding="utf-8"
61
+ )
62
+
63
+ src = raw.meta
64
+ meta = {
65
+ "version": raw.version,
66
+ "doi": raw.doi,
67
+ "technique_count": len(raw.techniques),
68
+ "fetched_at": src.fetched_at if src else None,
69
+ "source_kind": src.kind if src else "unknown",
70
+ "source_location": src.location if src else "unknown",
71
+ }
72
+ (d / _META).write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
73
+ return d
74
+
75
+
76
+ def read_snapshot() -> RawDataset:
77
+ """Load the cached snapshot into a :class:`RawDataset`."""
78
+ from .sources.feed import read_jsonl # local import avoids a cycle at import time
79
+
80
+ d = snapshot_dir()
81
+ meta = json.loads((d / _META).read_text(encoding="utf-8"))
82
+ techniques = read_jsonl((d / _JSONL).read_text(encoding="utf-8"))
83
+ known = []
84
+ if (d / _KNOWN).is_file():
85
+ known = json.loads((d / _KNOWN).read_text(encoding="utf-8"))
86
+ families = None
87
+ if (d / _FAMILIES).is_file():
88
+ families = json.loads((d / _FAMILIES).read_text(encoding="utf-8")).get("families")
89
+
90
+ location = f"{meta.get('source_kind', '?')}:{meta.get('source_location', '?')}"
91
+ return RawDataset(
92
+ version=meta.get("version", "cache"),
93
+ doi=meta.get("doi"),
94
+ techniques=techniques,
95
+ known_coverage=known,
96
+ families=families,
97
+ meta=SourceMeta(kind="cache", location=location, fetched_at=meta.get("fetched_at")),
98
+ )
99
+
100
+
101
+ def clear() -> bool:
102
+ """Remove the cache directory. Returns ``True`` if anything was removed."""
103
+ d = cache_dir()
104
+ if d.exists():
105
+ shutil.rmtree(d)
106
+ return True
107
+ return False
108
+
109
+
110
+ def _dir_size(path: Path) -> int:
111
+ return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
112
+
113
+
114
+ def info() -> dict[str, Any]:
115
+ """A summary of cache state for ``nrdax cache info`` / ``nrdax info``."""
116
+ d = snapshot_dir()
117
+ present = has_snapshot()
118
+ out: dict[str, Any] = {
119
+ "cache_dir": str(cache_dir()),
120
+ "snapshot_present": present,
121
+ }
122
+ if present:
123
+ meta = json.loads((d / _META).read_text(encoding="utf-8"))
124
+ out.update(
125
+ {
126
+ "version": meta.get("version"),
127
+ "doi": meta.get("doi"),
128
+ "technique_count": meta.get("technique_count"),
129
+ "fetched_at": meta.get("fetched_at"),
130
+ "source": f"{meta.get('source_kind')}:{meta.get('source_location')}",
131
+ "size_bytes": _dir_size(d),
132
+ }
133
+ )
134
+ return out
135
+
136
+
137
+ class CacheSource:
138
+ """Load the cached snapshot as a source (used as the default when present)."""
139
+
140
+ def load(self) -> RawDataset:
141
+ return read_snapshot()
nrdax/changes.py ADDED
@@ -0,0 +1,137 @@
1
+ """Change inspection.
2
+
3
+ NRDAX does not (yet) publish historical versioned releases addressable by version
4
+ number, so cross-version comparison is implemented as a **real diff between two
5
+ datasets the caller supplies** (two snapshots, cache vs current, bundled vs live) —
6
+ never simulated. ``since`` derives "added since a date" from ``first_seen``, which
7
+ *is* available on every record. See ``docs/guides/compare-changes.md`` for the
8
+ source limitation and how to pin snapshots for reproducible diffs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING
16
+
17
+ from .models import Technique
18
+
19
+ if TYPE_CHECKING:
20
+ from .registry import NRDAX
21
+
22
+ Predicate = Callable[[Technique], bool]
23
+
24
+ _ADVISORY_KINDS = frozenset({"cve", "ghsa", "vendor-advisory", "nr-advisory"})
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Change:
29
+ kind: str # e.g. "technique_added", "status_changed", "advisory_added"
30
+ technique_id: str
31
+ detail: str
32
+ old: str | None = None
33
+ new: str | None = None
34
+
35
+
36
+ @dataclass
37
+ class ChangeSet:
38
+ from_version: str
39
+ to_version: str
40
+ changes: list[Change] = field(default_factory=list)
41
+
42
+ def counts_by_kind(self) -> dict[str, int]:
43
+ out: dict[str, int] = {}
44
+ for c in self.changes:
45
+ out[c.kind] = out.get(c.kind, 0) + 1
46
+ return dict(sorted(out.items()))
47
+
48
+ def __len__(self) -> int:
49
+ return len(self.changes)
50
+
51
+
52
+ def _refs(t: Technique) -> set[tuple[str, str]]:
53
+ return {(ref.kind, ref.id) for _, ref in t.iter_references()}
54
+
55
+
56
+ def _instances(t: Technique) -> dict[tuple[str, str], str]:
57
+ # (chain, primitive_id) -> fidelity
58
+ return {(i.chain, i.primitive_id): i.fidelity for i in t.instances}
59
+
60
+
61
+ def _diff_pair(old: Technique, new: Technique, out: list[Change]) -> None:
62
+ tid = new.id
63
+ if old.display != new.display:
64
+ out.append(Change("name_changed", tid, "display name changed", old.display, new.display))
65
+ if old.family != new.family:
66
+ out.append(Change("family_changed", tid, "family changed", old.family, new.family))
67
+ if old.status != new.status:
68
+ out.append(Change("status_changed", tid, "status changed", old.status, new.status))
69
+ if old.first_seen != new.first_seen:
70
+ out.append(
71
+ Change("first_seen_changed", tid, "first_seen changed", old.first_seen, new.first_seen)
72
+ )
73
+ if old.reproduction_status != new.reproduction_status:
74
+ out.append(
75
+ Change(
76
+ "reproduction_status_changed",
77
+ tid,
78
+ "reproduction status changed",
79
+ old.reproduction_status,
80
+ new.reproduction_status,
81
+ )
82
+ )
83
+
84
+ old_inst, new_inst = _instances(old), _instances(new)
85
+ for key in sorted(new_inst.keys() - old_inst.keys()):
86
+ out.append(
87
+ Change("instance_added", tid, f"instance added on {key[0]} ({key[1]})", new=key[1])
88
+ )
89
+ for key in sorted(old_inst.keys() - new_inst.keys()):
90
+ out.append(
91
+ Change("instance_removed", tid, f"instance removed on {key[0]} ({key[1]})", old=key[1])
92
+ )
93
+
94
+ old_chains, new_chains = set(old.chains), set(new.chains)
95
+ for chain in sorted(new_chains - old_chains):
96
+ out.append(Change("chain_added", tid, f"chain added: {chain}", new=chain))
97
+
98
+ old_refs, new_refs = _refs(old), _refs(new)
99
+ for kind, rid in sorted(new_refs - old_refs):
100
+ if kind in _ADVISORY_KINDS:
101
+ out.append(Change("advisory_added", tid, f"advisory linked: {rid} ({kind})", new=rid))
102
+ elif kind == "nr-brief":
103
+ out.append(Change("brief_added", tid, f"research brief linked: {rid}", new=rid))
104
+ else:
105
+ out.append(Change("reference_added", tid, f"reference added: {rid} ({kind})", new=rid))
106
+ for kind, rid in sorted(old_refs - new_refs):
107
+ out.append(Change("reference_removed", tid, f"reference removed: {rid} ({kind})", old=rid))
108
+
109
+
110
+ def diff(old: NRDAX, new: NRDAX, *, predicate: Predicate | None = None) -> ChangeSet:
111
+ """Compute the changes from ``old`` to ``new`` (optionally filtered to
112
+ techniques matching ``predicate``)."""
113
+ keep = predicate or (lambda _t: True)
114
+ old_ids = {t.id for t in old if keep(t)}
115
+ new_ids = {t.id for t in new if keep(t)}
116
+ changes: list[Change] = []
117
+
118
+ for tid in sorted(new_ids - old_ids):
119
+ t = new.get(tid)
120
+ changes.append(Change("technique_added", tid, f"technique added: {t.display}"))
121
+ for tid in sorted(old_ids - new_ids):
122
+ t = old.get(tid)
123
+ changes.append(Change("technique_removed", tid, f"technique removed: {t.display}"))
124
+ for tid in sorted(old_ids & new_ids):
125
+ _diff_pair(old.get(tid), new.get(tid), changes)
126
+
127
+ changes.sort(key=lambda c: (c.technique_id, c.kind, c.detail))
128
+ return ChangeSet(from_version=old.version, to_version=new.version, changes=changes)
129
+
130
+
131
+ def since(registry: NRDAX, date: str, *, predicate: Predicate | None = None) -> list[Technique]:
132
+ """Techniques first seen on or after ``date`` (``YYYY-MM-DD``), sorted by
133
+ ``first_seen`` then id."""
134
+ keep = predicate or (lambda _t: True)
135
+ hits = [t for t in registry if keep(t) and t.first_seen and t.first_seen >= date]
136
+ hits.sort(key=lambda t: (t.first_seen, t.id))
137
+ return hits
nrdax/citations.py ADDED
@@ -0,0 +1,179 @@
1
+ """Citation generation for NRDAX techniques.
2
+
3
+ Uses the canonical URL and the metadata that actually exists: the technique's
4
+ ``display`` title, ``first_seen`` date, the dataset ``version``, and the DOI *only
5
+ when one is minted*. It never fabricates bibliographic metadata — the current
6
+ dataset has no DOI, so citations omit it rather than inventing one.
7
+
8
+ Styles: ``text`` (plain), ``markdown``, ``bibtex`` (``@online``), and ``json``
9
+ (CSL-JSON, importable by reference managers). House style: hyphens, never em dashes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime as _dt
15
+ import json
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from .errors import InvalidArgumentError
20
+ from .models import Technique
21
+
22
+ if TYPE_CHECKING:
23
+ from .registry import NRDAX
24
+
25
+ AUTHOR = "NullRabbit Labs"
26
+ PUBLISHER = "NullRabbit"
27
+ CONTAINER = "NRDAX - NullRabbit Decentralised Attack indeX"
28
+ CITATION_STYLES = ("text", "markdown", "bibtex", "json")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CitationData:
33
+ """The resolved, source-backed fields a citation is built from."""
34
+
35
+ id: str
36
+ title: str
37
+ url: str
38
+ author: str
39
+ publisher: str
40
+ container: str
41
+ version: str
42
+ first_seen: str
43
+ accessed: str
44
+ doi: str | None = None
45
+
46
+ @property
47
+ def year(self) -> str:
48
+ return self.first_seen[:4] if self.first_seen else ""
49
+
50
+
51
+ def _accessed(accessed: str | _dt.date | None) -> str:
52
+ if accessed is None:
53
+ return _dt.date.today().isoformat()
54
+ if isinstance(accessed, _dt.date):
55
+ return accessed.isoformat()
56
+ return accessed
57
+
58
+
59
+ def build(
60
+ technique: Technique,
61
+ *,
62
+ version: str,
63
+ doi: str | None = None,
64
+ accessed: str | _dt.date | None = None,
65
+ ) -> CitationData:
66
+ return CitationData(
67
+ id=technique.id,
68
+ title=technique.display,
69
+ url=technique.url,
70
+ author=AUTHOR,
71
+ publisher=PUBLISHER,
72
+ container=CONTAINER,
73
+ version=version,
74
+ first_seen=technique.first_seen,
75
+ accessed=_accessed(accessed),
76
+ doi=doi,
77
+ )
78
+
79
+
80
+ def _text(c: CitationData) -> str:
81
+ doi = f" https://doi.org/{c.doi}" if c.doi else ""
82
+ year = f" ({c.year})" if c.year else ""
83
+ return (
84
+ f"{c.author}.{year} {c.title} ({c.id}). {c.container}, version {c.version}. "
85
+ f"Retrieved {c.accessed}, from {c.url}.{doi}"
86
+ )
87
+
88
+
89
+ def _markdown(c: CitationData) -> str:
90
+ doi = f" [doi:{c.doi}](https://doi.org/{c.doi})" if c.doi else ""
91
+ year = f" ({c.year})" if c.year else ""
92
+ return (
93
+ f"{c.author}.{year} *{c.title}* ({c.id}). {c.container}, version {c.version}. "
94
+ f"Retrieved {c.accessed}, from [{c.url}]({c.url}).{doi}"
95
+ )
96
+
97
+
98
+ def _bibtex(c: CitationData) -> str:
99
+ lines = [
100
+ f"@online{{{c.id},",
101
+ f" author = {{{c.author}}},",
102
+ f" title = {{{{{c.title}}}}},",
103
+ ]
104
+ if c.year:
105
+ lines.append(f" year = {{{c.year}}},")
106
+ lines += [
107
+ f" organization = {{{c.publisher}}},",
108
+ f" howpublished = {{{c.container}}},",
109
+ f" version = {{{c.version}}},",
110
+ f" number = {{{c.id}}},",
111
+ f" url = {{{c.url}}},",
112
+ f" urldate = {{{c.accessed}}},",
113
+ ]
114
+ if c.doi:
115
+ lines.append(f" doi = {{{c.doi}}},")
116
+ lines.append(f" note = {{NRDAX technique {c.id}}}")
117
+ lines.append("}")
118
+ return "\n".join(lines)
119
+
120
+
121
+ def _csl_json(c: CitationData) -> str:
122
+ parts = c.first_seen.split("-")
123
+ issued: dict[str, Any] = {}
124
+ if len(parts) == 3 and all(p.isdigit() for p in parts):
125
+ issued = {"date-parts": [[int(parts[0]), int(parts[1]), int(parts[2])]]}
126
+ acc = c.accessed.split("-")
127
+ accessed_obj: dict[str, Any] = {}
128
+ if len(acc) == 3 and all(p.isdigit() for p in acc):
129
+ accessed_obj = {"date-parts": [[int(acc[0]), int(acc[1]), int(acc[2])]]}
130
+
131
+ doc: dict[str, Any] = {
132
+ "id": c.id,
133
+ "type": "dataset",
134
+ "title": c.title,
135
+ "author": [{"literal": c.author}],
136
+ "publisher": c.publisher,
137
+ "container-title": c.container,
138
+ "version": c.version,
139
+ "number": c.id,
140
+ "URL": c.url,
141
+ "note": f"NRDAX technique {c.id}",
142
+ }
143
+ if issued:
144
+ doc["issued"] = issued
145
+ if accessed_obj:
146
+ doc["accessed"] = accessed_obj
147
+ if c.doi:
148
+ doc["DOI"] = c.doi
149
+ return json.dumps(doc, indent=2, ensure_ascii=False)
150
+
151
+
152
+ _RENDERERS = {
153
+ "text": _text,
154
+ "markdown": _markdown,
155
+ "bibtex": _bibtex,
156
+ "json": _csl_json,
157
+ }
158
+
159
+
160
+ def render(citation: CitationData, style: str = "text") -> str:
161
+ try:
162
+ return _RENDERERS[style](citation)
163
+ except KeyError:
164
+ raise InvalidArgumentError(
165
+ f"unknown citation style {style!r} (choose from {', '.join(CITATION_STYLES)})"
166
+ ) from None
167
+
168
+
169
+ def cite(
170
+ registry: NRDAX,
171
+ technique_id: str,
172
+ *,
173
+ style: str = "text",
174
+ accessed: str | _dt.date | None = None,
175
+ ) -> str:
176
+ """Build and render a citation for a technique in ``registry``."""
177
+ technique = registry.get(technique_id)
178
+ data = build(technique, version=registry.version, doi=registry.doi, accessed=accessed)
179
+ return render(data, style)
nrdax/cli/__init__.py ADDED
File without changes