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.
- countermap/__init__.py +3 -0
- countermap/cli.py +566 -0
- countermap/db.py +281 -0
- countermap/diff.py +95 -0
- countermap/emit/__init__.py +108 -0
- countermap/emit/attack_flow.py +114 -0
- countermap/emit/cad.py +220 -0
- countermap/emit/mermaid.py +68 -0
- countermap/emit/navigator.py +124 -0
- countermap/emit/schemas/attack-flow-schema-2.0.0.json +430 -0
- countermap/emit/schemas/d3fend-cad-schema.json +125 -0
- countermap/emit/schemas/stix2.1/common/binary.json +8 -0
- countermap/emit/schemas/stix2.1/common/core.json +151 -0
- countermap/emit/schemas/stix2.1/common/dictionary.json +34 -0
- countermap/emit/schemas/stix2.1/common/extension.json +28 -0
- countermap/emit/schemas/stix2.1/common/external-reference.json +106 -0
- countermap/emit/schemas/stix2.1/common/granular-marking.json +37 -0
- countermap/emit/schemas/stix2.1/common/hashes-type.json +55 -0
- countermap/emit/schemas/stix2.1/common/hex.json +8 -0
- countermap/emit/schemas/stix2.1/common/identifier.json +8 -0
- countermap/emit/schemas/stix2.1/common/properties.json +38 -0
- countermap/emit/schemas/stix2.1/common/timestamp.json +8 -0
- countermap/emit/schemas/stix2.1/common/url-regex.json +8 -0
- countermap/emit/validate.py +86 -0
- countermap/ingest/__init__.py +150 -0
- countermap/ingest/atlas.py +90 -0
- countermap/ingest/attack.py +194 -0
- countermap/ingest/d3fend.py +311 -0
- countermap/ingest/f3.py +108 -0
- countermap/query.py +1313 -0
- countermap/scenario.py +271 -0
- countermap/server.py +253 -0
- countermap/sources.py +286 -0
- countermap-0.2.0.dist-info/METADATA +217 -0
- countermap-0.2.0.dist-info/RECORD +40 -0
- countermap-0.2.0.dist-info/WHEEL +4 -0
- countermap-0.2.0.dist-info/entry_points.txt +2 -0
- countermap-0.2.0.dist-info/licenses/LICENSE +201 -0
- countermap-0.2.0.dist-info/licenses/LICENSES/BSD-3-Clause-OASIS.txt +27 -0
- countermap-0.2.0.dist-info/licenses/NOTICE +40 -0
countermap/__init__.py
ADDED
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())
|