malwagon 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.
Potentially problematic release.
This version of malwagon might be problematic. Click here for more details.
- malwagon/__init__.py +13 -0
- malwagon/cli.py +407 -0
- malwagon/client.py +237 -0
- malwagon/config.py +210 -0
- malwagon/errors.py +17 -0
- malwagon/localfile.py +145 -0
- malwagon/render.py +165 -0
- malwagon-0.1.0.dist-info/METADATA +165 -0
- malwagon-0.1.0.dist-info/RECORD +12 -0
- malwagon-0.1.0.dist-info/WHEEL +4 -0
- malwagon-0.1.0.dist-info/entry_points.txt +2 -0
- malwagon-0.1.0.dist-info/licenses/LICENSE +21 -0
malwagon/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Malwagon command line client.
|
|
2
|
+
|
|
3
|
+
Submits a file to the Malwagon malware analysis sandbox, waits for the
|
|
4
|
+
detonation to finish, and prints the verdict.
|
|
5
|
+
|
|
6
|
+
Standard library only, on purpose. A security tool that pulls four transitive
|
|
7
|
+
dependencies to make one HTTPS request has widened its own supply chain for a
|
|
8
|
+
convenience, and every one of those maintainer keys becomes a way into the
|
|
9
|
+
machines this runs on.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = ["__version__"]
|
malwagon/cli.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""malwagon <file> - submit a sample, wait for the detonation, print the verdict.
|
|
2
|
+
|
|
3
|
+
The whole tool is one command with one argument. Everything else is a default
|
|
4
|
+
that can be overridden, because the thing a person actually wants to type is
|
|
5
|
+
|
|
6
|
+
malwagon suspicious.exe
|
|
7
|
+
|
|
8
|
+
and a tool that requires six flags to do its only job is a tool people wrap in a
|
|
9
|
+
shell function and then misconfigure.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from . import __version__, config, localfile, render
|
|
19
|
+
from .client import DEFAULT_API_URL, Client, host_of, normalise_url
|
|
20
|
+
from .errors import MalwagonError
|
|
21
|
+
|
|
22
|
+
#: Poll cadence. The server's read budget is 120 requests a minute per user, so
|
|
23
|
+
#: a scan polled every 4 seconds costs 15 a minute and a dozen concurrent
|
|
24
|
+
#: clients still fit. It widens as the wait grows: a scan that has been running
|
|
25
|
+
#: two minutes is not about to finish in the next four seconds.
|
|
26
|
+
_POLL_START = 4
|
|
27
|
+
_POLL_MAX = 20
|
|
28
|
+
|
|
29
|
+
#: What to tell the user to expect. Measured against the platform's own
|
|
30
|
+
#: documentation: a pre-armed guest starts the sample about three seconds in,
|
|
31
|
+
#: the default run is 120 seconds, and post-run collection (memory, rules,
|
|
32
|
+
#: report) is the rest. A cold boot moves the whole thing by minutes, which is
|
|
33
|
+
#: why this is stated as a range and called an estimate.
|
|
34
|
+
_ESTIMATE_LOW = 90
|
|
35
|
+
_ESTIMATE_HIGH = 300
|
|
36
|
+
|
|
37
|
+
#: Long enough to cover a cold boot plus the 600 second maximum run plus
|
|
38
|
+
#: collection, short enough that a wedged scan does not hold a terminal all day.
|
|
39
|
+
_DEFAULT_DEADLINE = 1800
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_MALICIOUS = 1
|
|
43
|
+
EXIT_ERROR = 2
|
|
44
|
+
EXIT_SUSPICIOUS = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _duration(seconds):
|
|
48
|
+
seconds = int(seconds)
|
|
49
|
+
if seconds < 60:
|
|
50
|
+
return "%ds" % seconds
|
|
51
|
+
return "%dm %02ds" % (seconds // 60, seconds % 60)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_parser():
|
|
55
|
+
parser = argparse.ArgumentParser(
|
|
56
|
+
prog="malwagon",
|
|
57
|
+
description="Submit a file to the Malwagon sandbox and print the verdict.",
|
|
58
|
+
epilog=(
|
|
59
|
+
"examples:\n"
|
|
60
|
+
" malwagon suspicious.exe submit, wait, print the verdict\n"
|
|
61
|
+
" malwagon driver.sys --json machine-readable output\n"
|
|
62
|
+
" malwagon payload.elf --no-wait submit and exit with the scan id\n"
|
|
63
|
+
" malwagon login store the API key for this host\n"
|
|
64
|
+
"\n"
|
|
65
|
+
"the API key, in order of precedence:\n"
|
|
66
|
+
" --api-key-file PATH read the first line of a file\n"
|
|
67
|
+
" --api-key-stdin read the first line of stdin\n"
|
|
68
|
+
" %-21s an environment variable\n"
|
|
69
|
+
" the config file written by 'malwagon login'\n"
|
|
70
|
+
"\n"
|
|
71
|
+
"there is deliberately no --api-key flag: a key on the command line is\n"
|
|
72
|
+
"visible in the process list and is written to your shell history.\n"
|
|
73
|
+
"\n"
|
|
74
|
+
"exit codes: 0 clean, 1 malicious, 2 error, 3 suspicious\n"
|
|
75
|
+
% config.ENV_KEY),
|
|
76
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
77
|
+
|
|
78
|
+
parser.add_argument("target", nargs="?", metavar="FILE",
|
|
79
|
+
help="the file to scan, or 'login' / 'logout'")
|
|
80
|
+
parser.add_argument("--version", action="version",
|
|
81
|
+
version="malwagon %s" % __version__)
|
|
82
|
+
|
|
83
|
+
api = parser.add_argument_group("api")
|
|
84
|
+
api.add_argument("--api-url", default=None, metavar="URL",
|
|
85
|
+
help="base URL of the platform (default %s, or %s)"
|
|
86
|
+
% (DEFAULT_API_URL, config.ENV_URL))
|
|
87
|
+
api.add_argument("--api-key-file", default=None, metavar="PATH",
|
|
88
|
+
help="read the API key from the first line of this file")
|
|
89
|
+
api.add_argument("--api-key-stdin", action="store_true",
|
|
90
|
+
help="read the API key from the first line of stdin")
|
|
91
|
+
api.add_argument("--ca-bundle", default=None, metavar="PATH",
|
|
92
|
+
help="verify TLS against this CA bundle instead of the system store")
|
|
93
|
+
api.add_argument("--proxy", default=None, metavar="URL",
|
|
94
|
+
help="send the request through this proxy")
|
|
95
|
+
api.add_argument("--timeout", type=int, default=120, metavar="SECONDS",
|
|
96
|
+
help="per-request timeout (default 120)")
|
|
97
|
+
|
|
98
|
+
scan = parser.add_argument_group("scan options")
|
|
99
|
+
scan.add_argument("--private", action="store_true",
|
|
100
|
+
help="keep the report private (needs a plan that includes it)")
|
|
101
|
+
scan.add_argument("--internet", action="store_true",
|
|
102
|
+
help="detonate with internet access (paid plans only)")
|
|
103
|
+
scan.add_argument("--os", default=None, metavar="KEY", dest="os_key",
|
|
104
|
+
help="force a sandbox image; by default the platform picks "
|
|
105
|
+
"Windows or Linux from the file itself")
|
|
106
|
+
scan.add_argument("--timeout-run", type=int, default=None, metavar="SECONDS",
|
|
107
|
+
dest="run_timeout",
|
|
108
|
+
help="how long to let the sample run inside the sandbox")
|
|
109
|
+
scan.add_argument("--no-dynamic", action="store_true",
|
|
110
|
+
help="static analysis only, no detonation")
|
|
111
|
+
scan.add_argument("--no-ai", action="store_true",
|
|
112
|
+
help="skip the AI narrative layer")
|
|
113
|
+
|
|
114
|
+
wait = parser.add_argument_group("waiting")
|
|
115
|
+
wait.add_argument("--no-wait", action="store_true",
|
|
116
|
+
help="submit and exit immediately with the scan id")
|
|
117
|
+
wait.add_argument("--wait-timeout", type=int, default=_DEFAULT_DEADLINE,
|
|
118
|
+
metavar="SECONDS",
|
|
119
|
+
help="give up waiting after this long (default %d)"
|
|
120
|
+
% _DEFAULT_DEADLINE)
|
|
121
|
+
|
|
122
|
+
out = parser.add_argument_group("output")
|
|
123
|
+
out.add_argument("--json", action="store_true", dest="as_json",
|
|
124
|
+
help="print one JSON object and nothing else")
|
|
125
|
+
out.add_argument("--quiet", "-q", action="store_true",
|
|
126
|
+
help="suppress progress; the result still prints")
|
|
127
|
+
out.add_argument("--no-color", action="store_true",
|
|
128
|
+
help="never emit colour (NO_COLOR is also honoured)")
|
|
129
|
+
|
|
130
|
+
safety = parser.add_argument_group("safety")
|
|
131
|
+
safety.add_argument("--force", action="store_true",
|
|
132
|
+
help="upload even if the path looks like a credential file")
|
|
133
|
+
safety.add_argument("--follow-symlinks", action="store_true",
|
|
134
|
+
help="upload the target of a symlink rather than refusing")
|
|
135
|
+
return parser
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_api_url(args):
|
|
139
|
+
return normalise_url(args.api_url or os.environ.get(config.ENV_URL)
|
|
140
|
+
or DEFAULT_API_URL)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _error_message(status, body):
|
|
144
|
+
"""The server's own words, sanitised, or a sentence about the status."""
|
|
145
|
+
if isinstance(body, dict):
|
|
146
|
+
error = body.get("error")
|
|
147
|
+
if isinstance(error, dict) and error.get("message"):
|
|
148
|
+
return render.safe(error["message"], limit=300)
|
|
149
|
+
return {
|
|
150
|
+
400: "the platform rejected the request",
|
|
151
|
+
401: "the API key was not accepted",
|
|
152
|
+
402: "no scan credits left on this account",
|
|
153
|
+
403: "this key is not allowed to do that",
|
|
154
|
+
404: "not found",
|
|
155
|
+
413: "the file is too large for this platform",
|
|
156
|
+
429: "rate limited",
|
|
157
|
+
}.get(status, "the platform answered HTTP %d" % status)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _fail_from_response(status, body, headers=None):
|
|
161
|
+
message = _error_message(status, body)
|
|
162
|
+
hint = ""
|
|
163
|
+
if status == 401:
|
|
164
|
+
hint = ("mint a key in the console under Settings, API tokens, then "
|
|
165
|
+
"store it with 'malwagon login'")
|
|
166
|
+
elif status == 403 and "submit" in message:
|
|
167
|
+
hint = "the key needs the submit permission, not just read"
|
|
168
|
+
elif status == 429 and headers is not None:
|
|
169
|
+
retry = headers.get("Retry-After")
|
|
170
|
+
if retry:
|
|
171
|
+
hint = "try again in %s seconds" % render.safe(retry, limit=12)
|
|
172
|
+
return MalwagonError(message, hint=hint)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _plan_notes(report):
|
|
176
|
+
"""What this account did NOT get, said plainly.
|
|
177
|
+
|
|
178
|
+
A Community scan runs network isolated and without the intelligence and AI
|
|
179
|
+
layers, and a report that simply omits those sections reads as "the sample
|
|
180
|
+
did nothing" rather than "we did not look". Saying which layers were absent
|
|
181
|
+
is the difference between a result and a misleading result.
|
|
182
|
+
"""
|
|
183
|
+
notes = []
|
|
184
|
+
if not isinstance(report, dict):
|
|
185
|
+
return notes
|
|
186
|
+
intel = report.get("intel")
|
|
187
|
+
ai = report.get("ai")
|
|
188
|
+
empty_intel = not intel or (isinstance(intel, dict) and not intel.get("items")
|
|
189
|
+
and not intel.get("sources"))
|
|
190
|
+
if empty_intel:
|
|
191
|
+
notes.append("threat intelligence lookups did not run")
|
|
192
|
+
if not ai:
|
|
193
|
+
notes.append("the AI narrative did not run")
|
|
194
|
+
return notes
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_login(args, printer):
|
|
198
|
+
api_url = _resolve_api_url(args)
|
|
199
|
+
host = host_of(api_url)
|
|
200
|
+
key, source = config.resolve_key(args, host, prompt=True)
|
|
201
|
+
client = Client(api_url, key, timeout=args.timeout, ca_bundle=args.ca_bundle,
|
|
202
|
+
proxy=args.proxy)
|
|
203
|
+
# Proved before it is stored. Writing a key that does not work, and finding
|
|
204
|
+
# out at the next scan, is the failure this round trip exists to prevent.
|
|
205
|
+
status, body, _ = client.scan_status(0)
|
|
206
|
+
if status in (401, 403):
|
|
207
|
+
raise _fail_from_response(status, body)
|
|
208
|
+
if status >= 500:
|
|
209
|
+
raise MalwagonError("the platform is not answering (HTTP %d)" % status)
|
|
210
|
+
path = config.save_key(host, key, api_url)
|
|
211
|
+
printer.note("key accepted (read from the %s) and saved to %s" % (source, path))
|
|
212
|
+
if os.name == "posix":
|
|
213
|
+
printer.note("the file is readable only by you")
|
|
214
|
+
return EXIT_OK
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_logout(args, printer):
|
|
218
|
+
host = host_of(_resolve_api_url(args))
|
|
219
|
+
if config.forget_key(host):
|
|
220
|
+
printer.note("removed the stored key for %s" % host)
|
|
221
|
+
else:
|
|
222
|
+
printer.note("no key was stored for %s" % host)
|
|
223
|
+
return EXIT_OK
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _submit(client, args, printer):
|
|
227
|
+
handle, name, size = localfile.open_sample(
|
|
228
|
+
args.target, allow_sensitive=args.force,
|
|
229
|
+
allow_symlink=args.follow_symlinks)
|
|
230
|
+
with handle:
|
|
231
|
+
printer.note("uploading %s (%s) to %s"
|
|
232
|
+
% (name, localfile.human_size(size), client.host))
|
|
233
|
+
fields = {
|
|
234
|
+
"private": "true" if args.private else None,
|
|
235
|
+
"internet": "true" if args.internet else None,
|
|
236
|
+
"os": args.os_key,
|
|
237
|
+
"timeout": str(args.run_timeout) if args.run_timeout else None,
|
|
238
|
+
"dynamic": "false" if args.no_dynamic else None,
|
|
239
|
+
"ai": "false" if args.no_ai else None,
|
|
240
|
+
}
|
|
241
|
+
status, body, headers = client.submit_file(handle, name, fields)
|
|
242
|
+
if status != 202:
|
|
243
|
+
raise _fail_from_response(status, body, headers)
|
|
244
|
+
scan = (body or {}).get("scan")
|
|
245
|
+
if not isinstance(scan, dict) or not isinstance(scan.get("scan_id"), int):
|
|
246
|
+
raise MalwagonError("the platform accepted the upload but did not "
|
|
247
|
+
"return a scan id")
|
|
248
|
+
return scan
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _wait(client, scan_id, args, printer):
|
|
252
|
+
"""Poll until the scan is terminal, saying so while it happens."""
|
|
253
|
+
deadline = time.monotonic() + max(30, args.wait_timeout)
|
|
254
|
+
started = time.monotonic()
|
|
255
|
+
interval = _POLL_START
|
|
256
|
+
last_status = ""
|
|
257
|
+
printer.note("waiting for the sandbox, usually %d to %d seconds"
|
|
258
|
+
% (_ESTIMATE_LOW, _ESTIMATE_HIGH))
|
|
259
|
+
scan = None
|
|
260
|
+
while True:
|
|
261
|
+
status, body, headers = client.scan_status(scan_id)
|
|
262
|
+
if status == 429:
|
|
263
|
+
retry = 0
|
|
264
|
+
try:
|
|
265
|
+
retry = int(headers.get("Retry-After") or 0)
|
|
266
|
+
except (TypeError, ValueError):
|
|
267
|
+
retry = 0
|
|
268
|
+
# A hostile or misconfigured server can send an enormous
|
|
269
|
+
# Retry-After; honour it up to a ceiling and no further.
|
|
270
|
+
interval = max(interval, min(retry or interval, 60))
|
|
271
|
+
elif status != 200:
|
|
272
|
+
raise _fail_from_response(status, body, headers)
|
|
273
|
+
else:
|
|
274
|
+
scan = (body or {}).get("scan")
|
|
275
|
+
if not isinstance(scan, dict):
|
|
276
|
+
raise MalwagonError("the platform returned an unreadable status")
|
|
277
|
+
state = render.safe(scan.get("status"), limit=24)
|
|
278
|
+
if state not in render.STATUSES:
|
|
279
|
+
state = "running"
|
|
280
|
+
if state != last_status:
|
|
281
|
+
last_status = state
|
|
282
|
+
printer.note(" %-12s %s elapsed"
|
|
283
|
+
% (state, _duration(time.monotonic() - started)))
|
|
284
|
+
if scan.get("terminal"):
|
|
285
|
+
return scan
|
|
286
|
+
if time.monotonic() > deadline:
|
|
287
|
+
raise MalwagonError(
|
|
288
|
+
"the scan did not finish within %s" % _duration(args.wait_timeout),
|
|
289
|
+
hint="it may still be running: %s" % client.report_url(scan_id))
|
|
290
|
+
time.sleep(interval)
|
|
291
|
+
interval = min(_POLL_MAX, interval * 1.5)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _print_result(scan, report, url, printer, args, notes):
|
|
295
|
+
verdict, raw = render.verdict_word(scan.get("verdict"))
|
|
296
|
+
score = scan.get("score")
|
|
297
|
+
score = score if isinstance(score, int) and 0 <= score <= 100 else None
|
|
298
|
+
|
|
299
|
+
if args.as_json:
|
|
300
|
+
printer.out(json.dumps({
|
|
301
|
+
"scan_id": scan.get("scan_id"),
|
|
302
|
+
"verdict": verdict,
|
|
303
|
+
"verdict_raw": raw or None,
|
|
304
|
+
"score": score,
|
|
305
|
+
"status": render.safe(scan.get("status"), limit=24),
|
|
306
|
+
"sha256": render.safe(scan.get("sha256"), limit=64),
|
|
307
|
+
"size": scan.get("size") if isinstance(scan.get("size"), int) else None,
|
|
308
|
+
"report_url": url,
|
|
309
|
+
"limitations": notes,
|
|
310
|
+
}, indent=2, sort_keys=True))
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
printer.out("")
|
|
314
|
+
label = verdict.upper()
|
|
315
|
+
if raw:
|
|
316
|
+
label += " (server said %r)" % raw
|
|
317
|
+
line = printer.paint(label, verdict)
|
|
318
|
+
if score is not None:
|
|
319
|
+
line += " score %d/100" % score
|
|
320
|
+
printer.out(" " + line)
|
|
321
|
+
digest = render.safe(scan.get("sha256"), limit=64)
|
|
322
|
+
if digest:
|
|
323
|
+
printer.out(" sha256 " + printer.paint(digest, "dim"))
|
|
324
|
+
printer.out(" report " + url)
|
|
325
|
+
if notes:
|
|
326
|
+
printer.out("")
|
|
327
|
+
for note in notes:
|
|
328
|
+
printer.out(" " + printer.paint("- " + note, "dim"))
|
|
329
|
+
printer.out(" " + printer.paint(
|
|
330
|
+
" a paid plan adds internet egress, threat intelligence and the",
|
|
331
|
+
"dim"))
|
|
332
|
+
printer.out(" " + printer.paint(
|
|
333
|
+
" AI narrative to this report", "dim"))
|
|
334
|
+
printer.out("")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def run(argv=None):
|
|
338
|
+
parser = build_parser()
|
|
339
|
+
args = parser.parse_args(argv)
|
|
340
|
+
printer = render.Printer(colour=False if args.no_color else None,
|
|
341
|
+
quiet=args.quiet)
|
|
342
|
+
|
|
343
|
+
if not args.target:
|
|
344
|
+
parser.print_help(sys.stderr)
|
|
345
|
+
return EXIT_ERROR
|
|
346
|
+
if args.target == "login":
|
|
347
|
+
return cmd_login(args, printer)
|
|
348
|
+
if args.target == "logout":
|
|
349
|
+
return cmd_logout(args, printer)
|
|
350
|
+
|
|
351
|
+
api_url = _resolve_api_url(args)
|
|
352
|
+
host = host_of(api_url)
|
|
353
|
+
key, _source = config.resolve_key(args, host)
|
|
354
|
+
client = Client(api_url, key, timeout=args.timeout,
|
|
355
|
+
ca_bundle=args.ca_bundle, proxy=args.proxy)
|
|
356
|
+
|
|
357
|
+
scan = _submit(client, args, printer)
|
|
358
|
+
scan_id = scan["scan_id"]
|
|
359
|
+
url = client.report_url(scan_id)
|
|
360
|
+
printer.note("scan %d queued" % scan_id)
|
|
361
|
+
|
|
362
|
+
if args.no_wait:
|
|
363
|
+
if args.as_json:
|
|
364
|
+
printer.out(json.dumps({"scan_id": scan_id, "status": "queued",
|
|
365
|
+
"report_url": url}, indent=2, sort_keys=True))
|
|
366
|
+
else:
|
|
367
|
+
printer.out(url)
|
|
368
|
+
return EXIT_OK
|
|
369
|
+
|
|
370
|
+
scan = _wait(client, scan_id, args, printer)
|
|
371
|
+
state = render.safe(scan.get("status"), limit=24)
|
|
372
|
+
if state != "completed":
|
|
373
|
+
raise MalwagonError("the scan ended as %s" % (state or "unknown"),
|
|
374
|
+
hint=url)
|
|
375
|
+
|
|
376
|
+
notes = []
|
|
377
|
+
if scan.get("report_available"):
|
|
378
|
+
status, body, _ = client.scan_report(scan_id)
|
|
379
|
+
if status == 200 and isinstance(body, dict):
|
|
380
|
+
notes = _plan_notes(body.get("report"))
|
|
381
|
+
|
|
382
|
+
_print_result(scan, None, url, printer, args, notes)
|
|
383
|
+
verdict, _ = render.verdict_word(scan.get("verdict"))
|
|
384
|
+
if verdict == "malicious":
|
|
385
|
+
return EXIT_MALICIOUS
|
|
386
|
+
if verdict == "suspicious":
|
|
387
|
+
return EXIT_SUSPICIOUS
|
|
388
|
+
return EXIT_OK
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def main(argv=None):
|
|
392
|
+
try:
|
|
393
|
+
return run(argv)
|
|
394
|
+
except MalwagonError as exc:
|
|
395
|
+
print("malwagon: %s" % config.redact(exc.message), file=sys.stderr)
|
|
396
|
+
if exc.hint:
|
|
397
|
+
print(" %s" % config.redact(exc.hint), file=sys.stderr)
|
|
398
|
+
return exc.exit_code
|
|
399
|
+
except KeyboardInterrupt:
|
|
400
|
+
print("\nmalwagon: interrupted", file=sys.stderr)
|
|
401
|
+
return 130
|
|
402
|
+
except BrokenPipeError:
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
if __name__ == "__main__":
|
|
407
|
+
sys.exit(main())
|
malwagon/client.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""The HTTP half: one host, one bearer token, bounded answers.
|
|
2
|
+
|
|
3
|
+
Standard library only. `urllib.request` is more awkward than `requests`, and it
|
|
4
|
+
is four fewer maintainer keys in the trust path of a tool that handles malware
|
|
5
|
+
samples and an API credential.
|
|
6
|
+
|
|
7
|
+
The rules this module exists to keep:
|
|
8
|
+
|
|
9
|
+
- HTTPS, verified, always. There is no --insecure and there will not be one.
|
|
10
|
+
A flag that turns off certificate checking is used far more often to make an
|
|
11
|
+
error go away than to solve the problem the error was reporting.
|
|
12
|
+
- No redirects, ever followed. A redirect is the standard way to walk a bearer
|
|
13
|
+
token onto a host that was not the one it was stored for, and this client
|
|
14
|
+
reports the Location instead of chasing it.
|
|
15
|
+
- No ambient configuration. `urllib` reads no netrc and no proxy environment
|
|
16
|
+
here unless the user asks for a proxy explicitly, so a `machine ...` line in
|
|
17
|
+
~/.netrc cannot attach a second credential to our request.
|
|
18
|
+
- Every response is bounded before it is parsed. `Content-Length` is a claim,
|
|
19
|
+
not a fact, so the read is capped by count, and the cap applies to the
|
|
20
|
+
DECOMPRESSED stream - a 2 MB gzip body can be 20 GB of JSON.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import gzip
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import socket
|
|
27
|
+
import ssl
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.parse
|
|
30
|
+
import urllib.request
|
|
31
|
+
import uuid
|
|
32
|
+
|
|
33
|
+
from . import __version__
|
|
34
|
+
from .config import redact
|
|
35
|
+
from .errors import MalwagonError
|
|
36
|
+
|
|
37
|
+
DEFAULT_API_URL = "https://malwagon.com"
|
|
38
|
+
|
|
39
|
+
#: Generous for the objects this API returns (a report is a few tens of KB) and
|
|
40
|
+
#: far below anything that would trouble a laptop.
|
|
41
|
+
MAX_RESPONSE = 4 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
USER_AGENT = "malwagon-cli/%s" % __version__
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalise_url(raw):
|
|
47
|
+
"""Validate a base URL and return it without a trailing slash.
|
|
48
|
+
|
|
49
|
+
Refusals, each because it is a way to move a credential somewhere it was not
|
|
50
|
+
meant to go: a non-https scheme off the loopback, a URL carrying userinfo
|
|
51
|
+
(`https://user:pass@host` - a second credential, and one that shows up in
|
|
52
|
+
logs), and a URL with a query or fragment, which a base URL has no use for
|
|
53
|
+
and which would be silently dropped when a path is appended.
|
|
54
|
+
"""
|
|
55
|
+
raw = (raw or "").strip()
|
|
56
|
+
if not raw:
|
|
57
|
+
raise MalwagonError("the API URL is empty")
|
|
58
|
+
parts = urllib.parse.urlsplit(raw)
|
|
59
|
+
if not parts.scheme or not parts.netloc:
|
|
60
|
+
raise MalwagonError("%r is not a full URL (try https://malwagon.com)" % raw)
|
|
61
|
+
if parts.username or parts.password:
|
|
62
|
+
raise MalwagonError("the API URL must not contain a username or password")
|
|
63
|
+
if parts.query or parts.fragment:
|
|
64
|
+
raise MalwagonError("the API URL must not contain a query or a fragment")
|
|
65
|
+
host = (parts.hostname or "").lower()
|
|
66
|
+
if parts.scheme != "https":
|
|
67
|
+
if not (parts.scheme == "http"
|
|
68
|
+
and host in ("localhost", "127.0.0.1", "::1")):
|
|
69
|
+
raise MalwagonError(
|
|
70
|
+
"the API URL must be https (%r is not)" % raw,
|
|
71
|
+
hint="plain http is accepted only for localhost")
|
|
72
|
+
return urllib.parse.urlunsplit(
|
|
73
|
+
(parts.scheme, parts.netloc, parts.path.rstrip("/"), "", ""))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def host_of(url):
|
|
77
|
+
return (urllib.parse.urlsplit(url).hostname or "").lower()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
81
|
+
"""A redirect is reported, never followed.
|
|
82
|
+
|
|
83
|
+
Following one is how a bearer token reaches a host it was not stored for,
|
|
84
|
+
and following it "just once, to the same host" still hands an in-path
|
|
85
|
+
attacker an https-to-http downgrade.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
89
|
+
raise MalwagonError(
|
|
90
|
+
"the server answered %s and redirected to %s"
|
|
91
|
+
% (code, redact(newurl)),
|
|
92
|
+
hint="this client does not follow redirects; check --api-url")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _ssl_context():
|
|
96
|
+
ctx = ssl.create_default_context()
|
|
97
|
+
ctx.check_hostname = True
|
|
98
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
99
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
100
|
+
# Belt and braces: an environment that disabled verification globally would
|
|
101
|
+
# otherwise be honoured silently, which is the one failure a security tool
|
|
102
|
+
# must not have.
|
|
103
|
+
if ctx.verify_mode is not ssl.CERT_REQUIRED or not ctx.check_hostname:
|
|
104
|
+
raise MalwagonError("this Python's TLS defaults have been weakened; "
|
|
105
|
+
"refusing to send a credential over it")
|
|
106
|
+
return ctx
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Client:
|
|
110
|
+
def __init__(self, api_url, api_key, *, timeout=60, ca_bundle=None,
|
|
111
|
+
proxy=None):
|
|
112
|
+
self.api_url = normalise_url(api_url)
|
|
113
|
+
self.host = host_of(self.api_url)
|
|
114
|
+
self.api_key = api_key
|
|
115
|
+
self.timeout = timeout
|
|
116
|
+
ctx = _ssl_context()
|
|
117
|
+
if ca_bundle:
|
|
118
|
+
try:
|
|
119
|
+
ctx.load_verify_locations(cafile=ca_bundle)
|
|
120
|
+
except (OSError, ssl.SSLError) as exc:
|
|
121
|
+
raise MalwagonError("could not load %s: %s" % (ca_bundle, exc)) from exc
|
|
122
|
+
handlers = [urllib.request.HTTPSHandler(context=ctx), _NoRedirect()]
|
|
123
|
+
# `ProxyHandler({})` with an EMPTY mapping is what disables proxy
|
|
124
|
+
# discovery; omitting the handler would let urllib read the environment.
|
|
125
|
+
handlers.append(urllib.request.ProxyHandler({"https": proxy, "http": proxy}
|
|
126
|
+
if proxy else {}))
|
|
127
|
+
self._opener = urllib.request.build_opener(*handlers)
|
|
128
|
+
|
|
129
|
+
# -- transport -------------------------------------------------------
|
|
130
|
+
def _request(self, method, path, *, body=None, content_type=None):
|
|
131
|
+
url = self.api_url + path
|
|
132
|
+
request = urllib.request.Request(url, data=body, method=method)
|
|
133
|
+
request.add_header("Authorization", "Bearer " + self.api_key)
|
|
134
|
+
request.add_header("Accept", "application/json")
|
|
135
|
+
request.add_header("User-Agent", USER_AGENT)
|
|
136
|
+
# Asked for explicitly so the response cap below is the only thing that
|
|
137
|
+
# decides how much is decompressed.
|
|
138
|
+
request.add_header("Accept-Encoding", "identity")
|
|
139
|
+
if content_type:
|
|
140
|
+
request.add_header("Content-Type", content_type)
|
|
141
|
+
try:
|
|
142
|
+
response = self._opener.open(request, timeout=self.timeout)
|
|
143
|
+
except urllib.error.HTTPError as exc:
|
|
144
|
+
return self._decode(exc.code, exc.headers, exc)
|
|
145
|
+
except urllib.error.URLError as exc:
|
|
146
|
+
reason = getattr(exc, "reason", exc)
|
|
147
|
+
if isinstance(reason, ssl.SSLCertVerificationError):
|
|
148
|
+
raise MalwagonError(
|
|
149
|
+
"the TLS certificate for %s could not be verified: %s"
|
|
150
|
+
% (self.host, reason.verify_message or reason),
|
|
151
|
+
hint="if this is a private deployment, pass --ca-bundle") from exc
|
|
152
|
+
if isinstance(reason, socket.timeout):
|
|
153
|
+
raise MalwagonError("%s did not answer within %ds"
|
|
154
|
+
% (self.host, self.timeout)) from exc
|
|
155
|
+
raise MalwagonError("could not reach %s: %s"
|
|
156
|
+
% (self.host, redact(reason))) from exc
|
|
157
|
+
except socket.timeout as exc:
|
|
158
|
+
raise MalwagonError("%s did not answer within %ds"
|
|
159
|
+
% (self.host, self.timeout)) from exc
|
|
160
|
+
with response:
|
|
161
|
+
return self._decode(response.status, response.headers, response)
|
|
162
|
+
|
|
163
|
+
def _decode(self, status, headers, stream):
|
|
164
|
+
"""(status, parsed-body-or-None, headers), with every bound applied."""
|
|
165
|
+
try:
|
|
166
|
+
declared = int(headers.get("Content-Length") or 0)
|
|
167
|
+
except (TypeError, ValueError):
|
|
168
|
+
declared = 0
|
|
169
|
+
if declared > MAX_RESPONSE:
|
|
170
|
+
raise MalwagonError("%s declared a %d byte response, which is over "
|
|
171
|
+
"this client's %d byte ceiling"
|
|
172
|
+
% (self.host, declared, MAX_RESPONSE))
|
|
173
|
+
raw = stream.read(MAX_RESPONSE + 1)
|
|
174
|
+
if len(raw) > MAX_RESPONSE:
|
|
175
|
+
raise MalwagonError("the response from %s exceeded %d bytes"
|
|
176
|
+
% (self.host, MAX_RESPONSE))
|
|
177
|
+
if (headers.get("Content-Encoding") or "").lower() == "gzip":
|
|
178
|
+
try:
|
|
179
|
+
with gzip.GzipFile(fileobj=io.BytesIO(raw)) as gz:
|
|
180
|
+
raw = gz.read(MAX_RESPONSE + 1)
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
raise MalwagonError("the response from %s was not valid gzip"
|
|
183
|
+
% self.host) from exc
|
|
184
|
+
if len(raw) > MAX_RESPONSE:
|
|
185
|
+
raise MalwagonError("the response from %s expanded past %d bytes"
|
|
186
|
+
% (self.host, MAX_RESPONSE))
|
|
187
|
+
body = None
|
|
188
|
+
kind = (headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
|
189
|
+
if kind == "application/json" and raw:
|
|
190
|
+
try:
|
|
191
|
+
body = json.loads(raw.decode("utf-8"))
|
|
192
|
+
except (ValueError, UnicodeDecodeError, RecursionError):
|
|
193
|
+
body = None
|
|
194
|
+
if body is not None and not isinstance(body, dict):
|
|
195
|
+
body = None
|
|
196
|
+
return status, body, headers
|
|
197
|
+
|
|
198
|
+
# -- endpoints -------------------------------------------------------
|
|
199
|
+
def submit_file(self, handle, name, fields):
|
|
200
|
+
"""POST /api/v1/scans/file as multipart, built by hand.
|
|
201
|
+
|
|
202
|
+
Built rather than borrowed because the whole file is already open and
|
|
203
|
+
bounded; encoding it into a string first would double the memory for a
|
|
204
|
+
128 MB sample.
|
|
205
|
+
"""
|
|
206
|
+
boundary = "----malwagon" + uuid.uuid4().hex
|
|
207
|
+
parts = []
|
|
208
|
+
for key, value in sorted(fields.items()):
|
|
209
|
+
if value is None:
|
|
210
|
+
continue
|
|
211
|
+
parts.append(
|
|
212
|
+
("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n"
|
|
213
|
+
% (boundary, key, value)).encode("utf-8"))
|
|
214
|
+
head = ("--%s\r\nContent-Disposition: form-data; name=\"file\"; "
|
|
215
|
+
"filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
|
216
|
+
% (boundary, name.replace('"', "_"))).encode("utf-8")
|
|
217
|
+
tail = ("\r\n--%s--\r\n" % boundary).encode("utf-8")
|
|
218
|
+
body = b"".join(parts) + head + handle.read() + tail
|
|
219
|
+
return self._request(
|
|
220
|
+
"POST", "/api/v1/scans/file", body=body,
|
|
221
|
+
content_type="multipart/form-data; boundary=%s" % boundary)
|
|
222
|
+
|
|
223
|
+
def scan_status(self, scan_id):
|
|
224
|
+
return self._request("GET", "/api/v1/scans/%d" % int(scan_id))
|
|
225
|
+
|
|
226
|
+
def scan_report(self, scan_id):
|
|
227
|
+
return self._request("GET", "/api/v1/scans/%d/report" % int(scan_id))
|
|
228
|
+
|
|
229
|
+
def report_url(self, scan_id):
|
|
230
|
+
"""The human page for a scan.
|
|
231
|
+
|
|
232
|
+
Built here rather than read from the response, because the API returns
|
|
233
|
+
no URL - deliberately, since it emits no field nobody named. `/s/<id>`
|
|
234
|
+
is the platform's own short link: it resolves for the owner and
|
|
235
|
+
redirects a public scan onto its indexable page.
|
|
236
|
+
"""
|
|
237
|
+
return "%s/s/%d" % (self.api_url, int(scan_id))
|