countermap 0.2.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.
Files changed (40) hide show
  1. countermap/__init__.py +3 -0
  2. countermap/cli.py +566 -0
  3. countermap/db.py +281 -0
  4. countermap/diff.py +95 -0
  5. countermap/emit/__init__.py +108 -0
  6. countermap/emit/attack_flow.py +114 -0
  7. countermap/emit/cad.py +220 -0
  8. countermap/emit/mermaid.py +68 -0
  9. countermap/emit/navigator.py +124 -0
  10. countermap/emit/schemas/attack-flow-schema-2.0.0.json +430 -0
  11. countermap/emit/schemas/d3fend-cad-schema.json +125 -0
  12. countermap/emit/schemas/stix2.1/common/binary.json +8 -0
  13. countermap/emit/schemas/stix2.1/common/core.json +151 -0
  14. countermap/emit/schemas/stix2.1/common/dictionary.json +34 -0
  15. countermap/emit/schemas/stix2.1/common/extension.json +28 -0
  16. countermap/emit/schemas/stix2.1/common/external-reference.json +106 -0
  17. countermap/emit/schemas/stix2.1/common/granular-marking.json +37 -0
  18. countermap/emit/schemas/stix2.1/common/hashes-type.json +55 -0
  19. countermap/emit/schemas/stix2.1/common/hex.json +8 -0
  20. countermap/emit/schemas/stix2.1/common/identifier.json +8 -0
  21. countermap/emit/schemas/stix2.1/common/properties.json +38 -0
  22. countermap/emit/schemas/stix2.1/common/timestamp.json +8 -0
  23. countermap/emit/schemas/stix2.1/common/url-regex.json +8 -0
  24. countermap/emit/validate.py +86 -0
  25. countermap/ingest/__init__.py +150 -0
  26. countermap/ingest/atlas.py +90 -0
  27. countermap/ingest/attack.py +194 -0
  28. countermap/ingest/d3fend.py +311 -0
  29. countermap/ingest/f3.py +108 -0
  30. countermap/query.py +1313 -0
  31. countermap/scenario.py +271 -0
  32. countermap/server.py +253 -0
  33. countermap/sources.py +286 -0
  34. countermap-0.2.0.dist-info/METADATA +217 -0
  35. countermap-0.2.0.dist-info/RECORD +40 -0
  36. countermap-0.2.0.dist-info/WHEEL +4 -0
  37. countermap-0.2.0.dist-info/entry_points.txt +2 -0
  38. countermap-0.2.0.dist-info/licenses/LICENSE +201 -0
  39. countermap-0.2.0.dist-info/licenses/LICENSES/BSD-3-Clause-OASIS.txt +27 -0
  40. countermap-0.2.0.dist-info/licenses/NOTICE +40 -0
countermap/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Countermap: a cross-framework knowledge graph for MITRE ATT&CK, D3FEND, ATLAS and F3."""
2
+
3
+ __version__ = "0.2.0"
countermap/cli.py ADDED
@@ -0,0 +1,566 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from . import __version__
14
+ from .sources import (
15
+ ALL_SOURCES,
16
+ FetchError,
17
+ compare_to_lock,
18
+ default_cache_dir,
19
+ default_db_path,
20
+ read_lock,
21
+ resolve_all,
22
+ write_lock,
23
+ )
24
+
25
+
26
+ def _utf8_stdio() -> None:
27
+ """Write UTF-8 whatever the locale says.
28
+
29
+ On Windows, piped or redirected output defaults to the ANSI code page (cp1252),
30
+ which cannot encode names such as "Operation AkaiRyū" and aborted mid-JSON.
31
+ """
32
+ for stream in (sys.stdout, sys.stderr):
33
+ encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "").replace("_", "")
34
+ if encoding != "utf8" and hasattr(stream, "reconfigure"):
35
+ stream.reconfigure(encoding="utf-8")
36
+
37
+
38
+ def _print(data) -> None:
39
+ try:
40
+ if isinstance(data, str):
41
+ sys.stdout.write(data if data.endswith("\n") else data + "\n")
42
+ else:
43
+ json.dump(data, sys.stdout, indent=2, ensure_ascii=False)
44
+ sys.stdout.write("\n")
45
+ sys.stdout.flush()
46
+ except BrokenPipeError: # output piped into head and closed early
47
+ sys.stderr.close()
48
+
49
+
50
+ def _overrides(values: list[str] | None) -> dict[str, str]:
51
+ out = {}
52
+ for item in values or []:
53
+ if "=" not in item:
54
+ raise SystemExit(f"--source expects KEY=URL_OR_PATH, got {item!r}")
55
+ key, ref = item.split("=", 1)
56
+ if key not in ALL_SOURCES:
57
+ raise SystemExit(f"unknown source {key!r}; choose from {', '.join(ALL_SOURCES)}")
58
+ out[key] = ref
59
+ return out
60
+
61
+
62
+ def _resolve(args) -> list:
63
+ if getattr(args, "lock", None):
64
+ sources = read_lock(Path(args.lock))
65
+ else:
66
+ only = set(args.only.split(",")) if getattr(args, "only", None) else None
67
+ sources = resolve_all(attack_version=getattr(args, "attack_version", None), only=only)
68
+ for key, ref in _overrides(getattr(args, "source", None)).items():
69
+ for s in sources:
70
+ if s.key == key:
71
+ s.url, s.sha256, s.fallbacks = ref, None, []
72
+ return sources
73
+
74
+
75
+ def cmd_build(args) -> int:
76
+ from .ingest import BuildError, build
77
+
78
+ db = Path(args.db or default_db_path())
79
+ try:
80
+ sources = _resolve(args)
81
+ except FetchError as exc:
82
+ print(f"error: {exc}", file=sys.stderr)
83
+ return 2
84
+ print(f"Building {db}")
85
+ try:
86
+ report = build(db, sources, Path(args.cache or default_cache_dir()), verify_hashes=bool(args.lock),
87
+ log=lambda m: print(m, file=sys.stderr))
88
+ except BuildError as exc:
89
+ print(f"error: {exc}", file=sys.stderr)
90
+ return 3
91
+ for s in report["sources"]:
92
+ print(f" {s['source']:<18} {str(s.get('version')):<10} sha256:{s['sha256'][:12]}")
93
+ if args.write_lock:
94
+ write_lock(Path(args.write_lock), sources)
95
+ print(f"Wrote lock file {args.write_lock}")
96
+ from .query import Countermap
97
+
98
+ for w in Countermap(db).datasets()["warnings"]:
99
+ print(f" warning: {w}")
100
+ return 0
101
+
102
+
103
+ def cmd_lock(args) -> int:
104
+ from .sources import fetch, sha256_file
105
+
106
+ sources = _resolve(args)
107
+ cache = Path(args.cache or default_cache_dir())
108
+ for s in sources:
109
+ try:
110
+ if not s.url:
111
+ raise FetchError(f"{s.key}: no URL could be resolved")
112
+ path = fetch(s.url, cache)
113
+ s.sha256 = sha256_file(path)
114
+ except FetchError:
115
+ ok = False
116
+ for fb in s.fallbacks:
117
+ try:
118
+ s.sha256, s.url = sha256_file(fetch(fb, cache)), fb
119
+ ok = True
120
+ break
121
+ except FetchError:
122
+ continue
123
+ if not ok:
124
+ print(f" ! {s.key}: could not fetch; recorded without hash", file=sys.stderr)
125
+ write_lock(Path(args.output), sources)
126
+ print(f"Wrote {args.output}")
127
+ return 0
128
+
129
+
130
+ def cmd_check_updates(args) -> int:
131
+ lock_path = Path(args.lock)
132
+ if not lock_path.is_file():
133
+ print(f"error: no lock file at {lock_path}. check-updates compares the latest upstream releases with a lock "
134
+ "file. Pass --lock PATH (for example the sources.lock.json from the Countermap repository), or write "
135
+ "one now with 'countermap lock -o sources.lock.json' and check against it later.", file=sys.stderr)
136
+ return 2
137
+ lock = read_lock(lock_path)
138
+ latest = resolve_all()
139
+ changes = compare_to_lock(lock, latest)
140
+ _print({"changes": changes})
141
+ return 10 if changes else 0
142
+
143
+
144
+ def cmd_serve(args) -> int:
145
+ import os
146
+
147
+ if args.db:
148
+ os.environ["COUNTERMAP_DB"] = str(Path(args.db).resolve())
149
+ from .server import main as serve
150
+
151
+ serve()
152
+ return 0
153
+
154
+
155
+ def _db(args):
156
+ from .query import Countermap
157
+
158
+ return Countermap(Path(args.db or default_db_path()))
159
+
160
+
161
+ def cmd_query(args) -> int:
162
+ from .query import NotFound
163
+
164
+ c = _db(args)
165
+ try:
166
+ if args.command == "lookup":
167
+ out = c.lookup(args.ref)
168
+ elif args.command == "search":
169
+ out = c.search(" ".join(args.query), framework=args.framework, kind=args.kind, limit=args.limit)
170
+ elif args.command == "context":
171
+ out = c.technique_context(args.ref)
172
+ elif args.command == "counter":
173
+ out = c.countermeasures(args.ref, tactic=args.tactic)
174
+ elif args.command == "actor":
175
+ out = c.actor_techniques(args.ref, include_software=args.with_software)
176
+ elif args.command == "gaps":
177
+ out = c.coverage_gaps(args.refs)
178
+ elif args.command == "coverage":
179
+ out = c.telemetry_coverage(log_sources=args.log_source, data_components=args.data_component,
180
+ platform=args.platform, techniques=args.technique, actor=args.actor,
181
+ include_software=args.with_software, limit=args.limit,
182
+ layer=bool(args.layer), domain=args.domain)
183
+ else:
184
+ out = c.datasets()
185
+ except NotFound as exc:
186
+ print(f"error: {exc}", file=sys.stderr)
187
+ return 4
188
+ if getattr(args, "layer", None) and "navigator" in out:
189
+ for p in _write_layers(Path(args.layer), out.pop("navigator")["layers"]):
190
+ print(f"wrote {p}", file=sys.stderr)
191
+ _print(out)
192
+ return 0
193
+
194
+
195
+ SIGMA_TECHNIQUE_TAG = re.compile(r"attack\.(t\d{4}(?:\.\d{3})?)", re.IGNORECASE)
196
+
197
+
198
+ SIGMA_MAX_BYTES = 1024 * 1024
199
+ SIGMA_MAX_NESTING = 100
200
+ _SCALAR_STARTS = frozenset(" \t\r\n:,[{-?") # a quote after one of these opens a quoted string
201
+
202
+
203
+ class _NestingTooDeep(ValueError):
204
+ pass
205
+
206
+
207
+ def _nesting_depth(text: str) -> int:
208
+ """The deepest ``[`` and ``{`` nesting in YAML text, in one linear pass.
209
+
210
+ Brackets in quoted strings and in comments do not count. A quote opens a string only where a scalar can
211
+ start, so the apostrophe in "it's" does not. A cheap approximation of YAML, enough to refuse hostile files
212
+ before the parser spends a minute recursing into them.
213
+ """
214
+ depth = deepest = 0
215
+ i, n, prev = 0, len(text), "\n"
216
+ while i < n:
217
+ ch = text[i]
218
+ if ch in "'\"" and prev in _SCALAR_STARTS:
219
+ j = i + 1
220
+ while j < n:
221
+ if ch == '"' and text[j] == "\\":
222
+ j += 2
223
+ continue
224
+ if text[j] == ch:
225
+ if ch == "'" and j + 1 < n and text[j + 1] == "'": # '' is an escaped quote
226
+ j += 2
227
+ continue
228
+ break
229
+ j += 1
230
+ i, prev = j + 1, ch
231
+ continue
232
+ if ch == "#" and prev in " \t\r\n":
233
+ end = text.find("\n", i)
234
+ i = n if end < 0 else end
235
+ continue
236
+ if ch in "[{":
237
+ depth += 1
238
+ deepest = max(deepest, depth)
239
+ elif ch in "]}":
240
+ depth = max(0, depth - 1)
241
+ prev = ch
242
+ i += 1
243
+ return deepest
244
+
245
+
246
+ def _is_link(path: str) -> bool:
247
+ """A symlink, or on Windows any reparse point such as a junction (which os.walk would otherwise follow)."""
248
+ try:
249
+ st = os.lstat(path)
250
+ except OSError:
251
+ return True # cannot tell: do not descend
252
+ if stat.S_ISLNK(st.st_mode):
253
+ return True
254
+ return bool(getattr(st, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400))
255
+
256
+
257
+ def _sigma_rules(path: str) -> tuple[int, set[str], int]:
258
+ """Rules, technique IDs and rules with technique tags in one Sigma file. May raise anything."""
259
+ import yaml
260
+
261
+ with open(path, encoding="utf-8") as fh:
262
+ text = fh.read()
263
+ # Deeply nested YAML costs the pure-Python parser about a minute of CPU before a RecursionError. The C
264
+ # loader (yaml.CSafeLoader) is not a way out: it kills the interpreter on the same input.
265
+ if text.count("[") + text.count("{") > SIGMA_MAX_NESTING and _nesting_depth(text) > SIGMA_MAX_NESTING:
266
+ raise _NestingTooDeep()
267
+ docs = list(yaml.safe_load_all(text))
268
+ rules, tagged, ids = 0, 0, set()
269
+ for doc in docs:
270
+ if not isinstance(doc, dict):
271
+ continue
272
+ rules += 1
273
+ tags = doc.get("tags") if isinstance(doc.get("tags"), list) else []
274
+ found = {m.group(1).upper() for t in tags
275
+ if isinstance(t, str) and (m := SIGMA_TECHNIQUE_TAG.fullmatch(t.strip()))}
276
+ if found:
277
+ tagged += 1
278
+ ids |= found
279
+ return rules, ids, tagged
280
+
281
+
282
+ def sigma_techniques(directory: Path) -> tuple[list[str], dict]:
283
+ """ATT&CK technique IDs from the ``attack.tNNNN`` tags of the Sigma rules under ``directory``.
284
+
285
+ This lives in the CLI on purpose, not in the MCP server: the server's tools take IDs and never read a path
286
+ an agent names, so an agent steered by injected text cannot use them to read files. YAML is parsed with
287
+ ``yaml.safe_load_all``, which builds plain data and never runs code.
288
+
289
+ One bad file never stops the run: any error while reading or parsing a file skips it, counted under its
290
+ exception type, and files over 1 MiB are skipped unread. The walk never follows symlinks or Windows
291
+ junctions (so a link loop cannot make it run forever), and a file reached twice is read once.
292
+ """
293
+ directory = Path(directory)
294
+ if not directory.is_dir():
295
+ raise FileNotFoundError(f"no such directory: {directory}")
296
+ found: set[str] = set()
297
+ reasons: dict[str, int] = {}
298
+ stats: dict = {"files": 0, "rules": 0, "rules_with_technique_tags": 0, "skipped": 0, "links_not_followed": 0,
299
+ "unreadable_directories": 0}
300
+ seen: set[str] = set()
301
+
302
+ def skip(reason: str) -> None:
303
+ stats["skipped"] += 1
304
+ reasons[reason] = reasons.get(reason, 0) + 1
305
+
306
+ def unreadable(_exc: OSError) -> None:
307
+ stats["unreadable_directories"] += 1
308
+
309
+ for root, dirs, files in os.walk(directory, followlinks=False, onerror=unreadable):
310
+ followed = []
311
+ for name in sorted(dirs):
312
+ if _is_link(os.path.join(root, name)):
313
+ stats["links_not_followed"] += 1
314
+ else:
315
+ followed.append(name)
316
+ dirs[:] = followed
317
+ for name in sorted(files):
318
+ if not name.lower().endswith((".yml", ".yaml")):
319
+ continue
320
+ path = os.path.join(root, name)
321
+ if os.path.islink(path):
322
+ stats["links_not_followed"] += 1
323
+ continue
324
+ real = os.path.normcase(os.path.realpath(path))
325
+ if real in seen:
326
+ continue
327
+ seen.add(real)
328
+ stats["files"] += 1
329
+ try:
330
+ if os.path.getsize(path) > SIGMA_MAX_BYTES:
331
+ skip("over 1 MiB")
332
+ continue
333
+ rules, ids, tagged = _sigma_rules(path)
334
+ except Exception as exc: # noqa: BLE001 - a hostile or broken file must never abort the run
335
+ skip("nesting too deep" if isinstance(exc, _NestingTooDeep) else type(exc).__name__)
336
+ continue
337
+ stats["rules"] += rules
338
+ stats["rules_with_technique_tags"] += tagged
339
+ found |= ids
340
+ stats["skipped_reasons"] = dict(sorted(reasons.items()))
341
+ return sorted(found), stats
342
+
343
+
344
+ def cmd_rules_coverage(args) -> int:
345
+ from .query import NotFound
346
+
347
+ sigma = None
348
+ if args.sigma:
349
+ techniques, sigma = sigma_techniques(Path(args.sigma))
350
+ sigma["techniques"] = len(techniques)
351
+ else:
352
+ techniques = args.technique
353
+ try:
354
+ out = _db(args).rule_coverage(techniques, scope_actors=args.actor, platform=args.platform, tactics=args.tactic,
355
+ domain=args.domain, limit=args.limit, layer=bool(args.layer))
356
+ except NotFound as exc:
357
+ print(f"error: {exc}", file=sys.stderr)
358
+ return 4
359
+ if sigma is not None:
360
+ out = {"sigma": sigma, **out}
361
+ if args.layer and "navigator" in out:
362
+ for p in _write_layers(Path(args.layer), out.pop("navigator")["layers"]):
363
+ print(f"wrote {p}", file=sys.stderr)
364
+ _print(out)
365
+ return 0
366
+
367
+
368
+ def _write_layers(path: Path, layers: dict) -> list[Path]:
369
+ """Write Navigator layers: one domain to ``path``, several to ``<stem>.<domain><suffix>`` beside it."""
370
+ written = []
371
+ for domain, layer in layers.items():
372
+ p = path if len(layers) == 1 else path.with_name(f"{path.stem}.{domain}{path.suffix or '.json'}")
373
+ p.parent.mkdir(parents=True, exist_ok=True)
374
+ p.write_text(json.dumps(layer, indent=2, ensure_ascii=False), encoding="utf-8")
375
+ written.append(p)
376
+ return written
377
+
378
+
379
+ def cmd_render(args) -> int:
380
+ from .emit import RenderError, render
381
+ from .query import NotFound
382
+
383
+ spec = json.loads(Path(args.spec).read_text(encoding="utf-8"))
384
+ formats = args.format or None
385
+ try:
386
+ out = render(spec, _db(args), formats, scope=args.scope)
387
+ except (NotFound, RenderError, ValueError) as exc:
388
+ print(f"error: {exc}", file=sys.stderr)
389
+ return 5
390
+ outdir = Path(args.out)
391
+ outdir.mkdir(parents=True, exist_ok=True)
392
+ stem = args.name or Path(args.spec).stem
393
+ written = []
394
+ if "cad" in out:
395
+ p = outdir / f"{stem}.d3fend-cad.json"
396
+ p.write_text(json.dumps(out["cad"], indent=2), encoding="utf-8")
397
+ written.append(p)
398
+ if "attack_flow" in out:
399
+ p = outdir / f"{stem}.attack-flow.json"
400
+ p.write_text(json.dumps(out["attack_flow"], indent=2), encoding="utf-8")
401
+ written.append(p)
402
+ if "mermaid" in out:
403
+ p = outdir / f"{stem}.mmd"
404
+ p.write_text(out["mermaid"], encoding="utf-8")
405
+ written.append(p)
406
+ for domain, layer in (out.get("navigator") or {}).items():
407
+ p = outdir / f"{stem}.navigator.{domain}.json"
408
+ p.write_text(json.dumps(layer, indent=2), encoding="utf-8")
409
+ written.append(p)
410
+ for p in written:
411
+ print(p)
412
+ for w in out["warnings"]:
413
+ print(f"warning: {w}", file=sys.stderr)
414
+ return 0
415
+
416
+
417
+ def cmd_diff(args) -> int:
418
+ from .diff import diff_snapshots
419
+
420
+ _print(diff_snapshots(Path(args.old), Path(args.new), framework=args.framework or None, kind=args.kind, limit=args.limit))
421
+ return 0
422
+
423
+
424
+ def cmd_calibrate(args) -> int:
425
+ from .emit.cad import calibrate_from_export, calibration_path
426
+
427
+ cal = calibrate_from_export(json.loads(Path(args.exported).read_text(encoding="utf-8")))
428
+ path = calibration_path()
429
+ path.parent.mkdir(parents=True, exist_ok=True)
430
+ path.write_text(json.dumps(cal, indent=2), encoding="utf-8")
431
+ print(f"Saved CAD calibration to {path}")
432
+ _print(cal)
433
+ return 0
434
+
435
+
436
+ DESCRIPTION = """\
437
+ Countermap: a local, read-only knowledge graph of MITRE ATT&CK® (Enterprise,
438
+ Mobile, ICS), D3FEND, ATLAS and F3, with provenance on every cross-framework
439
+ link. Build the database once with "countermap build", then query it from the
440
+ shell or serve it to MCP clients with "countermap serve"."""
441
+
442
+ EPILOG = """\
443
+ examples:
444
+ countermap build fetch the latest releases and build
445
+ countermap lookup "Cozy Bear" any object by ID, name or alias
446
+ countermap counter T1539 D3FEND countermeasures for T1539
447
+ countermap render scenario.json -o out/ CAD, Attack Flow, Navigator, Mermaid
448
+ countermap serve the read-only MCP server over stdio
449
+
450
+ Run "countermap COMMAND --help" for the options of one command.
451
+ Documentation: https://github.com/elchacal801/countermap"""
452
+
453
+
454
+ def main(argv: list[str] | None = None) -> int:
455
+ _utf8_stdio() # before parsing, so --help and usage errors are UTF-8 too
456
+ p = argparse.ArgumentParser(prog="countermap", description=DESCRIPTION, epilog=EPILOG,
457
+ formatter_class=argparse.RawDescriptionHelpFormatter)
458
+ p.add_argument("--version", action="version", version=f"countermap {__version__}")
459
+ p.add_argument("--db", help="database path (default: $COUNTERMAP_DB or the user cache)")
460
+ sub = p.add_subparsers(dest="command", required=True)
461
+
462
+ b = sub.add_parser("build", help="download (or read mirrors/local files) and build the database")
463
+ b.add_argument("--lock", help="build exactly the sources in this lock file and verify SHA-256")
464
+ b.add_argument("--write-lock", help="write the resolved sources and hashes to this lock file")
465
+ b.add_argument("--attack-version", help="pin ATT&CK to a version, e.g. 19.2")
466
+ b.add_argument("--only", help="comma-separated source keys: " + ",".join(ALL_SOURCES))
467
+ b.add_argument("--source", action="append", help="override a source: KEY=URL_OR_PATH (repeatable)")
468
+ b.add_argument("--cache", help="download cache directory")
469
+ b.set_defaults(func=cmd_build)
470
+
471
+ lk = sub.add_parser("lock", help="resolve the latest upstream versions and write a lock file")
472
+ lk.add_argument("-o", "--output", default="sources.lock.json")
473
+ lk.add_argument("--attack-version")
474
+ lk.add_argument("--only")
475
+ lk.add_argument("--source", action="append")
476
+ lk.add_argument("--cache")
477
+ lk.set_defaults(func=cmd_lock)
478
+
479
+ cu = sub.add_parser("check-updates", help="compare upstream to a lock file; exit 10 when updates exist")
480
+ cu.add_argument("--lock", default="sources.lock.json",
481
+ help="the lock file to compare against (default: sources.lock.json in the current directory)")
482
+ cu.set_defaults(func=cmd_check_updates)
483
+
484
+ sv = sub.add_parser("serve", help="run the read-only MCP server over stdio")
485
+ sv.set_defaults(func=cmd_serve)
486
+
487
+ for name, helptext in (("lookup", "look up an object"), ("context", "technique context"),
488
+ ("counter", "D3FEND countermeasures for a technique"), ("actor", "techniques for an actor")):
489
+ q = sub.add_parser(name, help=helptext)
490
+ q.add_argument("ref")
491
+ if name == "counter":
492
+ q.add_argument("--tactic")
493
+ if name == "actor":
494
+ q.add_argument("--with-software", action="store_true")
495
+ q.set_defaults(func=cmd_query)
496
+ s = sub.add_parser("search", help="full-text search")
497
+ s.add_argument("query", nargs="+")
498
+ s.add_argument("--framework")
499
+ s.add_argument("--kind")
500
+ s.add_argument("--limit", type=int, default=10)
501
+ s.set_defaults(func=cmd_query)
502
+ g = sub.add_parser("gaps", help="coverage gaps for a list of techniques")
503
+ g.add_argument("refs", nargs="+")
504
+ g.set_defaults(func=cmd_query)
505
+ cv = sub.add_parser("coverage", help="what your log sources can detect (by ATT&CK analytics) and where you are blind")
506
+ cv.add_argument("--log-source", action="append",
507
+ help='a log source you collect, optionally with a channel: "WinEventLog:Sysmon EventCode=10" (repeatable)')
508
+ cv.add_argument("--data-component", action="append", help="a data component you collect, ID or name (repeatable)")
509
+ target = cv.add_mutually_exclusive_group()
510
+ target.add_argument("--actor", help="a group, software or campaign whose techniques to check")
511
+ target.add_argument("--technique", action="append", help="a technique to check (repeatable)")
512
+ cv.add_argument("--with-software", action="store_true", help="with --actor, add techniques of its software")
513
+ cv.add_argument("--platform", help="only analytics (and, with no target, techniques) for this platform")
514
+ cv.add_argument("--domain", choices=["enterprise-attack", "mobile-attack", "ics-attack"],
515
+ help="only techniques of this ATT&CK domain (default: all three)")
516
+ cv.add_argument("--limit", type=int, default=50, help="techniques listed per class")
517
+ cv.add_argument("--layer", help="also write a Navigator coverage layer to this file")
518
+ cv.set_defaults(func=cmd_query)
519
+ rc = sub.add_parser("rules-coverage", help="what your detection rules cover, and the gaps ranked by threat-group use")
520
+ rules = rc.add_mutually_exclusive_group(required=True)
521
+ rules.add_argument("--technique", action="append", help="an ATT&CK technique ID your rules map to (repeatable)")
522
+ rules.add_argument("--sigma", help="a directory of Sigma rules whose attack.tNNNN tags to read (parsed, never run)")
523
+ rc.add_argument("--actor", action="append", help="scope to this group, software or campaign's techniques (repeatable)")
524
+ rc.add_argument("--platform", help="scope to techniques for this platform")
525
+ rc.add_argument("--tactic", action="append", help="scope to this tactic, by name or ID (repeatable)")
526
+ rc.add_argument("--domain", default="enterprise-attack", choices=["enterprise-attack", "mobile-attack", "ics-attack"])
527
+ rc.add_argument("--limit", type=int, default=50, help="gaps and covered techniques listed")
528
+ rc.add_argument("--layer", help="also write a Navigator coverage layer to this file")
529
+ rc.set_defaults(func=cmd_rules_coverage)
530
+ d = sub.add_parser("info", help="datasets, versions and build warnings")
531
+ d.set_defaults(func=cmd_query)
532
+
533
+ r = sub.add_parser("render", help="render a scenario spec to CAD, Attack Flow, Mermaid and Navigator files")
534
+ r.add_argument("spec")
535
+ r.add_argument("-o", "--out", default=".")
536
+ r.add_argument("--name")
537
+ r.add_argument("--format", action="append", choices=["cad", "attack_flow", "mermaid", "navigator"])
538
+ r.add_argument("--scope", default="incident",
539
+ choices=["incident", "campaign", "threat-actor", "malware", "emulation-plan", "attack-tree", "other"])
540
+ r.set_defaults(func=cmd_render)
541
+
542
+ df = sub.add_parser("diff", help="compare two database snapshots")
543
+ df.add_argument("old")
544
+ df.add_argument("new")
545
+ df.add_argument("--framework", default="attack")
546
+ df.add_argument("--kind")
547
+ df.add_argument("--limit", type=int, default=50)
548
+ df.set_defaults(func=cmd_diff)
549
+
550
+ cal = sub.add_parser("calibrate-cad", help="learn CAD handle ids and schema version from a graph exported by D3FEND CAD")
551
+ cal.add_argument("exported")
552
+ cal.set_defaults(func=cmd_calibrate)
553
+
554
+ args = p.parse_args(argv)
555
+ try:
556
+ return args.func(args)
557
+ except FileNotFoundError as exc:
558
+ print(f"error: {exc}", file=sys.stderr)
559
+ return 2
560
+ except json.JSONDecodeError as exc:
561
+ print(f"error: invalid JSON: {exc}", file=sys.stderr)
562
+ return 2
563
+
564
+
565
+ if __name__ == "__main__":
566
+ raise SystemExit(main())