Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol

## [Unreleased]

### Added

- **Connections: the search box can search one column at a time.** Plain text works as before,
and now a term can name its column: `port:443`, `ip:10.0.0.0/8`, `pid:>4000`, `scoped:yes`,
`dropped:>0`. Values use the same notation as the Control page fields, and several terms narrow
together. A new "?" button lists the column names with examples. The search used to look at 6 of
the table's 17 columns, so a PID was on screen and could not be searched for.
- **Connections: two new right-click actions on a row.** "Block this IP address" adds it to the
blocking field, and "Leave this process alone" excludes that process from impairment. Both ADD
to what is already in the field, so you can build a list one row at a time, and a repeat is
ignored rather than duplicated. Like every other row action they fill the form only - press
"Apply changes" to put them into a running session.

### Docs

- Both READMEs now say in the licence section that **what you make with the tool is yours**.
Expand Down
81 changes: 81 additions & 0 deletions beantester/gui/pages/conns.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
from tkinter import ttk

from ...i18n import T
from ...matchers import add_term
from ...views import (avg_packet_bytes, connection_proc, filter_sort_connections,
traffic_totals)
from .. import dialogs
from ..model_worker import AsyncModel
from ..labels import wrapping_label
from ..scaling import scaled
Expand Down Expand Up @@ -97,6 +99,57 @@ def port_cell(port):
REBUILD_MS = 1000



def append_to_field(app, key, term, log_key):
"""Add one term to an expression field, keeping what is already there.

The row actions build a field up click by click - block this address, then
that one - so they append. Replacing would throw away what the previous click
put there, which is the opposite of what the second click means.
``matchers.add_term`` owns the syntax (convention 10): it drops repeats, keeps
the comma escape of a regex intact and never leaves an empty term behind.

Like every other row action this only fills the form (convention 15): the
running session hears about it through the same "apply needed" line, and
nothing reaches the engine until the user presses Apply.

It lives on the PAGE rather than on ``App`` for a measured reason: ``app.py``
was already the largest module in the package and sits on the size ratchet in
``tests/test_code_shape.py``, which went red when these three helpers were
added there. The ratchet's answer is to put code where it belongs rather than
to raise the number, and a Connections row action belongs to the Connections
page.
"""
updated = add_term(app.vars[key].get(), term)
app.vars[key].set(updated)
app.form.set_values(app._settings_for_form())
app.on_form_changed()
app.log(f"{T(log_key)}: {updated}")
if app.running:
app.log(T("log.apply_needed"))


def block_ip_address(app, ip):
"""Add an address to the blocking field (decision pipeline step 2c)."""
if str(ip or "").strip():
append_to_field(app, "block_ip", str(ip).strip(), "log.block_ip_added")


def leave_process_alone(app, name):
"""Exclude a process from impairment by adding ``!name`` to the target.

With a target already set this narrows it. With the target EMPTY it turns
"impair everything" into "impair everything except this one", because a bare
negative means exactly that in this expression language - and that is the case
the menu entry is really for.
"""
name = str(name or "").strip()
if not name or name == "?":
app.log(T("log.no_process_for_row"))
return
append_to_field(app, "target", f"!{name}", "log.process_excluded")


class ConnsPage:
ID = "connections"
LABEL = "app.tabs.connections"
Expand All @@ -121,6 +174,14 @@ def __init__(self, app, parent):
entry.bind("<KeyRelease>", lambda e: self._schedule_search())
entry.bind("<Escape>", lambda e: self._clear_search())
add_tooltip(entry, "tips.conn_search")
# The same "?" affordance the expression fields use (gui/form.py): the search
# box understands `port:443` and `ip:10.0.0.0/8`, and a cheat sheet you can
# read is the only way anyone finds that out. A tooltip cannot be it - it
# runs away from the pointer as soon as you click.
help_btn = ttk.Button(top, text=T("fields.match_help"), style="Help.TButton",
width=2, command=self._show_search_help)
help_btn.pack(side="left", padx=(0, scaled(8)))
add_tooltip(help_btn, "tips.conn_search_help")

self.pause_var = tk.BooleanVar(value=False)
pause = ttk.Checkbutton(top, text=T("buttons.freeze"), variable=self.pause_var,
Expand Down Expand Up @@ -171,7 +232,10 @@ def _build_menu(self):
self.menu.add_command(label=T("menu.copy_ip"), command=self._copy_ip)
self.menu.add_separator()
self.menu.add_command(label=T("menu.target_process"), command=self._target_process)
self.menu.add_command(label=T("menu.leave_process_alone"),
command=self._leave_process_alone)
self.menu.add_command(label=T("menu.limit_dest"), command=self._limit_dest)
self.menu.add_command(label=T("menu.block_ip"), command=self._block_ip)
self.menu.add_separator()
self.menu.add_command(label=T("menu.reset_widths"),
command=self.table.reset_widths)
Expand Down Expand Up @@ -239,13 +303,30 @@ def _target_process(self):
return
self.app.set_target_expression(name)

def _show_search_help(self):
"""The search cheat sheet, opened by the "?" next to the box."""
dialogs.show_help(self.app.root, T("dialogs.conn_search_help_title"),
T("dialogs.conn_search_help"))

def _leave_process_alone(self):
row = self._selected()
if not row:
return
leave_process_alone(self.app, str(row.get("proc") or "").strip())

def _limit_dest(self):
row = self._selected()
if not row:
return
self.app.set_destination(str(row.get("remote_ip") or ""),
str(row.get("remote_port") or ""))

def _block_ip(self):
row = self._selected()
if not row:
return
block_ip_address(self.app, str(row.get("remote_ip") or ""))

# -- search -------------------------------------------------------------- #
def _schedule_search(self):
if self._search_job is not None:
Expand Down
33 changes: 33 additions & 0 deletions beantester/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,39 @@ def split_terms(text):
return [p.strip() for p in parts if p.strip()]


def add_term(text, term):
"""Return ``text`` with ``term`` appended, or unchanged if it is already there.

The Connections context menu builds expressions one row at a time: block this
address, then that one, then leave this process alone. Appending is the only
behaviour that makes sense there - replacing would silently drop the two
addresses the user blocked a moment ago.

It lives HERE rather than in the GUI because splitting on commas is a question
about the FILTER SYNTAX, and this module owns that (convention 10). Naive
string concatenation gets three things wrong that ``split_terms`` already
knows: an escaped ``\\,`` inside a regex is not a separator, terms carry
surrounding whitespace that must not become part of the value, and a trailing
comma from an earlier edit would produce an empty term.

Duplicates are dropped rather than repeated. ``80,80`` means the same as
``80``, so the only thing a repeat changes is that the field looks broken.

🔴 **The comma escape has to be put back on the way out**, exactly as in
``Matcher.describe``. ``split_terms`` turns ``\\,`` into a literal comma inside
the term, so re-joining without escaping emits it as a SEPARATOR and silently
splits one regex into two nonsense terms. This function reintroduced that bug
when it was first written, on the same day its twin was cited as a solved one -
which is why the round trip is now a test, not a promise.
"""
term = str(term or "").strip()
if not term:
return str(text or "")
existing = split_terms(text)
terms = existing if term in existing else existing + [term]
return ",".join(t.replace(",", "\\,") for t in terms)


# -- atom parsers --------------------------------------------------------------- #
def _check_bounds(number, bounds, field, term):
if bounds and not (bounds[0] <= number <= bounds[1]):
Expand Down
111 changes: 108 additions & 3 deletions beantester/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,53 @@
"""
import heapq

from .matchers import KIND_INT, KIND_IP, KIND_PROCESS, PORT_BOUNDS, parse_matcher

PARTIAL_SORT_RATIO = 10 # use a heap only when the limit is this much smaller

# -- field-qualified search ----------------------------------------------------- #
#
# The plain search matches one substring against a blob of process, protocol,
# direction, addresses and ports. That is 6 of the table's 17 columns: a PID is on
# screen and cannot be searched for, and neither can "only the impaired rows" or
# "only rows that dropped something" - which are the questions a tester actually
# has when the table holds a hundred thousand flows.
#
# So a term may name its column: `port:443`, `ip:10.0.0.0/8`, `pid:>4000`. The
# VALUE is parsed by `matchers.py`, i.e. the same mini-language as the form fields
# (comma lists, ranges, `!`, `>` `<`, wildcards, `re:`, CIDR). Nothing new to learn
# and, more to the point, nothing new to maintain: a second syntax for the same job
# would drift from the first one on its first edit (convention 10).
#
# Bare text keeps working exactly as before, so no existing habit breaks.
#
# `kind` is what the value is parsed as; `get` pulls the comparable value off a row.
# PROCESS is the text kind here: it is the one that understands wildcards, `re:` and
# `!` on names, which is what "search a text column" means.
SEARCH_FIELDS = {
"proc": (KIND_PROCESS, None), # special: matched with (pid, name)
"pid": (KIND_INT, lambda c, m: c.get("pid")),
"proto": (KIND_PROCESS, lambda c, m: c.get("proto")),
"dir": (KIND_PROCESS, lambda c, m: c.get("dir")),
"ip": (KIND_IP, lambda c, m: c.get("remote_ip")),
"port": (KIND_INT, lambda c, m: c.get("remote_port")),
"lport": (KIND_INT, lambda c, m: c.get("local_port")),
"packets": (KIND_INT, lambda c, m: c.get("packets")),
"dropped": (KIND_INT, lambda c, m: c.get("dropped")),
"down": (KIND_INT, lambda c, m: c.get("sent_in")),
"up": (KIND_INT, lambda c, m: c.get("sent_out")),
"bytes": (KIND_INT, lambda c, m: c.get("sent")),
}

# The one genuine boolean on a row. It gets words rather than an expression because
# `scoped:yes` reads like a question and `scoped:1` reads like a bug report about the
# search box. "Has drops" needs no boolean of its own - that is `dropped:>0`.
BOOL_FIELDS = {"scoped"}
TRUE_WORDS = {"yes", "y", "true", "1", "tak"}
FALSE_WORDS = {"no", "n", "false", "0", "nie"}

_BOUNDS = {"port": PORT_BOUNDS, "lport": PORT_BOUNDS}


class SearchIndex:
"""Cached lowercase search text, one entry per row.
Expand Down Expand Up @@ -137,11 +182,71 @@ def _connection_blob(c, proc_map=None):
f"{c.get('local_port') or ''}").lower()


def compile_query(query):
"""Turn a search string into a list of predicates, ONCE per query.

Compiling per row would put the expression parser on the path of every one of
a hundred thousand rows on every keystroke, which is the shape of the problem
``SearchIndex`` exists to solve, reintroduced one layer up. So parse here and
return closures; the row loop then only calls them.

A term is either ``field:value`` or bare text. Terms are ANDed, because that is
what narrowing means and what every search box a tester has used does.

An unparsable value is NOT an error: the box is typed into character by
character, so `port:44` is a valid search on the way to `port:443` and half of
`ip:10.0.` must not throw or blank the table. Such a term simply matches
nothing until it becomes valid, and a term naming an unknown field falls back
to plain text - `http://x` is a URL someone pasted, not a field called `http`.
"""
tests = []
for raw in str(query or "").split():
field, sep, value = raw.partition(":")
field = field.lower()
if not sep or (field not in SEARCH_FIELDS and field not in BOOL_FIELDS):
text = raw.lower()
tests.append(lambda c, m, t=text: t in _connection_blob(c, m))
continue
if field in BOOL_FIELDS:
want = value.strip().lower()
if want in TRUE_WORDS:
tests.append(lambda c, m, k=field: bool(c.get(k)))
elif want in FALSE_WORDS:
tests.append(lambda c, m, k=field: not c.get(k))
else: # half-typed: match nothing yet
tests.append(lambda c, m: False)
continue
kind, getter = SEARCH_FIELDS[field]
try:
matcher = parse_matcher(value, kind, f"fields.{field}",
bounds=_BOUNDS.get(field))
except ValueError:
tests.append(lambda c, m: False)
continue
if not matcher: # `port:` with nothing after it
continue
if field == "proc":
# The process kind judges (pid, name) together, exactly as the target
# field does - so `proc:1234` and `proc:chrome` both work here for the
# same reason they both work there.
tests.append(lambda c, m, x=matcher: x.matches(c.get("pid"),
connection_proc(c, m)))
elif kind == KIND_PROCESS:
# A text column is a process matcher with no pid: the value goes in the
# NAME position. Passing it positionally instead gives it to `pid`,
# where a name cannot be evaluated - and an unevaluable term quietly
# matches nothing, so `proto:tcp` found zero rows while looking correct.
tests.append(lambda c, m, x=matcher, g=getter: x.matches(None, g(c, m)))
else:
tests.append(lambda c, m, x=matcher, g=getter: x.matches(g(c, m)))
return tests


def _filter_connections(conns, query, proc_map):
q = (query or "").strip().lower()
if not q:
tests = compile_query(query)
if not tests:
return list(conns)
return [c for c in conns if q in _connection_blob(c, proc_map)]
return [c for c in conns if all(t(c, proc_map) for t in tests)]


def traffic_totals(conns, query="", proc_map=None):
Expand Down
Loading