From 4d6593918567e1d70fbd68908891d567ef11600c Mon Sep 17 00:00:00 2001 From: notmuchtohide <106391832+notmuchtohide@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:43:32 +0000 Subject: [PATCH 1/4] qrexec-policy-graph: add qube colors Fixes QubesOS/qubes-issues#3007 Code decisions: 1. Node styling is not organized: Node styling is added as nodes are found instead of being grouped together. It should have no impact on the final graph and was considered as a potentially acceptable trade-off for implementation simplicity. 2. qubesadmin vs. system_info: The already used system_info did not provide qube colors, therefore it was obtained from qubesadmin. Styling decisions: 1. Qube color was added to node borders in case of a literal qube (one that doesn't start with "@"). On non-literal qubes, nodes are dotted because it's not necessarily an individual qube. 2. The chosen style of coloring borders instead of solid filling made it easier to deal with the inner text (example: black text on black colored qubes). --- qrexec/tools/qrexec_policy_graph.py | 55 +++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/qrexec/tools/qrexec_policy_graph.py b/qrexec/tools/qrexec_policy_graph.py index e3e60ce3..161b4f2e 100644 --- a/qrexec/tools/qrexec_policy_graph.py +++ b/qrexec/tools/qrexec_policy_graph.py @@ -24,6 +24,8 @@ import sys +import qubesadmin + from .. import POLICYPATH from .. import exc from .. import utils @@ -78,8 +80,8 @@ ) -def handle_single_action(args, action): - """Get single policy action and output (or not) a line to add""" +def handle_single_action(args, action, app): + """Get single policy action and output (or not) lines to add""" if args.skip_labels: service = "" else: @@ -91,25 +93,47 @@ def handle_single_action(args, action): if action.rule.action.target: target = action.rule.action.target if args.target and target not in args.target: - return "" + return [] + + lines = [] + + # create nodes for source and target, with colors + for node in [action.request.source, target]: + if node.startswith("@"): # non-literal qubes + node_attributes = "style = dotted" + else: + node_color = app.domains[node].label.color.replace("0x", "#") + node_attributes = f'color = "{node_color}", penwidth = 5' + lines.append(f' "{node}" [{node_attributes}];\n') + + # create edges with services as labels if args.full_output: color = "orange" if isinstance(action, parser.AskResolution) else "red" - return ( + lines.append( f' "{action.request.source}" -> "{target}" ' f'[label="{service} {action.rule.action}" color={color}];\n' ) + return lines + if isinstance(action, parser.AskResolution): if args.include_ask: - return ( - f' "{action.request.source}" -> "{target}" ' - f'[label="{service}" color=orange];\n' + lines.append( + ( + f' "{action.request.source}" -> "{target}" ' + f'[label="{service}" color=orange];\n' + ) ) + return lines elif isinstance(action, parser.AllowResolution): - return ( - f' "{action.request.source}" -> "{target}" ' - f'[label="{service}" color=red];\n' + lines.append( + ( + f' "{action.request.source}" -> "{target}" ' + f'[label="{service}" color=red];\n' + ) ) - return "" + return lines + + return [] def main(args=None, output=sys.stdout): @@ -165,6 +189,8 @@ def main(args=None, output=sys.stdout): ) targets.append("@default") + app = qubesadmin.Qubes() + connections = set() policy = parser.FilePolicy(policy_path=args.policy_dir) @@ -201,14 +227,15 @@ def main(args=None, output=sys.stdout): system_info=system_info, ) action = policy.evaluate(request) - line = handle_single_action(args, action) + except exc.AccessDenied: + continue + + for line in handle_single_action(args, action, app): if line in connections: continue if line: output.write(line) connections.add(line) - except exc.AccessDenied: - continue output.write("}\n") if args.output: From fdde80086d4d631bc00d4583050c126faa4b76f5 Mon Sep 17 00:00:00 2001 From: notmuchtohide <106391832+notmuchtohide@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:24:01 +0000 Subject: [PATCH 2/4] Obtain label from system_info Obtain label from system_info instead of adding a dependency in qubesadmin. --- qrexec/tools/qrexec_policy_graph.py | 12 +++++------- qrexec/utils.py | 1 + 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/qrexec/tools/qrexec_policy_graph.py b/qrexec/tools/qrexec_policy_graph.py index 161b4f2e..eb636655 100644 --- a/qrexec/tools/qrexec_policy_graph.py +++ b/qrexec/tools/qrexec_policy_graph.py @@ -24,8 +24,6 @@ import sys -import qubesadmin - from .. import POLICYPATH from .. import exc from .. import utils @@ -80,7 +78,7 @@ ) -def handle_single_action(args, action, app): +def handle_single_action(args, action, system_info): """Get single policy action and output (or not) lines to add""" if args.skip_labels: service = "" @@ -102,7 +100,9 @@ def handle_single_action(args, action, app): if node.startswith("@"): # non-literal qubes node_attributes = "style = dotted" else: - node_color = app.domains[node].label.color.replace("0x", "#") + node_color = system_info["domains"][node]["label"].replace( + "0x", "#" + ) node_attributes = f'color = "{node_color}", penwidth = 5' lines.append(f' "{node}" [{node_attributes}];\n') @@ -189,8 +189,6 @@ def main(args=None, output=sys.stdout): ) targets.append("@default") - app = qubesadmin.Qubes() - connections = set() policy = parser.FilePolicy(policy_path=args.policy_dir) @@ -230,7 +228,7 @@ def main(args=None, output=sys.stdout): except exc.AccessDenied: continue - for line in handle_single_action(args, action, app): + for line in handle_single_action(args, action, system_info): if line in connections: continue if line: diff --git a/qrexec/utils.py b/qrexec/utils.py index dc4d0af4..29c6f8e3 100644 --- a/qrexec/utils.py +++ b/qrexec/utils.py @@ -127,6 +127,7 @@ class SystemInfoEntry(TypedDict): default_dispvm: Optional[str] power_state: str icon: str + label: str guivm: Optional[str] uuid: Optional[str] name: str From 7f6b461ef92b511777e183d01f573abc5a657e92 Mon Sep 17 00:00:00 2001 From: notmuchtohide <106391832+notmuchtohide@users.noreply.github.com> Date: Sun, 8 Mar 2026 16:26:21 +0000 Subject: [PATCH 3/4] Reduce graph node border --- qrexec/tools/qrexec_policy_graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qrexec/tools/qrexec_policy_graph.py b/qrexec/tools/qrexec_policy_graph.py index eb636655..5881fd89 100644 --- a/qrexec/tools/qrexec_policy_graph.py +++ b/qrexec/tools/qrexec_policy_graph.py @@ -103,7 +103,7 @@ def handle_single_action(args, action, system_info): node_color = system_info["domains"][node]["label"].replace( "0x", "#" ) - node_attributes = f'color = "{node_color}", penwidth = 5' + node_attributes = f'color = "{node_color}", penwidth = 3' lines.append(f' "{node}" [{node_attributes}];\n') # create edges with services as labels From b2822df6fc22e52dbaa721a25ba92040f342b8b6 Mon Sep 17 00:00:00 2001 From: notmuchtohide <106391832+notmuchtohide@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:21:56 +0000 Subject: [PATCH 4/4] tests: add qube color to system_info mock --- qrexec/tests/tools/qrexec_legacy_convert.py | 6 ++++++ qrexec/tests/tools/qrexec_policy_graph.py | 24 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/qrexec/tests/tools/qrexec_legacy_convert.py b/qrexec/tests/tools/qrexec_legacy_convert.py index d9dcf95d..ae83ef56 100644 --- a/qrexec/tests/tools/qrexec_legacy_convert.py +++ b/qrexec/tests/tools/qrexec_legacy_convert.py @@ -31,6 +31,7 @@ def system_info(): system_info = { "dom0": { "icon": "black", + "label": "0xffffff", "template_for_dispvms": False, "guivm": None, "type": "AdminVM", @@ -39,6 +40,7 @@ def system_info(): }, "work": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": False, "guivm": None, "type": "AppVM", @@ -47,6 +49,7 @@ def system_info(): }, "personal": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": False, "guivm": None, "type": "AppVM", @@ -55,6 +58,7 @@ def system_info(): }, "sys-usb": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": False, "guivm": None, "type": "AppVM", @@ -63,6 +67,7 @@ def system_info(): }, "sys-usb-2": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": False, "guivm": None, "type": "AppVM", @@ -71,6 +76,7 @@ def system_info(): }, "dvm_template": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": True, "guivm": None, "type": "AppVM", diff --git a/qrexec/tests/tools/qrexec_policy_graph.py b/qrexec/tests/tools/qrexec_policy_graph.py index dff3722e..7f106b6d 100644 --- a/qrexec/tests/tools/qrexec_policy_graph.py +++ b/qrexec/tests/tools/qrexec_policy_graph.py @@ -31,6 +31,7 @@ def system_info(): system_info = { "dom0": { "icon": "black", + "label": "0xffffff", "template_for_dispvms": False, "guivm": None, "type": "AdminVM", @@ -39,6 +40,7 @@ def system_info(): }, "work": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": False, "guivm": None, "type": "AppVM", @@ -47,6 +49,7 @@ def system_info(): }, "personal": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": False, "guivm": None, "type": "AppVM", @@ -55,6 +58,7 @@ def system_info(): }, "sys-usb": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": False, "guivm": None, "type": "AppVM", @@ -63,6 +67,7 @@ def system_info(): }, "sys-usb-2": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": False, "guivm": None, "type": "AppVM", @@ -71,6 +76,7 @@ def system_info(): }, "dvm_template": { "icon": "red", + "label": "0xcc0000", "template_for_dispvms": True, "guivm": None, "type": "AppVM", @@ -104,6 +110,8 @@ def test_simple_graph(): main(["--policy-dir", policy_dir, "--output", output.name]) content = output.read().decode() expected = """digraph g { + "work" [color = "#cc0000", penwidth = 3]; + "personal" [color = "#cc0000", penwidth = 3]; "work" -> "personal" [label="test.Service" color=red]; } """ @@ -126,6 +134,8 @@ def test_simple_ask(): ) content = output.read().decode() expected = """digraph g { + "work" [color = "#cc0000", penwidth = 3]; + "personal" [color = "#cc0000", penwidth = 3]; "work" -> "personal" [label="test.Service" color=orange]; } """ @@ -151,7 +161,10 @@ def test_simple_service(): ) content = output.read().decode() expected = """digraph g { + "work" [color = "#cc0000", penwidth = 3]; + "personal" [color = "#cc0000", penwidth = 3]; "work" -> "personal" [label="test.Service" color=red]; + "sys-usb" [color = "#cc0000", penwidth = 3]; "sys-usb" -> "personal" [label="test.Service" color=red]; } """ @@ -177,7 +190,10 @@ def test_simple_service_arg(): ) content = output.read().decode() expected = """digraph g { + "work" [color = "#cc0000", penwidth = 3]; + "personal" [color = "#cc0000", penwidth = 3]; "work" -> "personal" [label="test.Service" color=red]; + "sys-usb" [color = "#cc0000", penwidth = 3]; "sys-usb" -> "personal" [label="test.Service" color=red]; } """ @@ -202,6 +218,8 @@ def test_simple_service_arg_single(): ) content = output.read().decode() expected = """digraph g { + "work" [color = "#cc0000", penwidth = 3]; + "personal" [color = "#cc0000", penwidth = 3]; "work" -> "personal" [label="test.Service" color=red]; } """ @@ -227,6 +245,8 @@ def test_simple_service_no_wildcard(): ) content = output.read().decode() expected = """digraph g { + "work" [color = "#cc0000", penwidth = 3]; + "personal" [color = "#cc0000", penwidth = 3]; "work" -> "personal" [label="test.Service" color=red]; } """ @@ -253,6 +273,8 @@ def test_simple_service_no_wildcard_full(): ) content = output.read().decode() expected = """digraph g { + "work" [color = "#cc0000", penwidth = 3]; + "personal" [color = "#cc0000", penwidth = 3]; "work" -> "personal" [label="test.Service+arg allow" color=red]; "work" -> "personal" [label="test.Service+arg2 allow" color=red]; } @@ -277,6 +299,8 @@ def test_simple_redirect(): ) content = output.read().decode() expected = """digraph g { + "work" [color = "#cc0000", penwidth = 3]; + "dom0" [color = "#ffffff", penwidth = 3]; "work" -> "dom0" [label="test.Service" color=red]; } """