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 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))