From 50a94db2cc2f3107f6f94c1e1407cb6840d1da08 Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Sat, 16 Dec 2017 09:19:59 +1300 Subject: [PATCH 1/6] commands: Reassess the cuts API Make the cuts API more transparent. Cut specifications are no longer a centrally resolved core type, and flows are now passed explicitly. --- mitmproxy/addons/cut.py | 79 +++++++++---------- mitmproxy/command.py | 10 ++- mitmproxy/tools/console/defaultkeys.py | 2 +- test/mitmproxy/addons/test_cut.py | 101 ++++++++----------------- test/mitmproxy/test_command.py | 2 +- 5 files changed, 77 insertions(+), 117 deletions(-) diff --git a/mitmproxy/addons/cut.py b/mitmproxy/addons/cut.py index 5ec4c99eb..6b9dc7235 100644 --- a/mitmproxy/addons/cut.py +++ b/mitmproxy/addons/cut.py @@ -56,49 +56,37 @@ def extract(cut: str, f: flow.Flow) -> typing.Union[str, bytes]: return str(current or "") -def parse_cutspec(s: str) -> typing.Tuple[str, typing.Sequence[str]]: - """ - Returns (flowspec, [cuts]). - - Raises exceptions.CommandError if input is invalid. - """ - parts = s.split("|", maxsplit=1) - flowspec = "@all" - if len(parts) == 2: - flowspec = parts[1].strip() - cuts = parts[0] - cutparts = [i.strip() for i in cuts.split(",") if i.strip()] - if len(cutparts) == 0: - raise exceptions.CommandError("Invalid cut specification.") - return flowspec, cutparts - - class Cut: @command.command("cut") - def cut(self, cutspec: str) -> command.Cuts: + def cut( + self, + flows: typing.Sequence[flow.Flow], + cuts: typing.Sequence[command.Cut] + ) -> command.Cuts: """ - Resolve a cut specification of the form "cuts|flowspec". The cuts - are a comma-separated list of cut snippets. Cut snippets are - attribute paths from the base of the flow object, with a few - conveniences - "q", "s", "cc" and "sc" are shortcuts for request, - response, client_conn and server_conn, "port" and "host" retrieve - parts of an address tuple, ".header[key]" retrieves a header value. - Return values converted sensibly: SSL certicates are converted to PEM + Cut data from a set of flows. Cut specifications are attribute paths + from the base of the flow object, with a few conveniences - "q", + "s", "cc" and "sc" are shortcuts for request, response, client_conn + and server_conn, "port" and "host" retrieve parts of an address + tuple, ".header[key]" retrieves a header value. Return values + converted to strings or bytes: SSL certicates are converted to PEM format, bools are "true" or "false", "bytes" are preserved, and all - other values are converted to strings. The flowspec is optional, and - if it is not specified, it is assumed to be @all. + other values are converted to strings. """ - flowspec, cuts = parse_cutspec(cutspec) - flows = ctx.master.commands.call_args("view.resolve", [flowspec]) ret = [] for f in flows: ret.append([extract(c, f) for c in cuts]) return ret @command.command("cut.save") - def save(self, cuts: command.Cuts, path: command.Path) -> None: + def save( + self, + flows: typing.Sequence[flow.Flow], + cuts: typing.Sequence[command.Cut], + path: command.Path + ) -> None: """ - Save cuts to file. If there are multiple rows or columns, the format + Save cuts to file. If there are multiple flows or cuts, the format is UTF-8 encoded CSV. If there is exactly one row and one column, the data is written to file as-is, with raw bytes preserved. If the path is prefixed with a "+", values are appended if there is an @@ -108,30 +96,37 @@ class Cut: if path.startswith("+"): append = True path = command.Path(path[1:]) - if len(cuts) == 1 and len(cuts[0]) == 1: + if len(cuts) == 1 and len(flows) == 1: with open(path, "ab" if append else "wb") as fp: if fp.tell() > 0: # We're appending to a file that already exists and has content fp.write(b"\n") - v = cuts[0][0] - if isinstance(v, bytes): - fp.write(v) - else: - fp.write(v.encode("utf8")) + for v in [extract(cuts[0], f) for f in flows]: + if isinstance(v, bytes): + fp.write(v) + else: + fp.write(v.encode("utf8")) ctx.log.alert("Saved single cut.") else: with open(path, "a" if append else "w", newline='', encoding="utf8") as fp: writer = csv.writer(fp) - for r in cuts: + for f in flows: + vals = [extract(c, f) for c in cuts] writer.writerow( - [strutils.always_str(c) or "" for c in r] # type: ignore + [strutils.always_str(x) or "" for x in vals] # type: ignore ) - ctx.log.alert("Saved %s cuts as CSV." % len(cuts)) + ctx.log.alert("Saved %s cuts over %d flows as CSV." % (len(cuts), len(flows))) @command.command("cut.clip") - def clip(self, cuts: command.Cuts) -> None: + def clip( + self, + flows: typing.Sequence[flow.Flow], + cuts: typing.Sequence[command.Cut], + ) -> None: """ - Send cuts to the system clipboard. + Send cuts to the clipboard. If there are multiple flows or cuts, the + format is UTF-8 encoded CSV. If there is exactly one row and one + column, the data is written to file as-is, with raw bytes preserved. """ fp = io.StringIO(newline="") if len(cuts) == 1 and len(cuts[0]) == 1: diff --git a/mitmproxy/command.py b/mitmproxy/command.py index 05caf261e..82bad4fa0 100644 --- a/mitmproxy/command.py +++ b/mitmproxy/command.py @@ -29,6 +29,10 @@ Cuts = typing.Sequence[ ] +class Cut(str): + pass + + class Path(str): pass @@ -52,8 +56,10 @@ def typename(t: type, ret: bool) -> str: return "[flow]" if ret else "flowspec" elif t == typing.Sequence[str]: return "[str]" + elif t == typing.Sequence[Cut]: + return "[cut]" elif t == Cuts: - return "[cuts]" if ret else "cutspec" + return "[cuts]" elif t == flow.Flow: return "flow" elif issubclass(t, (str, int, bool)): @@ -264,7 +270,7 @@ def parsearg(manager: CommandManager, spec: str, argtype: type) -> typing.Any: "Command requires one flow, specification matched %s." % len(flows) ) return flows[0] - elif argtype == typing.Sequence[str]: + elif argtype in (typing.Sequence[str], typing.Sequence[Cut]): return [i.strip() for i in spec.split(",")] else: raise exceptions.CommandError("Unsupported argument type: %s" % argtype) diff --git a/mitmproxy/tools/console/defaultkeys.py b/mitmproxy/tools/console/defaultkeys.py index c4a44aca4..b845a3ae6 100644 --- a/mitmproxy/tools/console/defaultkeys.py +++ b/mitmproxy/tools/console/defaultkeys.py @@ -31,7 +31,7 @@ def map(km): km.add("A", "flow.resume @all", ["flowlist", "flowview"], "Resume all intercepted flows") km.add("a", "flow.resume @focus", ["flowlist", "flowview"], "Resume this intercepted flow") km.add( - "b", "console.command cut.save s.content|@focus ''", + "b", "console.command cut.save @focus s.content ", ["flowlist", "flowview"], "Save response body to file" ) diff --git a/test/mitmproxy/addons/test_cut.py b/test/mitmproxy/addons/test_cut.py index 242c6c2f1..bb3e1c2d2 100644 --- a/test/mitmproxy/addons/test_cut.py +++ b/test/mitmproxy/addons/test_cut.py @@ -56,42 +56,6 @@ def test_extract(): assert "CERTIFICATE" in cut.extract("sc.cert", tf) -def test_parse_cutspec(): - tests = [ - ("", None, True), - ("req.method", ("@all", ["req.method"]), False), - ( - "req.method,req.host", - ("@all", ["req.method", "req.host"]), - False - ), - ( - "req.method,req.host|~b foo", - ("~b foo", ["req.method", "req.host"]), - False - ), - ( - "req.method,req.host|~b foo | ~b bar", - ("~b foo | ~b bar", ["req.method", "req.host"]), - False - ), - ( - "req.method, req.host | ~b foo | ~b bar", - ("~b foo | ~b bar", ["req.method", "req.host"]), - False - ), - ] - for cutspec, output, err in tests: - try: - assert cut.parse_cutspec(cutspec) == output - except exceptions.CommandError: - if not err: - raise - else: - if err: - raise AssertionError("Expected error.") - - def test_headername(): with pytest.raises(exceptions.CommandError): cut.headername("header[foo.") @@ -110,69 +74,64 @@ def test_cut_clip(): v.add([tflow.tflow(resp=True)]) with mock.patch('pyperclip.copy') as pc: - tctx.command(c.clip, "q.method|@all") + tctx.command(c.clip, "@all", "q.method") assert pc.called with mock.patch('pyperclip.copy') as pc: - tctx.command(c.clip, "q.content|@all") + tctx.command(c.clip, "@all", "q.content") assert pc.called with mock.patch('pyperclip.copy') as pc: - tctx.command(c.clip, "q.method,q.content|@all") + tctx.command(c.clip, "@all", "q.method,q.content") assert pc.called -def test_cut_file(tmpdir): +def test_cut_save(tmpdir): f = str(tmpdir.join("path")) v = view.View() c = cut.Cut() with taddons.context() as tctx: tctx.master.addons.add(v, c) - v.add([tflow.tflow(resp=True)]) - tctx.command(c.save, "q.method|@all", f) + tctx.command(c.save, "@all", "q.method", f) assert qr(f) == b"GET" - tctx.command(c.save, "q.content|@all", f) + tctx.command(c.save, "@all", "q.content", f) assert qr(f) == b"content" - tctx.command(c.save, "q.content|@all", "+" + f) + tctx.command(c.save, "@all", "q.content", "+" + f) assert qr(f) == b"content\ncontent" v.add([tflow.tflow(resp=True)]) - tctx.command(c.save, "q.method|@all", f) + tctx.command(c.save, "@all", "q.method", f) assert qr(f).splitlines() == [b"GET", b"GET"] - tctx.command(c.save, "q.method,q.content|@all", f) + tctx.command(c.save, "@all", "q.method,q.content", f) assert qr(f).splitlines() == [b"GET,content", b"GET,content"] def test_cut(): - v = view.View() c = cut.Cut() - with taddons.context() as tctx: - v.add([tflow.tflow(resp=True)]) - tctx.master.addons.add(v, c) - assert c.cut("q.method|@all") == [["GET"]] - assert c.cut("q.scheme|@all") == [["http"]] - assert c.cut("q.host|@all") == [["address"]] - assert c.cut("q.port|@all") == [["22"]] - assert c.cut("q.path|@all") == [["/path"]] - assert c.cut("q.url|@all") == [["http://address:22/path"]] - assert c.cut("q.content|@all") == [[b"content"]] - assert c.cut("q.header[header]|@all") == [["qvalue"]] - assert c.cut("q.header[unknown]|@all") == [[""]] + with taddons.context(): + tflows = [tflow.tflow(resp=True)] + assert c.cut(tflows, ["q.method"]) == [["GET"]] + assert c.cut(tflows, ["q.scheme"]) == [["http"]] + assert c.cut(tflows, ["q.host"]) == [["address"]] + assert c.cut(tflows, ["q.port"]) == [["22"]] + assert c.cut(tflows, ["q.path"]) == [["/path"]] + assert c.cut(tflows, ["q.url"]) == [["http://address:22/path"]] + assert c.cut(tflows, ["q.content"]) == [[b"content"]] + assert c.cut(tflows, ["q.header[header]"]) == [["qvalue"]] + assert c.cut(tflows, ["q.header[unknown]"]) == [[""]] - assert c.cut("s.status_code|@all") == [["200"]] - assert c.cut("s.reason|@all") == [["OK"]] - assert c.cut("s.content|@all") == [[b"message"]] - assert c.cut("s.header[header-response]|@all") == [["svalue"]] - assert c.cut("moo") == [[""]] + assert c.cut(tflows, ["s.status_code"]) == [["200"]] + assert c.cut(tflows, ["s.reason"]) == [["OK"]] + assert c.cut(tflows, ["s.content"]) == [[b"message"]] + assert c.cut(tflows, ["s.header[header-response]"]) == [["svalue"]] + assert c.cut(tflows, ["moo"]) == [[""]] with pytest.raises(exceptions.CommandError): - assert c.cut("__dict__") == [[""]] + assert c.cut(tflows, ["__dict__"]) == [[""]] - v = view.View() c = cut.Cut() - with taddons.context() as tctx: - tctx.master.addons.add(v, c) - v.add([tflow.ttcpflow()]) - assert c.cut("q.method|@all") == [[""]] - assert c.cut("s.status|@all") == [[""]] + with taddons.context(): + tflows = [tflow.ttcpflow()] + assert c.cut(tflows, ["q.method"]) == [[""]] + assert c.cut(tflows, ["s.status"]) == [[""]] diff --git a/test/mitmproxy/test_command.py b/test/mitmproxy/test_command.py index 298b34fb6..066cbf155 100644 --- a/test/mitmproxy/test_command.py +++ b/test/mitmproxy/test_command.py @@ -155,8 +155,8 @@ def test_typename(): assert command.typename(typing.Sequence[flow.Flow], True) == "[flow]" assert command.typename(typing.Sequence[flow.Flow], False) == "flowspec" - assert command.typename(command.Cuts, False) == "cutspec" assert command.typename(command.Cuts, True) == "[cuts]" + assert command.typename(typing.Sequence[command.Cut], False) == "[cut]" assert command.typename(flow.Flow, False) == "flow" assert command.typename(typing.Sequence[str], False) == "[str]" From cd913d598da421b4c4e264e029be5f13e3e009bb Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Sat, 16 Dec 2017 10:30:08 +1300 Subject: [PATCH 2/6] command cuts: add completion - Remove shortcuts for request, response, etc. - we don't need them if we have completion - Restrict cuts specification to a set of prefixes - Extend cuts to add a few more items --- mitmproxy/addons/cut.py | 42 +++---- mitmproxy/command.py | 41 ++++++- mitmproxy/test/tflow.py | 4 + mitmproxy/test/tutils.py | 8 +- .../tools/console/commander/commander.py | 13 +++ mitmproxy/tools/console/defaultkeys.py | 2 +- test/mitmproxy/addons/test_cut.py | 107 +++++++++--------- test/mitmproxy/addons/test_view.py | 4 +- test/mitmproxy/net/http/test_response.py | 4 +- web/src/js/__tests__/ducks/_tflow.js | 4 +- 10 files changed, 141 insertions(+), 88 deletions(-) diff --git a/mitmproxy/addons/cut.py b/mitmproxy/addons/cut.py index 6b9dc7235..efc9e5df4 100644 --- a/mitmproxy/addons/cut.py +++ b/mitmproxy/addons/cut.py @@ -17,14 +17,6 @@ def headername(spec: str): return spec[len("header["):-1].strip() -flow_shortcuts = { - "q": "request", - "s": "response", - "cc": "client_conn", - "sc": "server_conn", -} - - def is_addr(v): return isinstance(v, tuple) and len(v) > 1 @@ -35,8 +27,6 @@ def extract(cut: str, f: flow.Flow) -> typing.Union[str, bytes]: for i, spec in enumerate(path): if spec.startswith("_"): raise exceptions.CommandError("Can't access internal attribute %s" % spec) - if isinstance(current, flow.Flow): - spec = flow_shortcuts.get(spec, spec) part = getattr(current, spec, None) if i == len(path) - 1: @@ -65,13 +55,12 @@ class Cut: ) -> command.Cuts: """ Cut data from a set of flows. Cut specifications are attribute paths - from the base of the flow object, with a few conveniences - "q", - "s", "cc" and "sc" are shortcuts for request, response, client_conn - and server_conn, "port" and "host" retrieve parts of an address - tuple, ".header[key]" retrieves a header value. Return values - converted to strings or bytes: SSL certicates are converted to PEM - format, bools are "true" or "false", "bytes" are preserved, and all - other values are converted to strings. + from the base of the flow object, with a few conveniences - "port" + and "host" retrieve parts of an address tuple, ".header[key]" + retrieves a header value. Return values converted to strings or + bytes: SSL certicates are converted to PEM format, bools are "true" + or "false", "bytes" are preserved, and all other values are + converted to strings. """ ret = [] for f in flows: @@ -101,11 +90,11 @@ class Cut: if fp.tell() > 0: # We're appending to a file that already exists and has content fp.write(b"\n") - for v in [extract(cuts[0], f) for f in flows]: - if isinstance(v, bytes): - fp.write(v) - else: - fp.write(v.encode("utf8")) + v = extract(cuts[0], flows[0]) + if isinstance(v, bytes): + fp.write(v) + else: + fp.write(v.encode("utf8")) ctx.log.alert("Saved single cut.") else: with open(path, "a" if append else "w", newline='', encoding="utf8") as fp: @@ -129,8 +118,8 @@ class Cut: column, the data is written to file as-is, with raw bytes preserved. """ fp = io.StringIO(newline="") - if len(cuts) == 1 and len(cuts[0]) == 1: - v = cuts[0][0] + if len(cuts) == 1 and len(flows) == 1: + v = extract(cuts[0], flows[0]) if isinstance(v, bytes): fp.write(strutils.always_str(v)) else: @@ -138,9 +127,10 @@ class Cut: ctx.log.alert("Clipped single cut.") else: writer = csv.writer(fp) - for r in cuts: + for f in flows: + vals = [extract(c, f) for c in cuts] writer.writerow( - [strutils.always_str(c) or "" for c in r] # type: ignore + [strutils.always_str(v) or "" for v in vals] # type: ignore ) ctx.log.alert("Clipped %s cuts as CSV." % len(cuts)) pyperclip.copy(fp.getvalue()) diff --git a/mitmproxy/command.py b/mitmproxy/command.py index 82bad4fa0..7d7fa7353 100644 --- a/mitmproxy/command.py +++ b/mitmproxy/command.py @@ -30,7 +30,46 @@ Cuts = typing.Sequence[ class Cut(str): - pass + # This is an awkward location for these values, but it's better than having + # the console core import and depend on an addon. FIXME: Add a way for + # addons to add custom types and manage their completion and validation. + valid_prefixes = [ + "request.method", + "request.scheme", + "request.host", + "request.http_version", + "request.port", + "request.path", + "request.url", + "request.text", + "request.content", + "request.raw_content", + "request.timestamp_start", + "request.timestamp_end", + "request.header[", + + "response.status_code", + "response.reason", + "response.text", + "response.content", + "response.timestamp_start", + "response.timestamp_end", + "response.raw_content", + "response.header[", + + "client_conn.address.port", + "client_conn.address.host", + "client_conn.tls_version", + "client_conn.sni", + "client_conn.ssl_established", + + "server_conn.address.port", + "server_conn.address.host", + "server_conn.ip_address.host", + "server_conn.tls_version", + "server_conn.sni", + "server_conn.ssl_established", + ] class Path(str): diff --git a/mitmproxy/test/tflow.py b/mitmproxy/test/tflow.py index e754cb54e..c3dab30ce 100644 --- a/mitmproxy/test/tflow.py +++ b/mitmproxy/test/tflow.py @@ -53,6 +53,8 @@ def twebsocketflow(client_conn=True, server_conn=True, messages=True, err=None, sec_websocket_version="13", sec_websocket_key="1234", ), + timestamp_start=1, + timestamp_end=2, content=b'' ) resp = http.HTTPResponse( @@ -64,6 +66,8 @@ def twebsocketflow(client_conn=True, server_conn=True, messages=True, err=None, upgrade='websocket', sec_websocket_accept=b'', ), + timestamp_start=1, + timestamp_end=2, content=b'', ) handshake_flow = http.HTTPFlow(client_conn, server_conn) diff --git a/mitmproxy/test/tutils.py b/mitmproxy/test/tutils.py index 80e5b6fd7..bcce547ac 100644 --- a/mitmproxy/test/tutils.py +++ b/mitmproxy/test/tutils.py @@ -31,7 +31,9 @@ def treq(**kwargs): path=b"/path", http_version=b"HTTP/1.1", headers=http.Headers(((b"header", b"qvalue"), (b"content-length", b"7"))), - content=b"content" + content=b"content", + timestamp_start=1, + timestamp_end=2, ) default.update(kwargs) return http.Request(**default) @@ -48,8 +50,8 @@ def tresp(**kwargs): reason=b"OK", headers=http.Headers(((b"header-response", b"svalue"), (b"content-length", b"7"))), content=b"message", - timestamp_start=time.time(), - timestamp_end=time.time(), + timestamp_start=1, + timestamp_end=2, ) default.update(kwargs) return http.Response(**default) diff --git a/mitmproxy/tools/console/commander/commander.py b/mitmproxy/tools/console/commander/commander.py index 5fc7dd128..b94d6f691 100644 --- a/mitmproxy/tools/console/commander/commander.py +++ b/mitmproxy/tools/console/commander/commander.py @@ -113,6 +113,19 @@ class CommandBuffer(): ), parse = parts, ) + if last.type == typing.Sequence[mitmproxy.command.Cut]: + spec = parts[-1].value.split(",") + opts = [] + for pref in mitmproxy.command.Cut.valid_prefixes: + spec[-1] = pref + opts.append(",".join(spec)) + self.completion = CompletionState( + completer = ListCompleter( + parts[-1].value, + opts, + ), + parse = parts, + ) elif isinstance(last.type, mitmproxy.command.Choice): self.completion = CompletionState( completer = ListCompleter( diff --git a/mitmproxy/tools/console/defaultkeys.py b/mitmproxy/tools/console/defaultkeys.py index b845a3ae6..7e078bbf7 100644 --- a/mitmproxy/tools/console/defaultkeys.py +++ b/mitmproxy/tools/console/defaultkeys.py @@ -31,7 +31,7 @@ def map(km): km.add("A", "flow.resume @all", ["flowlist", "flowview"], "Resume all intercepted flows") km.add("a", "flow.resume @focus", ["flowlist", "flowview"], "Resume this intercepted flow") km.add( - "b", "console.command cut.save @focus s.content ", + "b", "console.command cut.save @focus response.content ", ["flowlist", "flowview"], "Save response body to file" ) diff --git a/test/mitmproxy/addons/test_cut.py b/test/mitmproxy/addons/test_cut.py index bb3e1c2d2..0a523fff5 100644 --- a/test/mitmproxy/addons/test_cut.py +++ b/test/mitmproxy/addons/test_cut.py @@ -13,36 +13,41 @@ from unittest import mock def test_extract(): tf = tflow.tflow(resp=True) tests = [ - ["q.method", "GET"], - ["q.scheme", "http"], - ["q.host", "address"], - ["q.port", "22"], - ["q.path", "/path"], - ["q.url", "http://address:22/path"], - ["q.text", "content"], - ["q.content", b"content"], - ["q.raw_content", b"content"], - ["q.header[header]", "qvalue"], + ["request.method", "GET"], + ["request.scheme", "http"], + ["request.host", "address"], + ["request.http_version", "HTTP/1.1"], + ["request.port", "22"], + ["request.path", "/path"], + ["request.url", "http://address:22/path"], + ["request.text", "content"], + ["request.content", b"content"], + ["request.raw_content", b"content"], + ["request.timestamp_start", "1"], + ["request.timestamp_end", "2"], + ["request.header[header]", "qvalue"], - ["s.status_code", "200"], - ["s.reason", "OK"], - ["s.text", "message"], - ["s.content", b"message"], - ["s.raw_content", b"message"], - ["s.header[header-response]", "svalue"], + ["response.status_code", "200"], + ["response.reason", "OK"], + ["response.text", "message"], + ["response.content", b"message"], + ["response.raw_content", b"message"], + ["response.header[header-response]", "svalue"], + ["response.timestamp_start", "1"], + ["response.timestamp_end", "2"], - ["cc.address.port", "22"], - ["cc.address.host", "127.0.0.1"], - ["cc.tls_version", "TLSv1.2"], - ["cc.sni", "address"], - ["cc.ssl_established", "false"], + ["client_conn.address.port", "22"], + ["client_conn.address.host", "127.0.0.1"], + ["client_conn.tls_version", "TLSv1.2"], + ["client_conn.sni", "address"], + ["client_conn.ssl_established", "false"], - ["sc.address.port", "22"], - ["sc.address.host", "address"], - ["sc.ip_address.host", "192.168.0.1"], - ["sc.tls_version", "TLSv1.2"], - ["sc.sni", "address"], - ["sc.ssl_established", "false"], + ["server_conn.address.port", "22"], + ["server_conn.address.host", "address"], + ["server_conn.ip_address.host", "192.168.0.1"], + ["server_conn.tls_version", "TLSv1.2"], + ["server_conn.sni", "address"], + ["server_conn.ssl_established", "false"], ] for t in tests: ret = cut.extract(t[0], tf) @@ -53,7 +58,7 @@ def test_extract(): d = f.read() c1 = certs.SSLCert.from_pem(d) tf.server_conn.cert = c1 - assert "CERTIFICATE" in cut.extract("sc.cert", tf) + assert "CERTIFICATE" in cut.extract("server_conn.cert", tf) def test_headername(): @@ -74,15 +79,15 @@ def test_cut_clip(): v.add([tflow.tflow(resp=True)]) with mock.patch('pyperclip.copy') as pc: - tctx.command(c.clip, "@all", "q.method") + tctx.command(c.clip, "@all", "request.method") assert pc.called with mock.patch('pyperclip.copy') as pc: - tctx.command(c.clip, "@all", "q.content") + tctx.command(c.clip, "@all", "request.content") assert pc.called with mock.patch('pyperclip.copy') as pc: - tctx.command(c.clip, "@all", "q.method,q.content") + tctx.command(c.clip, "@all", "request.method,request.content") assert pc.called @@ -94,17 +99,17 @@ def test_cut_save(tmpdir): tctx.master.addons.add(v, c) v.add([tflow.tflow(resp=True)]) - tctx.command(c.save, "@all", "q.method", f) + tctx.command(c.save, "@all", "request.method", f) assert qr(f) == b"GET" - tctx.command(c.save, "@all", "q.content", f) + tctx.command(c.save, "@all", "request.content", f) assert qr(f) == b"content" - tctx.command(c.save, "@all", "q.content", "+" + f) + tctx.command(c.save, "@all", "request.content", "+" + f) assert qr(f) == b"content\ncontent" v.add([tflow.tflow(resp=True)]) - tctx.command(c.save, "@all", "q.method", f) + tctx.command(c.save, "@all", "request.method", f) assert qr(f).splitlines() == [b"GET", b"GET"] - tctx.command(c.save, "@all", "q.method,q.content", f) + tctx.command(c.save, "@all", "request.method,request.content", f) assert qr(f).splitlines() == [b"GET,content", b"GET,content"] @@ -112,20 +117,20 @@ def test_cut(): c = cut.Cut() with taddons.context(): tflows = [tflow.tflow(resp=True)] - assert c.cut(tflows, ["q.method"]) == [["GET"]] - assert c.cut(tflows, ["q.scheme"]) == [["http"]] - assert c.cut(tflows, ["q.host"]) == [["address"]] - assert c.cut(tflows, ["q.port"]) == [["22"]] - assert c.cut(tflows, ["q.path"]) == [["/path"]] - assert c.cut(tflows, ["q.url"]) == [["http://address:22/path"]] - assert c.cut(tflows, ["q.content"]) == [[b"content"]] - assert c.cut(tflows, ["q.header[header]"]) == [["qvalue"]] - assert c.cut(tflows, ["q.header[unknown]"]) == [[""]] + assert c.cut(tflows, ["request.method"]) == [["GET"]] + assert c.cut(tflows, ["request.scheme"]) == [["http"]] + assert c.cut(tflows, ["request.host"]) == [["address"]] + assert c.cut(tflows, ["request.port"]) == [["22"]] + assert c.cut(tflows, ["request.path"]) == [["/path"]] + assert c.cut(tflows, ["request.url"]) == [["http://address:22/path"]] + assert c.cut(tflows, ["request.content"]) == [[b"content"]] + assert c.cut(tflows, ["request.header[header]"]) == [["qvalue"]] + assert c.cut(tflows, ["request.header[unknown]"]) == [[""]] - assert c.cut(tflows, ["s.status_code"]) == [["200"]] - assert c.cut(tflows, ["s.reason"]) == [["OK"]] - assert c.cut(tflows, ["s.content"]) == [[b"message"]] - assert c.cut(tflows, ["s.header[header-response]"]) == [["svalue"]] + assert c.cut(tflows, ["response.status_code"]) == [["200"]] + assert c.cut(tflows, ["response.reason"]) == [["OK"]] + assert c.cut(tflows, ["response.content"]) == [[b"message"]] + assert c.cut(tflows, ["response.header[header-response]"]) == [["svalue"]] assert c.cut(tflows, ["moo"]) == [[""]] with pytest.raises(exceptions.CommandError): assert c.cut(tflows, ["__dict__"]) == [[""]] @@ -133,5 +138,5 @@ def test_cut(): c = cut.Cut() with taddons.context(): tflows = [tflow.ttcpflow()] - assert c.cut(tflows, ["q.method"]) == [[""]] - assert c.cut(tflows, ["s.status"]) == [[""]] + assert c.cut(tflows, ["request.method"]) == [[""]] + assert c.cut(tflows, ["response.status"]) == [[""]] diff --git a/test/mitmproxy/addons/test_view.py b/test/mitmproxy/addons/test_view.py index 1e0c3b550..1c76eb219 100644 --- a/test/mitmproxy/addons/test_view.py +++ b/test/mitmproxy/addons/test_view.py @@ -30,7 +30,7 @@ def test_order_refresh(): with taddons.context() as tctx: tctx.configure(v, view_order="time") v.add([tf]) - tf.request.timestamp_start = 1 + tf.request.timestamp_start = 10 assert not sargs v.update([tf]) assert sargs @@ -41,7 +41,7 @@ def test_order_generators(): tf = tflow.tflow(resp=True) rs = view.OrderRequestStart(v) - assert rs.generate(tf) == 0 + assert rs.generate(tf) == 1 rm = view.OrderRequestMethod(v) assert rm.generate(tf) == tf.request.method diff --git a/test/mitmproxy/net/http/test_response.py b/test/mitmproxy/net/http/test_response.py index fa1770feb..a77435c93 100644 --- a/test/mitmproxy/net/http/test_response.py +++ b/test/mitmproxy/net/http/test_response.py @@ -150,10 +150,10 @@ class TestResponseUtils: n = time.time() r.headers["date"] = email.utils.formatdate(n) pre = r.headers["date"] - r.refresh(n) + r.refresh(1) assert pre == r.headers["date"] - r.refresh(n + 60) + r.refresh(61) d = email.utils.parsedate_tz(r.headers["date"]) d = email.utils.mktime_tz(d) # Weird that this is not exact... diff --git a/web/src/js/__tests__/ducks/_tflow.js b/web/src/js/__tests__/ducks/_tflow.js index 44b323427..8599200de 100644 --- a/web/src/js/__tests__/ducks/_tflow.js +++ b/web/src/js/__tests__/ducks/_tflow.js @@ -45,8 +45,8 @@ export default function(){ "port": 22, "pretty_host": "address", "scheme": "http", - "timestamp_end": null, - "timestamp_start": null + "timestamp_end": 2, + "timestamp_start": 1 }, "response": { "contentHash": "ab530a13e45914982b79f9b7e3fba994cfd1f3fb22f71cea1afbf02b460c6d1d", From 2649e576910b4a90b6a50db3612f1a2b4c6d9f1a Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Sat, 16 Dec 2017 10:56:31 +1300 Subject: [PATCH 3/6] minor: fix key bindings, adjust flow command presentation Fixes #2681 --- mitmproxy/command.py | 2 +- mitmproxy/tools/console/defaultkeys.py | 2 +- test/mitmproxy/test_command.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mitmproxy/command.py b/mitmproxy/command.py index 7d7fa7353..b3c8eb223 100644 --- a/mitmproxy/command.py +++ b/mitmproxy/command.py @@ -92,7 +92,7 @@ def typename(t: type, ret: bool) -> str: if isinstance(t, Choice): return "choice" elif t == typing.Sequence[flow.Flow]: - return "[flow]" if ret else "flowspec" + return "[flow]" elif t == typing.Sequence[str]: return "[str]" elif t == typing.Sequence[Cut]: diff --git a/mitmproxy/tools/console/defaultkeys.py b/mitmproxy/tools/console/defaultkeys.py index 7e078bbf7..7920225b6 100644 --- a/mitmproxy/tools/console/defaultkeys.py +++ b/mitmproxy/tools/console/defaultkeys.py @@ -157,7 +157,7 @@ def map(km): ) km.add("e", "console.grideditor.editor", ["grideditor"], "Edit in external editor") - km.add("z", "console.eventlog.clear", ["eventlog"], "Clear") + km.add("z", "eventstore.clear", ["eventlog"], "Clear") km.add( "a", diff --git a/test/mitmproxy/test_command.py b/test/mitmproxy/test_command.py index 066cbf155..47680c996 100644 --- a/test/mitmproxy/test_command.py +++ b/test/mitmproxy/test_command.py @@ -153,7 +153,7 @@ def test_simple(): def test_typename(): assert command.typename(str, True) == "str" assert command.typename(typing.Sequence[flow.Flow], True) == "[flow]" - assert command.typename(typing.Sequence[flow.Flow], False) == "flowspec" + assert command.typename(typing.Sequence[flow.Flow], False) == "[flow]" assert command.typename(command.Cuts, True) == "[cuts]" assert command.typename(typing.Sequence[command.Cut], False) == "[cut]" From eab27db7d6421fe273040dd6a2596d031e4639e1 Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Sat, 16 Dec 2017 11:11:25 +1300 Subject: [PATCH 4/6] minor: commandeditor.py -> commandexecutor.py --- .../tools/console/{commandeditor.py => commandexecutor.py} | 0 mitmproxy/tools/console/keymap.py | 4 ++-- mitmproxy/tools/console/statusbar.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename mitmproxy/tools/console/{commandeditor.py => commandexecutor.py} (100%) diff --git a/mitmproxy/tools/console/commandeditor.py b/mitmproxy/tools/console/commandexecutor.py similarity index 100% rename from mitmproxy/tools/console/commandeditor.py rename to mitmproxy/tools/console/commandexecutor.py diff --git a/mitmproxy/tools/console/keymap.py b/mitmproxy/tools/console/keymap.py index e406905d7..b268906c4 100644 --- a/mitmproxy/tools/console/keymap.py +++ b/mitmproxy/tools/console/keymap.py @@ -1,5 +1,5 @@ import typing -from mitmproxy.tools.console import commandeditor +from mitmproxy.tools.console import commandexecutor from mitmproxy.tools.console import signals @@ -35,7 +35,7 @@ class Binding: class Keymap: def __init__(self, master): - self.executor = commandeditor.CommandExecutor(master) + self.executor = commandexecutor.CommandExecutor(master) self.keys = {} for c in Contexts: self.keys[c] = {} diff --git a/mitmproxy/tools/console/statusbar.py b/mitmproxy/tools/console/statusbar.py index 6a1f07a93..572b70fc1 100644 --- a/mitmproxy/tools/console/statusbar.py +++ b/mitmproxy/tools/console/statusbar.py @@ -4,7 +4,7 @@ import urwid from mitmproxy.tools.console import common from mitmproxy.tools.console import signals -from mitmproxy.tools.console import commandeditor +from mitmproxy.tools.console import commandexecutor import mitmproxy.tools.console.master # noqa from mitmproxy.tools.console.commander import commander @@ -68,7 +68,7 @@ class ActionBar(urwid.WidgetWrap): def sig_prompt_command(self, sender, partial=""): signals.focus.send(self, section="footer") self._w = commander.CommandEdit(self.master, partial) - self.prompting = commandeditor.CommandExecutor(self.master) + self.prompting = commandexecutor.CommandExecutor(self.master) def sig_prompt_onekey(self, sender, prompt, keys, callback, args=()): """ From f14ec2d8f043f051db45dd36a1d501788c7818e6 Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Sat, 16 Dec 2017 11:53:45 +1300 Subject: [PATCH 5/6] console: add a data view overlay for command output Fixes #2654 --- mitmproxy/command.py | 2 +- mitmproxy/test/tutils.py | 1 - mitmproxy/tools/console/commandexecutor.py | 14 ++-- mitmproxy/tools/console/grideditor/col.py | 67 +++++++++++++++++++ .../tools/console/grideditor/col_bytes.py | 2 +- mitmproxy/tools/console/grideditor/editors.py | 18 +++++ mitmproxy/tools/console/overlay.py | 27 ++++++++ 7 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 mitmproxy/tools/console/grideditor/col.py diff --git a/mitmproxy/command.py b/mitmproxy/command.py index b3c8eb223..2d51317c2 100644 --- a/mitmproxy/command.py +++ b/mitmproxy/command.py @@ -170,7 +170,7 @@ class Command: if chk: pargs.extend(remainder) else: - raise exceptions.CommandError("Invalid value type.") + raise exceptions.CommandError("Invalid value type: %s - expected %s" % (remainder, self.paramtypes[-1])) with self.manager.master.handlecontext(): ret = self.func(*pargs) diff --git a/mitmproxy/test/tutils.py b/mitmproxy/test/tutils.py index bcce547ac..cd9f3b3f6 100644 --- a/mitmproxy/test/tutils.py +++ b/mitmproxy/test/tutils.py @@ -1,4 +1,3 @@ -import time from io import BytesIO from mitmproxy.utils import data diff --git a/mitmproxy/tools/console/commandexecutor.py b/mitmproxy/tools/console/commandexecutor.py index e57ddbb4d..829daee13 100644 --- a/mitmproxy/tools/console/commandexecutor.py +++ b/mitmproxy/tools/console/commandexecutor.py @@ -2,6 +2,8 @@ import typing from mitmproxy import exceptions from mitmproxy import flow + +from mitmproxy.tools.console import overlay from mitmproxy.tools.console import signals @@ -21,9 +23,11 @@ class CommandExecutor: signals.status_message.send( message="Command returned %s flows" % len(ret) ) - elif len(str(ret)) < 50: - signals.status_message.send(message=str(ret)) else: - signals.status_message.send( - message="Command returned too much data to display." - ) + self.master.overlay( + overlay.DataViewerOverlay( + self.master, + ret, + ), + valign="top" + ) \ No newline at end of file diff --git a/mitmproxy/tools/console/grideditor/col.py b/mitmproxy/tools/console/grideditor/col.py new file mode 100644 index 000000000..3331f3e72 --- /dev/null +++ b/mitmproxy/tools/console/grideditor/col.py @@ -0,0 +1,67 @@ +import typing + +import urwid + +from mitmproxy.tools.console import signals +from mitmproxy.tools.console.grideditor import base +from mitmproxy.utils import strutils + +strbytes = typing.Union[str, bytes] + + +class Column(base.Column): + def Display(self, data): + return Display(data) + + def Edit(self, data): + return Edit(data) + + def blank(self): + return "" + + def keypress(self, key, editor): + if key in ["m_select"]: + editor.walker.start_edit() + else: + return key + + +class Display(base.Cell): + def __init__(self, data: strbytes) -> None: + self.data = data + if isinstance(data, bytes): + escaped = strutils.bytes_to_escaped_str(data) + else: + escaped = data.encode() + w = urwid.Text(escaped, wrap="any") + super().__init__(w) + + def get_data(self) -> strbytes: + return self.data + + +class Edit(base.Cell): + def __init__(self, data: strbytes) -> None: + if isinstance(data, bytes): + escaped = strutils.bytes_to_escaped_str(data) + else: + escaped = data.encode() + self.type = type(data) # type: typing.Type + w = urwid.Edit(edit_text=escaped, wrap="any", multiline=True) + w = urwid.AttrWrap(w, "editfield") + super().__init__(w) + + def get_data(self) -> strbytes: + txt = self._w.get_text()[0].strip() + try: + if self.type == bytes: + return strutils.escaped_str_to_bytes(txt) + else: + return txt.decode() + except ValueError: + signals.status_message.send( + self, + message="Invalid Python-style string encoding.", + expire=1000 + ) + raise diff --git a/mitmproxy/tools/console/grideditor/col_bytes.py b/mitmproxy/tools/console/grideditor/col_bytes.py index da10cbafb..990253ea4 100644 --- a/mitmproxy/tools/console/grideditor/col_bytes.py +++ b/mitmproxy/tools/console/grideditor/col_bytes.py @@ -46,7 +46,7 @@ class Edit(base.Cell): except ValueError: signals.status_message.send( self, - message="Invalid Python-style string encoding.", + message="Invalid data.", expire=1000 ) raise diff --git a/mitmproxy/tools/console/grideditor/editors.py b/mitmproxy/tools/console/grideditor/editors.py index 074cdb77b..b5d16737a 100644 --- a/mitmproxy/tools/console/grideditor/editors.py +++ b/mitmproxy/tools/console/grideditor/editors.py @@ -2,6 +2,7 @@ from mitmproxy import exceptions from mitmproxy.tools.console import layoutwidget from mitmproxy.tools.console.grideditor import base +from mitmproxy.tools.console.grideditor import col from mitmproxy.tools.console.grideditor import col_text from mitmproxy.tools.console.grideditor import col_bytes from mitmproxy.tools.console.grideditor import col_subgrid @@ -169,3 +170,20 @@ class OptionsEditor(base.GridEditor, layoutwidget.LayoutWidget): def is_error(self, col, val): pass + + +class DataViewer(base.GridEditor, layoutwidget.LayoutWidget): + title = None # type: str + + def __init__(self, master, vals): + if vals: + if not isinstance(vals[0], list): + vals = [[i] for i in vals] + self.columns = [col.Column("")] * len(vals[0]) + super().__init__(master, vals, self.callback) + + def callback(self, vals): + pass + + def is_error(self, col, val): + pass diff --git a/mitmproxy/tools/console/overlay.py b/mitmproxy/tools/console/overlay.py index 7072d00ec..f97f23f92 100644 --- a/mitmproxy/tools/console/overlay.py +++ b/mitmproxy/tools/console/overlay.py @@ -148,3 +148,30 @@ class OptionsOverlay(urwid.WidgetWrap, layoutwidget.LayoutWidget): def layout_popping(self): return self.ge.layout_popping() + + +class DataViewerOverlay(urwid.WidgetWrap, layoutwidget.LayoutWidget): + keyctx = "grideditor" + + def __init__(self, master, vals): + """ + vspace: how much vertical space to keep clear + """ + cols, rows = master.ui.get_cols_rows() + self.ge = grideditor.DataViewer(master, vals) + super().__init__( + urwid.AttrWrap( + urwid.LineBox( + urwid.BoxAdapter(self.ge, rows - 5), + title="Data viewer" + ), + "background" + ) + ) + self.width = math.ceil(cols * 0.8) + + def key_responder(self): + return self.ge.key_responder() + + def layout_popping(self): + return self.ge.layout_popping() From ac335057a7829e2972ab6af56fa1d977623d5426 Mon Sep 17 00:00:00 2001 From: Aldo Cortesi Date: Sun, 17 Dec 2017 10:17:05 +1300 Subject: [PATCH 6/6] Disable implicit JS test asset generation The JS test assets depend in a brittle way on the details of the tflow() utility functions. We shouldn't have to fix JS tests when adjusting these. Options: - Manually generate the test assets in a script. - Define the JS assets without using tflow, so they don't unexpextedly vary. --- test/mitmproxy/tools/web/test_app.py | 2 +- web/src/js/__tests__/ducks/_tflow.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/mitmproxy/tools/web/test_app.py b/test/mitmproxy/tools/web/test_app.py index 248581b95..5afc0bcae 100644 --- a/test/mitmproxy/tools/web/test_app.py +++ b/test/mitmproxy/tools/web/test_app.py @@ -322,7 +322,7 @@ class TestApp(tornado.testing.AsyncHTTPTestCase): ws_client2 = yield websocket.websocket_connect(ws_url) ws_client2.close() - def test_generate_tflow_js(self): + def _test_generate_tflow_js(self): _tflow = app.flow_to_json(tflow.tflow(resp=True, err=True)) # Set some value as constant, so that _tflow.js would not change every time. _tflow['client_conn']['id'] = "4a18d1a0-50a1-48dd-9aa6-d45d74282939" diff --git a/web/src/js/__tests__/ducks/_tflow.js b/web/src/js/__tests__/ducks/_tflow.js index 8599200de..44b323427 100644 --- a/web/src/js/__tests__/ducks/_tflow.js +++ b/web/src/js/__tests__/ducks/_tflow.js @@ -45,8 +45,8 @@ export default function(){ "port": 22, "pretty_host": "address", "scheme": "http", - "timestamp_end": 2, - "timestamp_start": 1 + "timestamp_end": null, + "timestamp_start": null }, "response": { "contentHash": "ab530a13e45914982b79f9b7e3fba994cfd1f3fb22f71cea1afbf02b460c6d1d",