interlinear-cti 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.
- interlinear_cti/__init__.py +3 -0
- interlinear_cti/cli.py +398 -0
- interlinear_cti/errors.py +10 -0
- interlinear_cti/export.py +165 -0
- interlinear_cti/fileio.py +75 -0
- interlinear_cti/glossing.py +303 -0
- interlinear_cti/iocs.py +684 -0
- interlinear_cti/model.py +129 -0
- interlinear_cti/normalize.py +1311 -0
- interlinear_cti/parse.py +615 -0
- interlinear_cti/parse_main.py +109 -0
- interlinear_cti/pyz.py +35 -0
- interlinear_cti/readers/__init__.py +123 -0
- interlinear_cti/readers/agent_log.py +64 -0
- interlinear_cti/readers/script.py +352 -0
- interlinear_cti/readers/shell_history.py +124 -0
- interlinear_cti/render.py +180 -0
- interlinear_cti/rules/00-context.yaml +101 -0
- interlinear_cti/rules/05-persistence.yaml +319 -0
- interlinear_cti/rules/10-transfer-exec.yaml +462 -0
- interlinear_cti/rules/15-credentials.yaml +95 -0
- interlinear_cti/rules/20-discovery.yaml +242 -0
- interlinear_cti/rules/25-collection.yaml +16 -0
- interlinear_cti/rules/30-persistence.yaml +96 -0
- interlinear_cti/rules/40-evasion.yaml +191 -0
- interlinear_cti/rules/50-network.yaml +164 -0
- interlinear_cti/rules/60-impact.yaml +22 -0
- interlinear_cti/rules/VERSION +1 -0
- interlinear_cti/rules.py +1085 -0
- interlinear_cti/textshow.py +110 -0
- interlinear_cti/tlds.py +82 -0
- interlinear_cti/verify.py +118 -0
- interlinear_cti-0.1.0.dist-info/METADATA +148 -0
- interlinear_cti-0.1.0.dist-info/RECORD +38 -0
- interlinear_cti-0.1.0.dist-info/WHEEL +4 -0
- interlinear_cti-0.1.0.dist-info/entry_points.txt +2 -0
- interlinear_cti-0.1.0.dist-info/licenses/LICENSE +201 -0
- interlinear_cti-0.1.0.dist-info/licenses/NOTICE +8 -0
interlinear_cti/cli.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Command-line interface for Interlinear.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 success, 1 success with warnings or rejected glosses, 2 invalid input or arguments, 3 an error writing
|
|
4
|
+
the transcript or an internal error.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .errors import ResolverUnavailable
|
|
16
|
+
from .fileio import OutputError
|
|
17
|
+
|
|
18
|
+
MAX_PRINTED = 100 # rejections, and warnings, printed by apply-glosses and annotate; a glosses file can hold any number
|
|
19
|
+
|
|
20
|
+
# Writing an output failed, after the input was read: not invalid input, so exit code 3. It is fileio's error, so the
|
|
21
|
+
# commands that write through fileio (build-parse-pyz) are reported the same way. It holds the error's type name.
|
|
22
|
+
_OutputError = OutputError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _escaped(text: str) -> str:
|
|
26
|
+
"""Text for the console that may hold attacker-chosen text (a path, an error message): defanged and escaped."""
|
|
27
|
+
from .normalize import normalize_text
|
|
28
|
+
from .parse import escape_controls
|
|
29
|
+
|
|
30
|
+
return escape_controls(normalize_text(text), keep="")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _save(transcript, path: str) -> None:
|
|
34
|
+
from . import model
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
model.save(transcript, path)
|
|
38
|
+
except OSError as exc: # such as a full disk
|
|
39
|
+
raise _OutputError(type(exc).__name__) from None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _by_label(value: str) -> str:
|
|
43
|
+
from .glossing import BY_LABEL, BY_RULE
|
|
44
|
+
|
|
45
|
+
if not BY_LABEL.fullmatch(value):
|
|
46
|
+
raise argparse.ArgumentTypeError(f"must be a short label: {BY_RULE}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _at_least_one(value: str) -> int:
|
|
51
|
+
try:
|
|
52
|
+
number = int(value)
|
|
53
|
+
except ValueError:
|
|
54
|
+
raise argparse.ArgumentTypeError("must be a whole number") from None
|
|
55
|
+
if number < 1:
|
|
56
|
+
raise argparse.ArgumentTypeError("must be at least 1")
|
|
57
|
+
return number
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _utf8_stdio() -> None:
|
|
61
|
+
"""Write UTF-8 whatever the locale says (Windows pipes default to cp1252)."""
|
|
62
|
+
for stream in (sys.stdout, sys.stderr):
|
|
63
|
+
encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "").replace("_", "")
|
|
64
|
+
if encoding != "utf8" and hasattr(stream, "reconfigure"):
|
|
65
|
+
# Keep the stream's error handler: reconfigure() with only an encoding resets it to "strict".
|
|
66
|
+
stream.reconfigure(encoding="utf-8", errors=stream.errors)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_parse(args: argparse.Namespace) -> int:
|
|
70
|
+
from .parse_main import run_safely
|
|
71
|
+
|
|
72
|
+
return run_safely(args.files, args.output, kind=args.kind, case=args.case,
|
|
73
|
+
max_file_bytes=args.max_file_bytes, max_lines=args.max_lines,
|
|
74
|
+
max_retained_bytes=args.max_retained_bytes, max_files=args.max_files)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cmd_build_parse_pyz(args: argparse.Namespace) -> int:
|
|
78
|
+
from .pyz import build
|
|
79
|
+
|
|
80
|
+
print(f"Wrote {build(args.output)}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_rules(args: argparse.Namespace) -> int:
|
|
85
|
+
from .rules import load_library, run_examples
|
|
86
|
+
|
|
87
|
+
resolver = None
|
|
88
|
+
if args.verify:
|
|
89
|
+
from .verify import CountermapResolver
|
|
90
|
+
|
|
91
|
+
resolver = CountermapResolver(args.db)
|
|
92
|
+
try:
|
|
93
|
+
library = load_library(resolver=resolver)
|
|
94
|
+
if args.action == "list":
|
|
95
|
+
for rule in library.rules:
|
|
96
|
+
label = "context" if rule.context else ", ".join(rule.techniques)
|
|
97
|
+
print(f"{rule.id:28} {label:24} {rule.file}")
|
|
98
|
+
print(f"{_count(len(library.rules), 'rule', 'rules')}, version {library.version}")
|
|
99
|
+
return 0
|
|
100
|
+
failures = run_examples(library)
|
|
101
|
+
for failure in failures:
|
|
102
|
+
print(f"FAIL {failure}")
|
|
103
|
+
checked = sum(len(r.examples_match) + len(r.examples_no_match) for r in library.rules)
|
|
104
|
+
print(f"{_count(checked, 'example', 'examples')} checked, {_count(len(failures), 'failure', 'failures')}"
|
|
105
|
+
+ ("; techniques verified" if resolver else ""))
|
|
106
|
+
return 1 if failures else 0
|
|
107
|
+
finally:
|
|
108
|
+
if resolver is not None: # close the database connection, not leave it to the garbage collector
|
|
109
|
+
resolver.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _now() -> str:
|
|
113
|
+
import datetime as _dt
|
|
114
|
+
|
|
115
|
+
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_gloss(args: argparse.Namespace) -> int:
|
|
119
|
+
from .glossing import apply_rules
|
|
120
|
+
from .model import load
|
|
121
|
+
from .rules import load_library
|
|
122
|
+
from .verify import CountermapResolver
|
|
123
|
+
|
|
124
|
+
transcript = load(args.transcript)
|
|
125
|
+
with CountermapResolver(args.db) as resolver:
|
|
126
|
+
library = load_library(resolver=resolver)
|
|
127
|
+
counts = apply_rules(transcript, library, at=_now())
|
|
128
|
+
transcript.datasets = resolver.versions()
|
|
129
|
+
_save(transcript, args.transcript)
|
|
130
|
+
pending = sum(1 for line in transcript.lines if line.gloss.source == "pending")
|
|
131
|
+
print(f"Glossed {_escaped(args.transcript)} with rules {library.version}: "
|
|
132
|
+
f"{counts.get('rule', 0)} rule, {counts.get('none', 0)} context; {pending} still pending")
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_pending(args: argparse.Namespace) -> int:
|
|
137
|
+
from .glossing import pending_lines
|
|
138
|
+
from .model import load
|
|
139
|
+
|
|
140
|
+
rows = pending_lines(load(args.transcript), limit=args.limit, after=args.after)
|
|
141
|
+
print(json.dumps(rows, indent=1, ensure_ascii=False))
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _read_glosses(path: str) -> list:
|
|
146
|
+
"""A glosses file: a JSON list, with or without the BOM Windows PowerShell 5.1 writes."""
|
|
147
|
+
try:
|
|
148
|
+
entries = json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
149
|
+
except RecursionError:
|
|
150
|
+
raise ValueError(f"{path}: not a usable glosses file (JSON nested too deeply)") from None
|
|
151
|
+
except ValueError as exc: # not UTF-8, or not JSON
|
|
152
|
+
raise ValueError(f"{path}: not a usable glosses file ({exc})") from None
|
|
153
|
+
if not isinstance(entries, list):
|
|
154
|
+
raise ValueError(f"{path}: expected a JSON list of gloss entries")
|
|
155
|
+
return entries
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _merge_glosses(args: argparse.Namespace, source: str) -> int:
|
|
159
|
+
from .glossing import apply_glosses
|
|
160
|
+
from .model import load
|
|
161
|
+
from .verify import CountermapResolver
|
|
162
|
+
|
|
163
|
+
entries = _read_glosses(args.glosses)
|
|
164
|
+
transcript = load(args.transcript)
|
|
165
|
+
warnings: list[str] = []
|
|
166
|
+
with CountermapResolver(args.db) as resolver:
|
|
167
|
+
applied, rejected = apply_glosses(transcript, entries, source=source, by=args.by, resolver=resolver,
|
|
168
|
+
at=_now(), warnings=warnings)
|
|
169
|
+
if not transcript.datasets:
|
|
170
|
+
transcript.datasets = resolver.versions()
|
|
171
|
+
_save(transcript, args.transcript)
|
|
172
|
+
glossed = _count(applied, f"{source} gloss", f"{source} glosses")
|
|
173
|
+
summary = f"Applied {glossed} to {_escaped(args.transcript)}; rejected {len(rejected)}"
|
|
174
|
+
print(summary + (f"; {_count(len(warnings), 'warning', 'warnings')}" if warnings else ""))
|
|
175
|
+
_print_some(rejected, "rejected", "rejection", "rejections")
|
|
176
|
+
_print_some(warnings, "warning", "warning", "warnings")
|
|
177
|
+
return 1 if rejected or warnings else 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _count(n: int, one: str, many: str) -> str:
|
|
181
|
+
"""n and the noun that agrees with it: 1 line, 2 lines."""
|
|
182
|
+
return f"{n} {one if n == 1 else many}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _print_some(messages: list[str], label: str, one: str, many: str) -> None:
|
|
186
|
+
"""Print the first MAX_PRINTED messages to stderr, then how many more there are (the summary counts them all)."""
|
|
187
|
+
for message in messages[:MAX_PRINTED]:
|
|
188
|
+
print(f" {label}: {message}", file=sys.stderr)
|
|
189
|
+
if len(messages) > MAX_PRINTED:
|
|
190
|
+
print(f" ... and {_count(len(messages) - MAX_PRINTED, f'more {one}', f'more {many}')}", file=sys.stderr)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_apply_glosses(args: argparse.Namespace) -> int:
|
|
194
|
+
return _merge_glosses(args, "model")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_annotate(args: argparse.Namespace) -> int:
|
|
198
|
+
return _merge_glosses(args, "analyst")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _write_text(path: str | Path, text: str) -> None:
|
|
202
|
+
"""Write a text output file through fileio.atomic_write, which keeps a replaced file's mode, gives a new one the
|
|
203
|
+
mode a plain create would, and removes its temporary file on any failure.
|
|
204
|
+
|
|
205
|
+
A write-time OSError is signalled as _OutputError, which main() reports as exit code 3; anything else is raised as
|
|
206
|
+
it is, so the UnicodeEncodeError of a lone surrogate in a hand-edited transcript is invalid input, exit code 2.
|
|
207
|
+
"""
|
|
208
|
+
from . import fileio
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
fileio.atomic_write(path, text)
|
|
212
|
+
except OSError as exc: # such as an unwritable folder or a full disk
|
|
213
|
+
raise _OutputError(type(exc).__name__) from None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _refuse_transcript(transcript: str, output: str | Path) -> None:
|
|
217
|
+
"""Refuse an output path that is the transcript itself, which the output would replace: invalid arguments."""
|
|
218
|
+
import os
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
same = os.path.samefile(transcript, output)
|
|
222
|
+
except OSError: # one of them does not exist
|
|
223
|
+
same = Path(transcript).resolve() == Path(output).resolve()
|
|
224
|
+
if same:
|
|
225
|
+
raise ValueError(f"{output}: the output path is the transcript itself; choose another with -o")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
MAX_HEADER_BYTES = 65536 # how much of an existing file's first line render reads to tell whether it wrote it
|
|
229
|
+
# File names and folder names that AI coding agents read as instructions, compared in lower case. The view holds
|
|
230
|
+
# attacker text, so render never writes one of these files, or anything under one of these folders, even a new one:
|
|
231
|
+
# the next agent session in that folder would load the attacker's text as its instructions.
|
|
232
|
+
AGENT_INSTRUCTION_NAMES = frozenset({
|
|
233
|
+
"claude.md", "claude.local.md", "agents.md", "agent.md", "gemini.md", "conventions.md", ".cursorrules",
|
|
234
|
+
".claude", ".github", ".cursor", ".windsurf", ".amazonq", ".kiro", ".clinerules", ".roo", ".continue",
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _refuse_other_files(output: Path) -> None:
|
|
239
|
+
"""Refuse an agent instruction file, an output that is not a .md file, or an existing output that is not a
|
|
240
|
+
regular file holding an earlier view: invalid arguments.
|
|
241
|
+
|
|
242
|
+
The view holds attacker text, so render must not be aimed at a CLAUDE.md, a shell startup file or another
|
|
243
|
+
transcript: it never writes a path an AI agent reads as instructions, writes only .md files, and replaces only a
|
|
244
|
+
file whose first line is a view's header.
|
|
245
|
+
"""
|
|
246
|
+
import os
|
|
247
|
+
import stat
|
|
248
|
+
|
|
249
|
+
from .render import is_render_header
|
|
250
|
+
|
|
251
|
+
# The path as given and as resolved, so neither .. nor a working folder inside .claude gets past the check.
|
|
252
|
+
parts = {part.lower() for part in (*output.parts, *output.resolve().parts)}
|
|
253
|
+
if parts & AGENT_INSTRUCTION_NAMES:
|
|
254
|
+
raise ValueError(f"{output}: AI agents read this path as agent instructions, so render will not write it")
|
|
255
|
+
if output.suffix.lower() != ".md":
|
|
256
|
+
raise ValueError(f"{output}: the output path must end in .md")
|
|
257
|
+
try:
|
|
258
|
+
st = os.stat(output) # following a link, as reading the header would
|
|
259
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
260
|
+
return # nothing there yet
|
|
261
|
+
except OSError as exc: # such as a link loop or a folder that cannot be searched
|
|
262
|
+
raise ValueError(f"{output}: cannot be checked ({type(exc).__name__}), so render will not replace it") from None
|
|
263
|
+
# Opening a FIFO to read its first line would wait for a writer forever, and a device is no earlier view.
|
|
264
|
+
if not stat.S_ISREG(st.st_mode):
|
|
265
|
+
raise ValueError(f"{output}: exists and is not a regular file, so render will not replace it")
|
|
266
|
+
try:
|
|
267
|
+
# O_NONBLOCK where there is one: should a FIFO take the file's place after the check, the open still returns.
|
|
268
|
+
fd = os.open(output, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0))
|
|
269
|
+
with os.fdopen(fd, "rb") as fh:
|
|
270
|
+
first = fh.readline(MAX_HEADER_BYTES)
|
|
271
|
+
except OSError: # a file that cannot be read
|
|
272
|
+
first = b""
|
|
273
|
+
try:
|
|
274
|
+
line = first.decode("utf-8-sig").rstrip("\r\n") # an editor may have added a BOM or CRLF line endings
|
|
275
|
+
except UnicodeDecodeError:
|
|
276
|
+
line = ""
|
|
277
|
+
if not is_render_header(line):
|
|
278
|
+
raise ValueError(f"{output}: exists and is not an Interlinear render, so render will not replace it")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_render(args: argparse.Namespace) -> int:
|
|
282
|
+
from .model import load
|
|
283
|
+
from .render import render_markdown
|
|
284
|
+
|
|
285
|
+
out = Path(args.output) if args.output else Path(args.transcript).with_suffix(".md")
|
|
286
|
+
_refuse_transcript(args.transcript, out)
|
|
287
|
+
_refuse_other_files(out)
|
|
288
|
+
_write_text(out, render_markdown(load(args.transcript)))
|
|
289
|
+
print(f"Wrote {_escaped(str(out))}")
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
294
|
+
from .export import NO_STEPS, export_scenario
|
|
295
|
+
from .model import load
|
|
296
|
+
|
|
297
|
+
_refuse_transcript(args.transcript, args.output)
|
|
298
|
+
spec, warnings = export_scenario(load(args.transcript), title=args.title)
|
|
299
|
+
if not spec["steps"]: # Countermap rejects a scenario with no steps, so none is written
|
|
300
|
+
for warning in warnings:
|
|
301
|
+
if warning != NO_STEPS:
|
|
302
|
+
print(f" warning: {_escaped(warning)}", file=sys.stderr)
|
|
303
|
+
raise ValueError(f"{args.transcript}: {NO_STEPS}, so nothing was exported (a Countermap scenario needs at "
|
|
304
|
+
"least one step)")
|
|
305
|
+
_write_text(args.output, json.dumps(spec, indent=2, ensure_ascii=False) + "\n")
|
|
306
|
+
print(f"Wrote {_escaped(args.output)}: {_count(len(spec['steps']), 'step', 'steps')}")
|
|
307
|
+
for warning in warnings: # a warning can name a technique ID from a hand-edited transcript
|
|
308
|
+
print(f" warning: {_escaped(warning)}", file=sys.stderr)
|
|
309
|
+
return 1 if warnings else 0
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
313
|
+
from .parse_main import SafeArgumentParser, add_parse_arguments
|
|
314
|
+
|
|
315
|
+
# Subparsers take the parent's class, so every argument error is defanged and escaped.
|
|
316
|
+
parser = SafeArgumentParser(prog="interlinear", description=__doc__)
|
|
317
|
+
parser.add_argument("--version", action="version", version=f"interlinear {__version__}")
|
|
318
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
319
|
+
|
|
320
|
+
p = sub.add_parser("parse", help="stage 1: raw files to transcript.json (run inside the untrusted sandbox)")
|
|
321
|
+
add_parse_arguments(p)
|
|
322
|
+
p.set_defaults(func=cmd_parse)
|
|
323
|
+
|
|
324
|
+
b = sub.add_parser("build-parse-pyz", help="build the standard-library-only parse zipapp for the sandbox")
|
|
325
|
+
b.add_argument("-o", "--output", default="dist/interlinear-parse.pyz")
|
|
326
|
+
b.set_defaults(func=cmd_build_parse_pyz)
|
|
327
|
+
|
|
328
|
+
r = sub.add_parser("rules", help="list the rule library, or test its examples")
|
|
329
|
+
r.add_argument("action", choices=["list", "test"])
|
|
330
|
+
r.add_argument("--verify", action="store_true", help="also check every technique ID through Countermap")
|
|
331
|
+
r.add_argument("--db", help="Countermap database (default: Countermap's default path)")
|
|
332
|
+
r.set_defaults(func=cmd_rules)
|
|
333
|
+
|
|
334
|
+
g = sub.add_parser("gloss", help="apply the rule library to pending lines")
|
|
335
|
+
g.add_argument("transcript")
|
|
336
|
+
g.add_argument("--db", help="Countermap database (default: Countermap's default path)")
|
|
337
|
+
g.set_defaults(func=cmd_gloss)
|
|
338
|
+
|
|
339
|
+
pe = sub.add_parser("pending", help="print pending lines as JSON, for the gloss skill")
|
|
340
|
+
pe.add_argument("transcript")
|
|
341
|
+
pe.add_argument("--limit", type=_at_least_one, default=50, metavar="N", help="at most N lines (default 50)")
|
|
342
|
+
pe.add_argument("--after", type=int, default=0, metavar="N",
|
|
343
|
+
help="only lines numbered above N: pass the last n printed to get the next page (default 0)")
|
|
344
|
+
pe.set_defaults(func=cmd_pending)
|
|
345
|
+
|
|
346
|
+
ap = sub.add_parser("apply-glosses", help="validate model glosses from a JSON file and fill pending lines")
|
|
347
|
+
ap.add_argument("transcript")
|
|
348
|
+
ap.add_argument("glosses")
|
|
349
|
+
ap.add_argument("--by", required=True, type=_by_label, help="the model ID")
|
|
350
|
+
ap.add_argument("--db", help="Countermap database (default: Countermap's default path)")
|
|
351
|
+
ap.set_defaults(func=cmd_apply_glosses)
|
|
352
|
+
|
|
353
|
+
an = sub.add_parser("annotate", help="validate analyst glosses from a JSON file and merge them; each replaces "
|
|
354
|
+
"the line's earlier gloss, which moves to history")
|
|
355
|
+
an.add_argument("transcript")
|
|
356
|
+
an.add_argument("glosses")
|
|
357
|
+
an.add_argument("--by", required=True, type=_by_label, help="your name")
|
|
358
|
+
an.add_argument("--db", help="Countermap database (default: Countermap's default path)")
|
|
359
|
+
an.set_defaults(func=cmd_annotate)
|
|
360
|
+
|
|
361
|
+
rd = sub.add_parser("render", help="write the Markdown interlinear view")
|
|
362
|
+
rd.add_argument("transcript")
|
|
363
|
+
rd.add_argument("-o", "--output", help="default: the transcript path with .md")
|
|
364
|
+
rd.set_defaults(func=cmd_render)
|
|
365
|
+
|
|
366
|
+
ex = sub.add_parser("export-scenario", help="write a Countermap scenario spec from the glossed lines")
|
|
367
|
+
ex.add_argument("transcript")
|
|
368
|
+
ex.add_argument("-o", "--output", required=True)
|
|
369
|
+
ex.add_argument("--title")
|
|
370
|
+
ex.set_defaults(func=cmd_export)
|
|
371
|
+
return parser
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def main(argv: list[str] | None = None) -> int:
|
|
375
|
+
"""Run a command, with the error handling of parse_main.run_safely.
|
|
376
|
+
|
|
377
|
+
An OSError or ValueError while reading the input (TranscriptError and LibraryError included) and an unavailable
|
|
378
|
+
Countermap are invalid input: exit code 2, with the message defanged and escaped. An error writing an output (the
|
|
379
|
+
transcript, a view, a scenario or the zipapp) is exit code 3, naming the error's type. Anything else, such as a database or YAML error, is an internal error:
|
|
380
|
+
exit code 3, naming only the exception's type, since its message can carry attacker text.
|
|
381
|
+
"""
|
|
382
|
+
args = build_parser().parse_args(argv)
|
|
383
|
+
_utf8_stdio()
|
|
384
|
+
try:
|
|
385
|
+
return args.func(args)
|
|
386
|
+
except _OutputError as exc:
|
|
387
|
+
print(f"error: cannot write output ({exc})", file=sys.stderr)
|
|
388
|
+
return 3
|
|
389
|
+
except (OSError, ValueError, ResolverUnavailable) as exc:
|
|
390
|
+
print(f"error: {_escaped(str(exc))}", file=sys.stderr)
|
|
391
|
+
return 2
|
|
392
|
+
except Exception as exc: # noqa: BLE001 - no traceback, which would print attacker text
|
|
393
|
+
print(f"error: internal {type(exc).__name__}", file=sys.stderr)
|
|
394
|
+
return 3
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Errors shared across stages.
|
|
2
|
+
|
|
3
|
+
Standard library only: the CLI imports this module on every run, and `interlinear parse` must not load pydantic.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResolverUnavailable(RuntimeError):
|
|
10
|
+
"""Countermap or its database is missing or unusable."""
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Export glossed lines as a Countermap scenario spec (spec section 8).
|
|
2
|
+
|
|
3
|
+
scenario.json is read in editors and on GitHub, and Countermap draws it in several formats, so every text in it that
|
|
4
|
+
came from the transcript (the title, the source names and each step's description) has its control and hidden
|
|
5
|
+
characters shown by their code points and is defanged, after any cut: a cut can leave a live host, such as
|
|
6
|
+
login.example-bank.com from login.example-bank.com.evil-test[.]net. Only canonical ATT&CK and ATLAS technique IDs
|
|
7
|
+
are exported; a hand-edited transcript can hold anything in that field.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .model import Technique, Transcript
|
|
16
|
+
from .normalize import defang
|
|
17
|
+
from .textshow import code_point, show_hidden
|
|
18
|
+
from .verify import CANONICAL_ID
|
|
19
|
+
|
|
20
|
+
MAX_STEPS = 100 # Countermap's scenario limit
|
|
21
|
+
MAX_TITLE = 200 # Countermap's title limit
|
|
22
|
+
MAX_DESCRIPTION = 500 # a step's description
|
|
23
|
+
MAX_NAMED_SOURCES = 10 # source names in the scenario's description; the rest are counted
|
|
24
|
+
MAX_NAMES = 300 # characters of source names in the scenario's description
|
|
25
|
+
MAX_LISTED_LINES = 20 # line numbers named in a warning; the rest are counted
|
|
26
|
+
_FIT_PASSES = 4
|
|
27
|
+
ORDER_NOTE = "The steps follow the transcript's line order; each cites its transcript lines and gloss sources."
|
|
28
|
+
NO_STEPS = "no glossed lines with techniques; the scenario has no steps"
|
|
29
|
+
# C0 controls, DEL and C1 controls. JSON escapes only the C0 ones, so a raw C1 control such as U+009B (CSI) would
|
|
30
|
+
# reach a terminal that prints the file.
|
|
31
|
+
_CONTROLS = re.compile("[" + chr(0) + "-" + chr(0x1F) + chr(0x7F) + "-" + chr(0x9F) + "]")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _primary(technique: Technique) -> str:
|
|
35
|
+
if technique.status == "revoked" and technique.replaced_by:
|
|
36
|
+
return technique.replaced_by[0]
|
|
37
|
+
return technique.id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _unreplaced(technique: Technique) -> bool:
|
|
41
|
+
return technique.status == "revoked" and not technique.replaced_by
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ranges(numbers: list[int]) -> str:
|
|
45
|
+
parts: list[str] = []
|
|
46
|
+
start = prev = numbers[0]
|
|
47
|
+
for n in numbers[1:]:
|
|
48
|
+
if n == prev + 1:
|
|
49
|
+
prev = n
|
|
50
|
+
continue
|
|
51
|
+
parts.append(f"{start}-{prev}" if start != prev else f"{start}")
|
|
52
|
+
start = prev = n
|
|
53
|
+
parts.append(f"{start}-{prev}" if start != prev else f"{start}")
|
|
54
|
+
return ("Line " if len(numbers) == 1 else "Lines ") + ", ".join(parts)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _shown(text: str) -> str:
|
|
58
|
+
"""text with each control character and each hidden character (textshow.is_hidden) shown by its code point."""
|
|
59
|
+
return show_hidden(_CONTROLS.sub(lambda m: code_point(m.group()), text))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _fit(text: str, limit: int) -> str:
|
|
63
|
+
"""text with its invisible characters shown and defanged, in at most limit characters.
|
|
64
|
+
|
|
65
|
+
A longer text is cut to leave room for "...", and the cut is defanged, which can lengthen it, then cut again
|
|
66
|
+
until it fits. Should that not settle, the cut ends at its last space instead, leaving out the name it ran into.
|
|
67
|
+
"""
|
|
68
|
+
text = defang(_shown(text))
|
|
69
|
+
if len(text) <= limit:
|
|
70
|
+
return text
|
|
71
|
+
room = limit - 3
|
|
72
|
+
cut = text[:room]
|
|
73
|
+
for _ in range(_FIT_PASSES):
|
|
74
|
+
fanged = defang(cut)
|
|
75
|
+
if len(fanged) <= room:
|
|
76
|
+
return fanged + "..."
|
|
77
|
+
cut = fanged[:room]
|
|
78
|
+
space = cut.rfind(" ")
|
|
79
|
+
return (cut[:space] if space > 0 else "") + "..."
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _source_names(transcript: Transcript) -> str:
|
|
83
|
+
"""The first source names, up to MAX_NAMED_SOURCES of them in at most MAX_NAMES characters, then how many more."""
|
|
84
|
+
named: list[str] = []
|
|
85
|
+
used = 0
|
|
86
|
+
for source in transcript.sources[:MAX_NAMED_SOURCES]:
|
|
87
|
+
name = _fit(source.name, MAX_NAMES)
|
|
88
|
+
cost = len(name) + (2 if named else 0)
|
|
89
|
+
if named and used + cost > MAX_NAMES:
|
|
90
|
+
break
|
|
91
|
+
named.append(name)
|
|
92
|
+
used += cost
|
|
93
|
+
rest = len(transcript.sources) - len(named)
|
|
94
|
+
return ", ".join(named) + (f" and {rest} more" if rest else "")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _lines_warning(numbers: list[int], problem: str) -> str:
|
|
98
|
+
listed = _ranges(numbers[:MAX_LISTED_LINES])
|
|
99
|
+
more = f" and {len(numbers) - MAX_LISTED_LINES} more" if len(numbers) > MAX_LISTED_LINES else ""
|
|
100
|
+
return f"{listed}{more} {'has' if len(numbers) == 1 else 'have'} {problem}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def export_scenario(transcript: Transcript, title: str | None = None) -> tuple[dict, list[str]]:
|
|
104
|
+
usable = []
|
|
105
|
+
non_canonical: list[int] = [] # lines whose technique ID is not an ATT&CK or ATLAS technique ID
|
|
106
|
+
for line in transcript.lines:
|
|
107
|
+
if line.actor == "tool_output" or line.gloss.source not in ("rule", "model", "analyst"):
|
|
108
|
+
continue
|
|
109
|
+
if line.gloss.techniques:
|
|
110
|
+
if CANONICAL_ID.fullmatch(_primary(line.gloss.techniques[0])):
|
|
111
|
+
usable.append(line)
|
|
112
|
+
else:
|
|
113
|
+
non_canonical.append(line.n)
|
|
114
|
+
# A model gloss with no techniques marks a context line. Those lines are left out, so the reader is told.
|
|
115
|
+
model_context = sum(1 for line in transcript.lines
|
|
116
|
+
if line.actor != "tool_output" and line.gloss.source == "model" and not line.gloss.techniques)
|
|
117
|
+
# A step is a run of consecutive lines with the same primary technique, actor and source file, so lines from two
|
|
118
|
+
# files never merge into one step. The steps keep the transcript's order.
|
|
119
|
+
groups: list[dict] = []
|
|
120
|
+
for line in usable:
|
|
121
|
+
key = (_primary(line.gloss.techniques[0]), line.actor, line.source)
|
|
122
|
+
if groups and groups[-1]["key"] == key:
|
|
123
|
+
groups[-1]["lines"].append(line)
|
|
124
|
+
else:
|
|
125
|
+
groups.append({"key": key, "technique": key[0], "actor": line.actor, "lines": [line]})
|
|
126
|
+
warnings: list[str] = []
|
|
127
|
+
if model_context:
|
|
128
|
+
marked = "line was marked context by a model and is" if model_context == 1 else \
|
|
129
|
+
"lines were marked context by a model and are"
|
|
130
|
+
warnings.append(f"{model_context} {marked} not in the scenario")
|
|
131
|
+
if non_canonical:
|
|
132
|
+
warnings.append(_lines_warning(non_canonical, "a technique ID that is not an ATT&CK or ATLAS technique ID; "
|
|
133
|
+
"left out of the scenario"))
|
|
134
|
+
if len(groups) > MAX_STEPS:
|
|
135
|
+
warnings.append(f"{len(groups)} steps exceed Countermap's limit of {MAX_STEPS}; exported the first {MAX_STEPS}")
|
|
136
|
+
groups = groups[:MAX_STEPS]
|
|
137
|
+
steps: list[dict] = []
|
|
138
|
+
unreplaced: dict[str, list[str]] = {} # a revoked technique with no replacement, and the steps that keep it
|
|
139
|
+
for i, group in enumerate(groups, start=1):
|
|
140
|
+
lines = group["lines"]
|
|
141
|
+
sources = sorted({line.gloss.source for line in lines})
|
|
142
|
+
prefix = "Model-glossed: " if sources == ["model"] else ""
|
|
143
|
+
head = f"{prefix}{_ranges([line.n for line in lines])} ({', '.join(sources)}; {group['actor']})"
|
|
144
|
+
meaning = lines[0].gloss.meaning
|
|
145
|
+
step: dict = {"id": f"s{i}", "technique": group["technique"]}
|
|
146
|
+
if i > 1:
|
|
147
|
+
step["after"] = [f"s{i - 1}"]
|
|
148
|
+
step["description"] = _fit(f"{head}: {meaning}" if meaning else head, MAX_DESCRIPTION)
|
|
149
|
+
steps.append(step)
|
|
150
|
+
if any(_unreplaced(line.gloss.techniques[0]) for line in lines):
|
|
151
|
+
unreplaced.setdefault(group["technique"], []).append(step["id"])
|
|
152
|
+
for technique, ids in unreplaced.items():
|
|
153
|
+
warnings.append(f"{technique} is revoked and has no replacement; kept as is in "
|
|
154
|
+
f"{'step' if len(ids) == 1 else 'steps'} {', '.join(ids)}")
|
|
155
|
+
if not steps:
|
|
156
|
+
warnings.append(NO_STEPS)
|
|
157
|
+
spec = {
|
|
158
|
+
"title": _fit(title or transcript.case or "Interlinear transcript", MAX_TITLE),
|
|
159
|
+
"description": f"Exported by Interlinear {__version__} from {_source_names(transcript)}. {ORDER_NOTE}",
|
|
160
|
+
"authors": [],
|
|
161
|
+
"steps": steps,
|
|
162
|
+
"countermeasures": "auto",
|
|
163
|
+
"max_countermeasures_per_step": 2,
|
|
164
|
+
}
|
|
165
|
+
return spec, warnings
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The one atomic writer for files Interlinear writes (spec section 10). Standard library only: the parse stage's zipapp
|
|
2
|
+
includes this module.
|
|
3
|
+
|
|
4
|
+
A file is written to a temporary file in the same folder and renamed over the target with os.replace, so a reader
|
|
5
|
+
sees the old file or the new one, never a partial one, and a failed write leaves the old file as it was.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
import tempfile
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OutputError(Exception):
|
|
19
|
+
"""Writing an output file failed. That is not invalid input, so a command exits with code 3. The message is only
|
|
20
|
+
the underlying error's type name, since an error message can hold an attacker-chosen path."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def file_mode(path: str | Path) -> int:
|
|
24
|
+
"""The mode for a file written to path through a temporary file: the mode of the regular file it replaces, or
|
|
25
|
+
else the mode a plain create would give it, 0o666 less the umask. On Windows only the read-only flag applies."""
|
|
26
|
+
try:
|
|
27
|
+
st = os.stat(path)
|
|
28
|
+
except OSError: # nothing there yet
|
|
29
|
+
st = None
|
|
30
|
+
if st is not None and stat.S_ISREG(st.st_mode):
|
|
31
|
+
return stat.S_IMODE(st.st_mode)
|
|
32
|
+
# The umask can only be read by setting it, so set an owner-only one and restore the old one straight away: a
|
|
33
|
+
# file or folder another thread creates in between is never more open than it would have been.
|
|
34
|
+
umask = os.umask(0o077)
|
|
35
|
+
os.umask(umask)
|
|
36
|
+
return 0o666 & ~umask
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def discard_temp(tmp: str | Path) -> None:
|
|
40
|
+
"""Remove a temporary file after a failed write, without hiding the failure. Windows cannot remove a read-only
|
|
41
|
+
file, which a temporary file given a read-only file's mode is, so it is made writable first."""
|
|
42
|
+
with contextlib.suppress(OSError):
|
|
43
|
+
os.chmod(tmp, stat.S_IREAD | stat.S_IWRITE)
|
|
44
|
+
with contextlib.suppress(OSError):
|
|
45
|
+
Path(tmp).unlink(missing_ok=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def atomic_write(path: str | Path, data: bytes | str | Iterable[str], executable: bool = False) -> Path:
|
|
49
|
+
"""Write data to path atomically, creating missing folders, and return the path.
|
|
50
|
+
|
|
51
|
+
data is bytes, or text written as UTF-8 with no newline translation, so a file is byte-identical on Windows and
|
|
52
|
+
Linux. It may also be an iterable of text chunks, such as json.JSONEncoder.iterencode gives, so a large file is
|
|
53
|
+
never held whole. A new file gets the mode a plain create would give it (0o666 less the umask) and a replaced file
|
|
54
|
+
keeps its mode (file_mode); executable adds the owner's execute bit. On any exception the temporary file is
|
|
55
|
+
removed (discard_temp) and the exception is raised again, an OSError as it came.
|
|
56
|
+
"""
|
|
57
|
+
p = Path(path)
|
|
58
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
mode = file_mode(p) | (stat.S_IXUSR if executable else 0)
|
|
60
|
+
fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=f".{p.name}.", suffix=".tmp")
|
|
61
|
+
try:
|
|
62
|
+
with os.fdopen(fd, "wb") as fh:
|
|
63
|
+
if isinstance(data, bytes):
|
|
64
|
+
fh.write(data)
|
|
65
|
+
elif isinstance(data, str):
|
|
66
|
+
fh.write(data.encode("utf-8"))
|
|
67
|
+
else:
|
|
68
|
+
for chunk in data:
|
|
69
|
+
fh.write(chunk.encode("utf-8"))
|
|
70
|
+
os.chmod(tmp, mode) # mkstemp creates the file 0o600
|
|
71
|
+
os.replace(tmp, p)
|
|
72
|
+
except BaseException:
|
|
73
|
+
discard_temp(tmp)
|
|
74
|
+
raise
|
|
75
|
+
return p
|