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 +114 -0
- nrdax/_version.py +10 -0
- nrdax/cache.py +141 -0
- nrdax/changes.py +137 -0
- nrdax/citations.py +179 -0
- nrdax/cli/__init__.py +0 -0
- nrdax/cli/formatting.py +215 -0
- nrdax/cli/main.py +643 -0
- nrdax/coverage.py +53 -0
- nrdax/data/snapshot/SNAPSHOT.md +20 -0
- nrdax/data/snapshot/coverage-matrix.json +2832 -0
- nrdax/data/snapshot/families.json +120 -0
- nrdax/data/snapshot/index.json +387 -0
- nrdax/data/snapshot/registry.jsonl +381 -0
- nrdax/errors.py +114 -0
- nrdax/exporters/__init__.py +52 -0
- nrdax/exporters/_fields.py +85 -0
- nrdax/exporters/csv_exporter.py +69 -0
- nrdax/exporters/json_exporter.py +44 -0
- nrdax/exporters/stix_exporter.py +162 -0
- nrdax/models.py +416 -0
- nrdax/py.typed +0 -0
- nrdax/queries/__init__.py +8 -0
- nrdax/queries/filters.py +145 -0
- nrdax/queries/search.py +125 -0
- nrdax/registry.py +278 -0
- nrdax/relationships.py +106 -0
- nrdax/sources/__init__.py +50 -0
- nrdax/sources/_http.py +58 -0
- nrdax/sources/api.py +96 -0
- nrdax/sources/bundled.py +46 -0
- nrdax/sources/feed.py +136 -0
- nrdax/sources/file.py +77 -0
- nrdax/sources/memory.py +43 -0
- nrdax/sources/stix.py +111 -0
- nrdax/vocab.py +113 -0
- nrdax-0.1.0.dist-info/METADATA +229 -0
- nrdax-0.1.0.dist-info/RECORD +42 -0
- nrdax-0.1.0.dist-info/WHEEL +4 -0
- nrdax-0.1.0.dist-info/entry_points.txt +2 -0
- nrdax-0.1.0.dist-info/licenses/LICENSE +177 -0
- nrdax-0.1.0.dist-info/licenses/NOTICE +25 -0
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
|