Major step towards unification of the UI Implementation of the Back Feature, splitting of TUI files back into subfolders

This commit is contained in:
2025-12-02 16:22:43 -05:00
parent 1f06404a16
commit 1d9caadaf3
29 changed files with 3761 additions and 561 deletions
+369
View File
@@ -0,0 +1,369 @@
# Copyright (C) 2025 James Brotosky, Brandon Wickline
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
from typing import List, Optional
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import (
Button,
Footer,
Header,
Input,
RadioButton,
RadioSet,
Static,
TextArea,
)
from models.agent import Agent
logger = logging.getLogger(__name__)
class OTPGenerator(Widget):
"""Widget for generating OTPs for selected devices."""
# Reactive properties to track form completion
requestor_filled = reactive(False)
reasoning_filled = reactive(False)
duration_selected = reactive(True) # Default is selected
otp_generated = reactive(False)
class OTPInfo(Message):
def __init__(
self,
devices: Optional[List[Agent]],
requestor: str,
reasoning: str,
duration: int,
):
super().__init__()
self.devices = devices
self.requestor = requestor
self.reasoning = reasoning
self.duration = duration
# Duration options in minutes
DURATION_OPTIONS = [
(15, "15 minutes"),
(60, "1 hour"),
(360, "6 hours"),
(1440, "1 day"),
(10080, "7 days"),
]
def __init__(self, devices: List[Agent]):
"""Initialize with a list of Agent objects."""
super().__init__()
self.devices = devices
def watch_requestor_filled(self, old_value: bool, new_value: bool) -> None:
"""Update button state when requestor changes."""
self._update_button_state()
def watch_reasoning_filled(self, old_value: bool, new_value: bool) -> None:
"""Update button state when reasoning changes."""
self._update_button_state()
def watch_otp_generated(self, old_value: bool, new_value: bool) -> None:
"""Update button state when OTP is generated."""
self._update_button_state()
def _update_button_state(self) -> None:
"""Enable/disable the generate button based on form state."""
try:
button = self.query_one("#generate_button", Button)
# Enable only if all fields filled and OTP not yet generated
button.disabled = not (
self.requestor_filled
and self.reasoning_filled
and not self.otp_generated
)
except NoMatches:
pass
def compose(self):
yield Header(show_clock=True, icon="")
title_text = Static(
f"🎫 Generate One Time Passes for {len(self.devices)} device(s)",
id="otpgen_title",
)
title_text.styles.margin = (0, 0, 1, 0)
yield title_text
with Horizontal() as main_layout:
main_layout.styles.height = "auto"
# Left side - Inputs and controls
with Vertical() as left_side:
left_side.styles.width = "1fr"
left_side.styles.height = "auto"
# Requestor input
requestor_label = Static("Who is requesting OTP?")
requestor_label.styles.margin = (0, 0, 0, 0)
yield requestor_label
requestor_box = Input(
placeholder="Enter requestor name", id="requestor_input"
)
requestor_box.styles.margin = (0, 0, 1, 0)
yield requestor_box
# Reasoning input
reasoning_label = Static("What work are they doing?")
reasoning_label.styles.margin = (0, 0, 0, 0)
yield reasoning_label
reasoning_box = Input(
placeholder="Enter reason for OTP", id="reasoning_input"
)
reasoning_box.styles.margin = (0, 0, 1, 0)
yield reasoning_box
# Duration selection
duration_label = Static("Duration:")
duration_label.styles.margin = (0, 0, 0, 0)
yield duration_label
with RadioSet(id="duration_radio") as radio_set:
radio_set.styles.margin = (0, 0, 1, 0)
for minutes, label in self.DURATION_OPTIONS:
radio = RadioButton(label, id=f"duration_{minutes}")
if minutes == 360: # Default to 6 hours
radio.value = True
yield radio
# Buttons in a horizontal layout
with Horizontal() as button_row:
button_row.styles.height = "auto"
button_row.styles.margin = (1, 0, 0, 0)
generate_button = Button(
"Generate OTP", id="generate_button", variant="primary"
)
generate_button.styles.width = "100%"
yield generate_button
# Right side - Show device list initially, then output after generation
with Vertical() as right_side:
right_side.styles.width = "2fr"
right_side.styles.height = "100%"
output_label = Static(
f"Selected Devices ({len(self.devices)}):", id="output_label"
)
output_label.styles.margin = (0, 0, 0, 0)
yield output_label
# Container for either device list or output
with Vertical(id="output_container") as output_container:
output_container.styles.height = "1fr"
output_container.styles.margin = (1, 0, 0, 0)
output_container.styles.overflow_y = "auto"
output_container.styles.border = ("round", "green")
# Show device list initially
device_list_text = "\n".join(
f"{device.hostname}" for device in self.devices
)
device_display = Static(device_list_text, id="device_display")
yield device_display
# Copy to clipboard button (hidden initially)
copy_button = Button("📋 Copy to Clipboard", id="copy_clipboard_button")
copy_button.styles.margin = (1, 0, 0, 0)
copy_button.styles.display = "none"
yield copy_button
yield Footer()
def on_mount(self) -> None:
"""Set initial button state."""
self._update_button_state()
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle input field changes."""
input_id = event.input.id
if input_id == "requestor_input":
self.requestor_filled = bool(event.value.strip())
elif input_id == "reasoning_input":
self.reasoning_filled = bool(event.value.strip())
def on_button_pressed(self, event: Button.Pressed):
btn_id = event.button.id
if btn_id == "copy_clipboard_button":
try:
output_area = self.query_one("#otp_output", TextArea)
text_to_copy = output_area.text
import pyperclip
pyperclip.copy(text_to_copy)
self.app.notify(
"✓ Copied to clipboard!", severity="information", timeout=2
)
except ImportError:
self.app.notify(
"⚠️ pyperclip not installed. Run: pip install pyperclip",
severity="warning",
)
except Exception as e:
self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error")
event.stop()
elif btn_id == "generate_button":
try:
requestor = self.query_one("#requestor_input", Input).value.strip()
reasoning = self.query_one("#reasoning_input", Input).value.strip()
radio_set = self.query_one("#duration_radio", RadioSet)
selected_button_id = (
radio_set.pressed_button.id if radio_set.pressed_button else None
)
if not selected_button_id:
self._show_error("Please select a duration")
return
duration = int(selected_button_id.replace("duration_", ""))
if not requestor or not reasoning:
self._show_error("Please fill in all fields")
return
self.otp_generated = True
# Access API from the app - this is the key change!
api = self.app.api # type: ignore
output_lines = [
"Requested OTP Codes:",
"=" * 25,
]
otp_dict = {}
for device in self.devices:
try:
otp_code = api.otp_generate(device.agentid, duration, reasoning)
otp_dict[device.hostname] = otp_code
logger.debug(f"Generated OTP for {device.hostname}: {otp_code}")
except Exception as e:
otp_dict[device.hostname] = f"ERROR: {str(e)}"
logger.error(
f"Failed to generate OTP for {device.hostname}: {e}"
)
for hostname, otp_code in otp_dict.items():
output_lines.append(f"{hostname} | {otp_code}")
output_lines.append("=" * 25)
result_text = "\n".join(output_lines)
self._show_result(result_text)
# Post message with the OTP info
self.post_message(
self.OTPInfo(self.devices, requestor, reasoning, duration)
)
event.stop()
except NoMatches:
self._show_error("UI elements not found")
except Exception as e:
self._show_error(f"Error: {str(e)}")
logger.exception("Error generating OTP")
def _show_error(self, message: str):
"""Display error message in output area."""
try:
container = self.query_one("#output_container", Vertical)
try:
device_display = self.query_one("#device_display", Static)
device_display.remove()
except NoMatches:
pass
try:
output_area = self.query_one("#otp_output", TextArea)
except NoMatches:
output_area = TextArea(id="otp_output", read_only=True)
container.mount(output_area)
output_area.text = f"❌ ERROR: {message}"
except Exception as e:
logger.debug(f"Error showing error message: {e}")
def _show_result(self, message: str):
"""Display result message in output area."""
try:
container = self.query_one("#output_container", Vertical)
try:
device_display = self.query_one("#device_display", Static)
device_display.remove()
except NoMatches:
pass
try:
output_area = self.query_one("#otp_output", TextArea)
except NoMatches:
output_area = TextArea(id="otp_output", read_only=True)
container.mount(output_area)
output_area.text = message
output_label = self.query_one("#output_label", Static)
output_label.update("Generated OTP Details:")
copy_button = self.query_one("#copy_clipboard_button", Button)
copy_button.styles.display = "block"
except Exception as e:
logger.debug(f"Error showing result: {e}")
def display_otp_result(self, result_text: str):
"""Display OTP generation result in the output area."""
try:
container = self.query_one("#output_container", Vertical)
try:
device_display = self.query_one("#device_display", Static)
device_display.remove()
except NoMatches:
pass
try:
output_area = self.query_one("#otp_output", TextArea)
except NoMatches:
output_area = TextArea(id="otp_output", read_only=True)
container.mount(output_area)
output_area.text = result_text
except Exception as e:
logger.debug(f"Error displaying OTP result: {e}")
def clear_form(self):
"""Clear all input fields and reset state."""
try:
self.query_one("#requestor_input", Input).value = ""
self.query_one("#reasoning_input", Input).value = ""
self.query_one("#otp_output", TextArea).text = ""
self.otp_generated = False
self.requestor_filled = False
self.reasoning_filled = False
self._update_button_state()
except NoMatches:
pass
+740
View File
@@ -0,0 +1,740 @@
# Copyright (C) 2025 James Brotosky, Brandon Wickline
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from dataclasses import asdict
from datetime import datetime
import logging
import os
from typing import List
import pandas as pd
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.reactive import reactive
from textual.widget import Widget
from textual.widgets import Button, DataTable, Footer, Header, Static, TextArea
from models.agent import Agent
from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen
from TUI.Screens.policyselectorscreen import PolicySelectorScreen
from TUI.Widgets.OTP_generate import OTPGenerator
logger = logging.getLogger(__name__)
class AgentMoveOperations(Widget):
"""
A Textual widget for managing bulk agent operations and policy migrations.
This widget provides a comprehensive UI for performing operations on multiple
selected agents. It displays the list of target agents and provides buttons to
trigger various bulk operations like toggling policy modes or enabling local approval.
The widget manages its own state through reactive properties and provides real-time
feedback on operation progress and results. Operations are executed sequentially
per agent with error handling that tracks both successful and failed operations.
Attributes:
operation_in_progress (reactive[bool]): Tracks whether an operation is currently
executing. Used to disable buttons during execution.
selected_operation (reactive[str]): Tracks which operation type is currently
selected or in progress (e.g., "local_approval", "toggle_enforcement").
Example:
```python
agents = [agent1, agent2, agent3]
widget = AgentMoveOperations(agents)
```
"""
# Reactive property to track if an operation is in progress
operation_in_progress = reactive(False)
# Tracks the currently selected operation type
selected_operation = reactive("")
class OperationComplete(Message):
"""
Message posted when a bulk operation completes.
This message is broadcast to parent widgets/screens to notify them of
operation completion along with detailed results. It contains the list
of agents that were processed and the outcome for each.
Attributes:
operation (str): Name of the operation that completed (e.g., "Local Approval Mode").
agents (List[Agent]): List of all agents that were targeted by the operation.
successful (List[tuple]): List of (Agent, result_data) tuples for successfully
processed agents. Result data varies by operation type.
unsuccessful (List[tuple]): List of (Agent, error_message) tuples for agents
where the operation failed. Error message is a string explaining the failure.
"""
def __init__(
self,
operation: str,
agents: List[Agent],
successful: List[tuple],
unsuccessful: List[tuple],
):
super().__init__()
self.operation = operation
self.agents = agents
self.successful = successful # List of (agent, result) tuples
self.unsuccessful = unsuccessful # List of (agent, error) tuples
def __init__(self, agents: List[Agent]):
"""
Initialize the AgentMoveOperations widget.
Args:
agents (List[Agent]): List of Agent objects to perform operations on.
These agents will be displayed in the widget's agent table.
"""
super().__init__()
self.agents = agents
def watch_operation_in_progress(self, old_value: bool, new_value: bool) -> None:
"""
React to changes in the operation_in_progress reactive property.
This is called automatically by Textual when operation_in_progress changes.
It updates the button states to reflect whether an operation is running.
Args:
old_value (bool): Previous value of operation_in_progress.
new_value (bool): New value of operation_in_progress.
"""
self._update_button_states()
def _update_button_states(self) -> None:
"""
Update the enabled/disabled state of operation buttons based on current status.
This method implements the following logic:
- If an operation is in progress: disable all buttons
- If an operation is selected: disable only that operation's button
- If no operation is selected: enable all buttons
The state transitions prevent users from starting multiple operations
simultaneously and provide visual feedback on which operation is active.
Handles NoMatches exceptions gracefully in case buttons are not yet rendered.
"""
try:
export_csv_btn = self.query_one("#export_csv_btn", Button)
local_approval_btn = self.query_one("#local_approval_btn", Button)
toggle_enforcement_btn = self.query_one("#toggle_enforcement_btn", Button)
other_policy_btn = self.query_one("#other_policy_btn", Button)
otp_gen_btn = self.query_one("#otp_gen_btn", Button)
# If operation in progress, disable all
if self.operation_in_progress:
otp_gen_btn = True
export_csv_btn.disabled = True
local_approval_btn.disabled = True
toggle_enforcement_btn.disabled = True
other_policy_btn.disabled = True
else:
# If an operation was selected, disable
if self.selected_operation:
otp_gen_btn.disabled = self.selected_operation == "otp_gen"
export_csv_btn.disabled = self.selected_operation == "export_csv"
local_approval_btn.disabled = (
self.selected_operation == "local_approval"
)
toggle_enforcement_btn.disabled = (
self.selected_operation == "toggle_enforcement"
)
other_policy_btn.disabled = (
self.selected_operation == "other_policy"
)
else:
# Enable all buttons
otp_gen_btn = False
export_csv_btn = False
local_approval_btn.disabled = False
toggle_enforcement_btn.disabled = False
other_policy_btn.disabled = False
except NoMatches:
pass
def _display_results(
self, operation_name: str, successful: list, unsuccessful: list
) -> None:
"""
Display operation results in the results text area.
Formats the results into a human-readable summary including:
- Operation name and separator
- List of successful operations with agent hostnames
- List of failed operations with agent hostnames and error messages
- Summary statistics (total successful/failed count)
The results are displayed in the results_text TextArea widget and the
results container is made visible after being initially hidden.
Args:
operation_name (str): Human-readable name of the operation (e.g., "Local Approval Mode").
successful (list): List of (Agent, result_data) tuples for successful operations.
unsuccessful (list): List of (Agent, error_message) tuples for failed operations.
"""
try:
# Build results text
results_lines = [
f"Operation: {operation_name}",
f"{'=' * 50}",
"",
f"✅ Successful ({len(successful)}):",
]
if successful:
for agent, result in successful:
results_lines.append(f"{agent.hostname}")
else:
results_lines.append(" (none)")
results_lines.append("")
results_lines.append(f"❌ Failed ({len(unsuccessful)}):")
if unsuccessful:
for agent, error in unsuccessful:
results_lines.append(f"{agent.hostname}: {error}")
else:
results_lines.append(" (none)")
results_lines.append("")
results_lines.append(f"{'=' * 50}")
results_lines.append(
f"Total: {len(successful)} successful, {len(unsuccessful)} failed"
)
results_text_widget = self.query_one("#results_text", TextArea)
results_text_widget.text = "\n".join(results_lines)
# Show results container
results_container = self.query_one("#results_container", Vertical)
results_container.styles.display = "block"
except Exception as e:
logger.error(f"Error displaying results: {e}")
def compose(self):
"""
Build the UI layout for the AgentMoveOperations widget.
This method is called by Textual to create the widget's UI structure.
It builds a two-column layout with:
- Left side: Agent table showing selected agents and their current policies
- Right side: Operation buttons and results display area
- Bottom: Navigation buttons (Back)
The layout is responsive with:
- Agent table: 2/3 width
- Operations panel: 1/3 width
- Results area: Initially hidden, shown after operation completion
"""
yield Header(show_clock=True, icon="⚙️")
title_text = Static(
f"🖥️ Agent Operations - {len(self.agents)} device(s) selected",
id="move_ops_title",
)
title_text.styles.margin = (0, 0, 1, 0)
yield title_text
with Horizontal() as main_layout:
main_layout.styles.height = "auto"
# Left side - Agent list
with Vertical() as left_side:
left_side.styles.width = "3fr"
left_side.styles.height = "auto"
agents_label = Static("Selected Agents:")
agents_label.styles.margin = (0, 0, 0, 0)
yield agents_label
# Create a DataTable to show agents with their current policies
agent_table = DataTable(id="agent_table")
agent_table.styles.height = "1fr"
agent_table.styles.margin = (1, 0, 1, 0)
yield agent_table
# Right side - Operation buttons
with Vertical() as right_side:
right_side.styles.width = "2fr"
right_side.styles.margin = (0, 1, 0, 1)
right_side.styles.height = "auto"
operations_label = Static("Operations:")
operations_label.styles.margin = (0, 0, 1, 0)
yield operations_label
# Operation buttons
export_csv_btn = Button("📄 Export CSV", id="export_csv_btn")
export_csv_btn.styles.width = "100%"
export_csv_btn.styles.margin = (0, 0, 1, 0)
yield export_csv_btn
local_approval_btn = Button(
"✔️ Local Approval Mode", id="local_approval_btn"
)
local_approval_btn.styles.width = "100%"
local_approval_btn.styles.margin = (0, 0, 1, 0)
yield local_approval_btn
otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn")
otp_gen_btn.styles.width = "100%"
otp_gen_btn.styles.margin = (0, 0, 1, 0)
yield otp_gen_btn
toggle_enforcement_btn = Button(
"🔄 Toggle Audit/Enforcement", id="toggle_enforcement_btn"
)
toggle_enforcement_btn.styles.width = "100%"
toggle_enforcement_btn.styles.margin = (0, 0, 1, 0)
yield toggle_enforcement_btn
other_policy_btn = Button(
"🔀 Move to Other Policy", id="other_policy_btn"
)
other_policy_btn.styles.width = "100%"
other_policy_btn.styles.margin = (0, 0, 1, 0)
yield other_policy_btn
# Status label
status_label = Static("", id="status_label")
status_label.styles.margin = (2, 0, 0, 0)
yield status_label
yield Footer()
def on_mount(self) -> None:
"""
Initialize widget after it has been mounted on the screen.
This Textual lifecycle method is called after the widget is added to the DOM.
It performs initialization tasks:
- Populates the agent table with columns for Hostname, Policy, and Status
- Adds rows to the table for each agent in self.agents
- Initializes button states based on current widget state
The agent table displays agent.hostname, agent.groupname (or "Unknown"),
and agent.status_text (or "Unknown") for each agent.
"""
table = self.query_one("#agent_table", DataTable)
table.add_columns("Hostname", "Current Policy", "Status")
for agent in self.agents:
table.add_row(
agent.hostname,
agent.groupname or "Unknown",
agent.status_text or "Unknown",
)
self._update_button_states()
def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None:
"""Handle OTP generation request - call the actual OTP generation function."""
def on_button_pressed(self, event: Button.Pressed):
"""
Handle button press events from the widget.
This Textual event handler routes button presses to appropriate actions:
- copy_results_btn: Copy results text to clipboard (requires pyperclip)
- local_approval_btn: Start local approval operation
- toggle_enforcement_btn: Start toggle audit/enforcement operation
- other_policy_btn: Start move to other policy operation
After handling, event.stop() is called to prevent event propagation.
Args:
event (Button.Pressed): The button press event containing the button reference.
"""
btn_id = event.button.id
if btn_id == "copy_results_btn":
try:
results_text = self.query_one("#results_text", TextArea)
import pyperclip
pyperclip.copy(results_text.text)
self.app.notify(
"📋✅ Results copied to clipboard!",
severity="information",
timeout=2,
)
except ImportError:
self.app.notify(
"❌ pyperclip not installed. Run: pip install pyperclip",
severity="warning",
)
except Exception as e:
self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error")
event.stop()
elif btn_id == "export_csv_btn":
self._start_export_csv_operation()
event.stop()
elif btn_id == "local_approval_btn":
self._start_local_approval_operation()
event.stop()
elif btn_id == "toggle_enforcement_btn":
self._start_toggle_enforcement_operation()
event.stop()
elif btn_id == "other_policy_btn":
self._start_other_policy_operation()
event.stop()
elif btn_id == "otp_gen_btn":
self._start_OTP_gen_operation()
event.stop()
def _start_local_approval_operation(self) -> None:
"""
Execute the local approval mode operation on all selected agents.
This operation performs the following steps for each agent:
1. Generate a unique batch ID (current Unix timestamp)
2. Create a local approval OTP with default duration of 360 minutes (6 hours)
3. Move the agent to its related audit policy mode
The operation:
- Sets operation state flags (selected_operation, operation_in_progress)
- Updates the status label with progress indicator
- Iterates through all agents, tracking successful and unsuccessful operations
- Displays formatted results via _display_results()
- Posts an OperationComplete message for parent widget handling
Agents that fail are logged and added to the unsuccessful list with error details.
The operation completes and returns to a non-busy state regardless of individual
agent success/failure.
Note: The OTP duration (360 minutes) is currently hardcoded and could be
made configurable in future versions.
"""
self.selected_operation = "local_approval"
self.operation_in_progress = True
status_label = self.query_one("#status_label", Static)
status_label.update("✔️ Moving agents to local approval...")
# Get API from app
api = self.app.api
successful = []
unsuccessful = []
try:
import time
from services.agenthandler import moveAgentToRelatedPolicy
# Generate batch ID
batch = int(time.time())
duration = 360 # Default 6 hours, could make this configurable
for agent in self.agents:
try:
# Add local approval OTP
addLocalApproval(api, batch, duration, agent.agentid)
# Move to audit mode
result = moveAgentToRelatedPolicy(api, agent, "audit")
successful.append((agent, result))
logger.info(
f"Successfully moved {agent.hostname} to local approval"
)
except Exception as e:
unsuccessful.append((agent, str(e)))
logger.error(
f"Failed to move {agent.hostname} to local approval: {e}"
)
except Exception as e:
logger.error(f"Error during local approval operation: {e}")
status_label.update(f"❌ Error: {str(e)}")
self.operation_in_progress = False
return
self.operation_in_progress = False
status_label.update("✅ Operation complete!")
# Display results in the widget
self._display_results("Local Approval Mode", successful, unsuccessful)
# Also post message for potential parent handling
self.post_message(
self.OperationComplete(
"Local Approval Mode", self.agents, successful, unsuccessful
)
)
def _start_export_csv_operation(self) -> None:
self.selected_operation = "export_csv"
self.operation_in_progress = True
successful = []
status_label = self.query_one("#status_label", Static)
status_label.update("Exporting CSV...")
self.app.refresh_data()
agents = self.agents
policies = self.app.policies
path = self.app.working_dir
try:
# Enrich each agent with policies and status text
for agent in agents:
agent.enrich_with_policies(policies)
# Convert each Agent to a dictionary, including all fields
data = []
for agent in agents:
row = asdict(agent)
# Remove the class-level status_map from the row
row.pop("status_map", None)
data.append(row)
# Create DataFrame
df = pd.DataFrame(data)
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"agentsearch_{timestamp}.csv"
file_path = os.path.join(str(path), filename)
df.to_csv(file_path, index=False)
successful.append(file_path)
status_label.update(f"✅ Exported to {file_path}")
except Exception:
status_label.update("❌ Failed")
self.operation_in_progress = False
"""
# Display results in the widget
self._display_results("CSV Export", successful, unsuccessful)
# Also post message for potential parent handling
self.post_message(
self.OperationComplete(
"CSV Export", self.agents, successful, unsuccessful
)
)
"""
def _start_toggle_enforcement_operation(self) -> None:
"""
Toggle agents between enforcement and audit policy modes.
This operation intelligently switches each agent between enforcement and
audit modes based on its current state:
- If agent.groupid is in POLICY_MAP_ENF_AUD: currently enforcing , move to audit
- Otherwise: currently in audit, move to enforcement
The operation:
- Retrieves the enforcement/audit policy relationship map from protected config
- Sets operation state flags and updates status label
- Iterates through agents, determining current mode and toggling to opposite
- Tracks successful toggles with the new mode in the result message
- Logs both successes and failures
- Displays results and posts OperationComplete message
The policy relationship map (POLICY_MAP_ENF_AUD) must be present in protected
configuration and maps enforcement policy IDs to audit policy IDs. If the map
is empty or not found, all agents are assumed to be in audit mode and will
be moved to enforcement.
Returns to a non-busy state after completion regardless of individual results.
"""
self.selected_operation = "toggle_enforcement"
self.operation_in_progress = True
status_label = self.query_one("#status_label", Static)
status_label.update("🔄 Toggling enforcement mode...")
# Get API from app
api = self.app.api
successful = []
unsuccessful = []
try:
from services.agenthandler import moveAgentToRelatedPolicy
from utils.configmanager import get_system_json
policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}")
for agent in self.agents:
try:
# Determine current mode and toggle
if agent.groupid in policy_relationship_map:
# Currently in enforcement, move to audit
result = moveAgentToRelatedPolicy(api, agent, "audit")
mode = "audit"
else:
# Currently in audit, move to enforcement
result = moveAgentToRelatedPolicy(api, agent, "enforcement")
mode = "enforcement"
successful.append((agent, f"Moved to {mode}: {result}"))
logger.info(f"Successfully toggled {agent.hostname} to {mode}")
self.app.refresh_data()
except Exception as e:
unsuccessful.append((agent, str(e)))
logger.error(f"Failed to toggle {agent.hostname}: {e}")
except Exception as e:
logger.error(f"Error during toggle enforcement operation: {e}")
status_label.update(f"❌ Error: {str(e)}")
self.operation_in_progress = False
return
self.operation_in_progress = False
status_label.update("✅ Operation complete!")
# Display results in the widget
self._display_results("Toggle Audit/Enforcement", successful, unsuccessful)
# Also post message for potential parent handling
self.post_message(
self.OperationComplete(
"Toggle Audit/Enforcement", self.agents, successful, unsuccessful
)
)
def _start_other_policy_operation(self) -> None:
"""
Move agents to a user-selected policy (currently unimplemented).
This operation is intended to allow bulk movement of selected agents to any
alternative policy via a policy selection dialog. Currently, this feature
is not fully implemented.
Planned Implementation:
1. Push a new policy selector screen (TUI modal/overlay)
2. Allow user to choose target policy from available options
3. Move all selected agents to the chosen policy
4. Display results like other operations
Current Behavior:
- Sets selected_operation to "other_policy"
- Displays "Policy selection not yet implemented" status message
- Clears selected_operation without performing any action
TODO: Complete implementation by:
- Creating a policy selector screen component
- Implementing the policy selection logic
- Integrating with moveAgentToPolicy API call
- Adding proper result tracking and display
"""
self.selected_operation = "other_policy"
self.operation_in_progress = True
status_label = self.query_one("#status_label", Static)
status_label.update("Loading available policies...")
try:
# Fetch all policies from API
api = self.app.api
# Fetch all available policies
all_policies_df = api.policy_find_all()
if all_policies_df.empty:
status_label.update("No policies available")
self.operation_in_progress = False
self.selected_operation = ""
return
# Create and push the policy selector screen
policy_selector_screen = PolicySelectorScreen(
policies=all_policies_df,
agent_move_operations=self,
)
self.app.push_screen(policy_selector_screen)
except Exception as e:
logger.error(f"Error loading policies: {e}")
status_label.update(f"❌ Error: {str(e)}")
self.operation_in_progress = False
self.selected_operation = ""
self.app.notify(f"Failed to load policies: {str(e)}", severity="error")
def _start_OTP_gen_operation(self) -> None:
status_label = self.query_one("#status_label", Static)
status_label.update("Generating OTP.")
self.app.push_screen(OTPWorkflowScreen(self.agents))
def _execute_move_to_policy(self, target_policy) -> None:
"""
Execute the actual move of agents to the selected policy.
Moves each agent sequentially to the target policy, tracking success/failure.
Updates the status label and displays results upon completion.
Args:
target_policy: The Policy object selected by the user.
"""
status_label = self.query_one("#status_label", Static)
status_label.update(f"Moving agents to {target_policy.name}...")
api = self.app.api
successful = []
unsuccessful = []
try:
for agent in self.agents:
try:
# Move agent to target policy
api.agent_move(agent.agentid, target_policy.groupid)
successful.append((agent, f"Moved to {target_policy.name}"))
logger.info(
f"Successfully moved {agent.hostname} to policy {target_policy.name}"
)
except Exception as e:
unsuccessful.append((agent, str(e)))
logger.error(
f"Failed to move {agent.hostname} to policy {target_policy.name}: {e}"
)
except Exception as e:
logger.error(f"Error during move to policy operation: {e}")
status_label.update(f"Error: {str(e)}")
self.operation_in_progress = False
return
self.app.refresh_data()
self.operation_in_progress = False
status_label.update("Operation complete!")
# Display results in the widget
self._display_results(
f"Move to {target_policy.name}",
successful,
unsuccessful,
)
# Also post message for potential parent handling
self.post_message(
self.OperationComplete(
f"Move to {target_policy.name}",
self.agents,
successful,
unsuccessful,
)
)
+216
View File
@@ -0,0 +1,216 @@
# Copyright (C) 2025 James Brotosky, Brandon Wickline
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import difflib
import re
from typing import List, Optional
from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.message import Message
from textual.widget import Widget
from textual.widgets import (
Button,
Footer,
Header,
SelectionList,
Static,
Switch,
TextArea,
)
from models.agent import Agent
class MultiAgentSelector(Widget):
"""Widget for selecting multiple agents from a list."""
class AgentsSelected(Message):
def __init__(self, selected_agents: List[Agent]):
super().__init__()
self.selected_agents = selected_agents
def __init__(self, all_agents: Optional[List[Agent]]):
super().__init__()
self.all_agents = all_agents
self._match_type = "exact"
@property
def match_type(self):
return self._match_type
@match_type.setter
def match_type(self, value):
self._match_type = value
def compose(self):
yield Header(show_clock=True, icon="")
title_text = Static("🖥️ Agent Selector", id="selector_title")
title_text.styles.margin = (0, 0, 0, 1)
yield title_text
with Horizontal() as main_layout:
main_layout.styles.height = "auto"
# Left side - Input and controls
with Vertical() as left_pane:
left_pane.styles.width = "1fr"
left_pane.styles.height = "auto"
text_area = TextArea(
id="device_input",
placeholder="Paste device names here (one per line). Supports wildcards: * and ?",
)
text_area.styles.height = 10
text_area.styles.overflow_y = "auto"
yield text_area
with Horizontal(id="switch_search_container"):
switch = Switch(value=False, id="match_switch")
switch.styles.width = "auto"
switch.styles.margin = (1, 0, 0, 0)
switch.styles.padding = (0, 0, 0, 0)
yield switch
switch_label = Static("Match: Exact", id="match_switch_label")
switch_label.styles.width = "auto"
switch_label.styles.margin = (2, 1, 0, 0)
yield switch_label
search = Button("🔍 Search", id="search_button")
search.styles.margin = (1, 0, 0, 0)
yield search
with Horizontal() as select_buttons:
select_buttons.styles.margin = (0, 0, 0, 0)
select_none_button = Button("🚫 Select None", id="select_none")
select_none_button.styles.margin = (1, 1, 0, 1)
yield select_none_button
select_all_button = Button("✅ Select All", id="select_all")
select_all_button.styles.margin = (1, 0, 0, 1)
yield select_all_button
with Horizontal() as button_row:
button_row.styles.height = "auto"
button_row.styles.margin = (1, 0, 0, 0)
submit_button = Button(
"▶ Select & Continue", id="submit_selection", variant="primary"
)
submit_button.styles.margin = (0, 5, 2, 1)
submit_button.styles.padding = (0, 6, 0, 0)
yield submit_button
# Right side - Results
with Vertical() as right_pane:
right_pane.styles.width = "2fr"
yield SelectionList(id="match_results")
yield Static(id="unmatched_label")
yield Footer()
def on_switch_changed(self, event: Switch.Changed):
self.match_type = "fuzzy" if event.value else "exact"
self.query_one("#match_switch_label", Static).update(
f"Match: {self.match_type.capitalize()}"
)
def on_button_pressed(self, event: Button.Pressed):
btn_id = event.button.id
try:
match_list = self.query_one("#match_results", SelectionList)
except NoMatches:
return
if btn_id == "select_all":
match_list.select_all()
event.stop()
elif btn_id == "select_none":
match_list.deselect_all()
event.stop()
elif btn_id == "submit_selection":
# Get selected hostnames
selected_hostnames = list(match_list.selected)
# Convert back to Agent objects
selected_agents = [
agent
for agent in self.all_agents
if agent.hostname in selected_hostnames
]
self.post_message(self.AgentsSelected(selected_agents))
event.stop()
elif btn_id == "search_button":
self.update_matches()
event.stop()
def update_matches(self):
raw_input = self.query_one("#device_input", TextArea).text.strip()
device_names = [line.strip() for line in raw_input.split("\n") if line.strip()]
matched, unmatched = self.match_devices(device_names)
match_list = self.query_one("#match_results", SelectionList)
match_list.clear_options()
for name in matched:
match_list.add_option((name, name))
unmatched_label = self.query_one("#unmatched_label", Static)
if unmatched:
unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}")
else:
unmatched_label.update("")
def match_devices(self, device_names: list[str]) -> tuple[list[str], list[str]]:
if not self.all_agents or not device_names:
return [], device_names
agent_names = [agent.hostname for agent in self.all_agents]
matched = set()
unmatched = []
for name in device_names:
# Check if the name contains wildcards
has_wildcards = "*" in name or "?" in name
if has_wildcards:
# Use regex for wildcard matching
pattern = re.escape(name)
pattern = pattern.replace(r"\*", ".*").replace(r"\?", ".")
regex = re.compile(f"^{pattern}$", re.IGNORECASE)
wildcard_matches = [
agent_name for agent_name in agent_names if regex.match(agent_name)
]
if wildcard_matches:
matched.update(wildcard_matches)
else:
unmatched.append(name)
elif self.match_type == "exact":
# Case-insensitive exact match
name_lower = name.lower()
exact_match = None
for agent_name in agent_names:
if agent_name.lower() == name_lower:
exact_match = agent_name
break
if exact_match:
matched.add(exact_match)
else:
unmatched.append(name)
else:
# Fuzzy match
matches = difflib.get_close_matches(name, agent_names, n=5, cutoff=0.5)
if matches:
matched.update(matches)
else:
unmatched.append(name)
return sorted(matched), unmatched
+486
View File
@@ -0,0 +1,486 @@
# Copyright (C) 2025 James Brotosky, Brandon Wickline
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>
import logging
import re
from typing import Optional
import pandas as pd
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Button, DataTable, Static, TextArea
from models.policy import Policy
logger = logging.getLogger(__name__)
class PolicySelector(Widget):
"""
A Textual widget for selecting a target policy for agent operations.
This widget displays available policies in a table and allows users to select
one policy as the destination for bulk agent movements. It automatically excludes:
- Parent/logical policies (where parent == "global-policy-settings")
- Specified policy IDs (e.g., the current policy)
Features:
- Wildcard filtering (* and ?)
- Interactive table for policy browsing
- Explicit confirm button for selection
- Use escape key to go back
Attributes:
policies (list[Policy]): List of available Policy objects to display.
excluded_policy_ids (set[str]): Set of policy IDs to exclude from selection.
selected_policy (Optional[Policy]): The currently selected policy (if any).
Automatically Filtered Out:
- Policies with parent == "global-policy-settings" (parent policies for organization)
- Any policies in excluded_policy_ids set
Example:
```python
policies = [policy1, policy2, policy3]
widget = PolicySelector(policies, excluded_policy_ids={current_policy.groupid})
```
"""
class PolicySelected(Message):
"""
Message posted when a policy is selected.
Attributes:
policy (Policy): The selected policy object.
"""
def __init__(self, policy: Policy):
super().__init__()
self.policy = policy
def __init__(self, policies: list):
"""
Initialize the PolicySelector widget.
Args:
policies (list): List of Policy objects or DataFrame rows to display.
Can be a list of Policy objects or a pandas DataFrame of policy data.
"""
super().__init__()
self.policies = policies
self.selected_policy: Optional[Policy] = None
self._filtered_policies = []
self._displayed_policies = [] # Track what's currently shown in the table
self._filter_text = ""
def compose(self):
"""
Build the UI layout for the PolicySelector widget.
The layout includes:
- Title indicating policy selection
- Search/filter text area with wildcard support
- Filter help text showing wildcard options
- Apply Filter button
- Clear Filter button
- Confirm Selection button
- Policy table displaying available policies
- Use escape key to go back
"""
title_text = Static(
"Select Target Policy",
id="policy_selector_title",
)
title_text.styles.margin = (0, 0, 1, 0)
yield title_text
with Horizontal() as main_layout:
main_layout.styles.height = "auto"
# Left side - Filter and controls
with Vertical() as left_side:
left_side.styles.width = "1fr"
left_side.styles.height = "auto"
left_side.styles.margin = (0, 1, 0, 1)
filter_label = Static("Filter Policies:")
filter_label.styles.margin = (0, 0, 0, 0)
yield filter_label
filter_input = TextArea(
id="policy_filter",
text="",
)
filter_input.styles.height = 3
filter_input.styles.margin = (0, 0, 1, 0)
yield filter_input
filter_help = Static("(Use * and ? for wildcards)", id="filter_help")
filter_help.styles.margin = (0, 0, 1, 0)
yield filter_help
apply_button = Button("🔍 Apply Filter", id="filter_button")
apply_button.styles.width = "100%"
apply_button.styles.margin = (0, 0, 1, 0)
yield apply_button
clear_button = Button("🧹 Clear Filter", id="clear_filter_button")
clear_button.styles.width = "100%"
clear_button.styles.margin = (0, 0, 1, 0)
yield clear_button
confirm_button = Button("✅ Confirm Selection", id="confirm_button")
confirm_button.styles.width = "100%"
confirm_button.styles.margin = (1, 0, 1, 0)
yield confirm_button
selected_label = Static("", id="selected_policy_label")
selected_label.styles.margin = (2, 0, 1, 0)
yield selected_label
# Right side - Policy table
with Vertical() as right_side:
right_side.styles.width = "2fr"
right_side.styles.height = "auto"
table_label = Static("Available Policies:")
table_label.styles.margin = (0, 0, 0, 0)
yield table_label
policy_table = DataTable(id="policy_table", cursor_type="row")
policy_table.styles.height = "1fr"
policy_table.styles.margin = (1, 0, 1, 0)
yield policy_table
def on_mount(self) -> None:
"""
Initialize the policy table when the widget is mounted.
Populates the table with column (Policy Name) and rows for each
available policy (excluding those in excluded_policy_ids and parent policies).
Sets up event handlers for table row selection.
Filters out:
- Parent policies (where parent == "global-policy-settings")
"""
table = self.query_one("#policy_table", DataTable)
# Configure table for row selection
table.cursor_type = "row"
table.zebra_stripes = True
# Only add Policy Name column
table.add_columns("Policy Name")
# Filter out excluded policies and convert to list if DataFrame
if isinstance(self.policies, pd.DataFrame):
policies_list = self.policies.to_dict("records")
else:
policies_list = self.policies
policies_list = sorted(policies_list)
self._filtered_policies = []
self._displayed_policies = [] # Initialize displayed list
for policy_data in policies_list:
# Handle both Policy objects and dict/DataFrame rows
if isinstance(policy_data, Policy):
policy_id = policy_data.groupid
policy_name = policy_data.name
parent = policy_data.parent
else:
policy_id = policy_data.get("groupid", "Unknown")
policy_name = policy_data.get("name", "Unknown")
parent = policy_data.get("parent", None)
# Skip parent policies (logical policies that shouldn't have devices)
if parent == "global-policy-settings":
logger.debug(f"Skipping parent policy: {policy_name}")
continue
self._filtered_policies.append(policy_data)
self._displayed_policies.append(policy_data) # Add to displayed list
table.add_row(
policy_name,
key=policy_id,
)
def on_button_pressed(self, event: Button.Pressed):
"""
Handle button press events from the widget.
Routes to:
- filter_button (Apply Filter): Filter policies with wildcard support
- clear_filter_button: Clear filter and show all policies
- confirm_button: Confirm selection and post message
Args:
event (Button.Pressed): The button press event.
"""
btn_id = event.button.id
if btn_id == "filter_button":
self._apply_filter()
event.stop()
elif btn_id == "clear_filter_button":
self._clear_filter()
event.stop()
elif btn_id == "confirm_button":
self._confirm_selection()
event.stop()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""
Handle row selection in the policy table.
Updates the selected_policy and displays the selection in the UI.
Args:
event: DataTable.RowSelected event containing the selected row data.
"""
try:
# Get the row key from the event
row_key = event.row_key
if row_key is None:
return
# Find the policy with matching groupid
for policy_data in self._displayed_policies:
if isinstance(policy_data, Policy):
if policy_data.groupid == row_key.value:
self.selected_policy = policy_data
break
else:
if policy_data.get("groupid") == row_key.value:
self.selected_policy = Policy(
groupid=policy_data.get("groupid"),
hidden=policy_data.get("hidden", False),
name=policy_data.get("name"),
parent=policy_data.get("parent"),
)
break
if self.selected_policy:
# Update selection display
label = self.query_one("#selected_policy_label", Static)
label.update(f"Selected: {self.selected_policy.name}")
# Log for debugging
logger.debug(
f"Selected policy: {self.selected_policy.name} (ID: {self.selected_policy.groupid})"
)
self.app.notify(
f"Selected: {self.selected_policy.name}",
severity="information",
timeout=1,
)
except Exception as e:
logger.error(f"Error handling row selection: {e}")
self.app.notify(f"Selection error: {str(e)}", severity="error")
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""
Handle row highlighting (cursor movement) in the table.
This provides immediate visual feedback when navigating rows.
"""
try:
# Get the row key from the event
row_key = event.row_key
if row_key is None:
return
# Find the highlighted policy
highlighted_name = None
for policy_data in self._displayed_policies:
if isinstance(policy_data, Policy):
if policy_data.groupid == row_key.value:
highlighted_name = policy_data.name
break
else:
if policy_data.get("groupid") == row_key.value:
highlighted_name = policy_data.get("name")
break
if highlighted_name:
label = self.query_one("#selected_policy_label", Static)
label.update(f"Highlighting: {highlighted_name}")
except Exception as e:
logger.error(f"Error handling row highlight: {e}")
def _apply_filter(self) -> None:
"""
Apply filter text to policy list with wildcard support.
Supports wildcards:
- * matches any sequence of characters
- ? matches a single character
Examples:
- "policy*" matches "policy_prod", "policy_dev", etc.
- "policy?" matches "policy1", "policy2", etc.
- "*audit*" matches anything containing "audit"
- "*test*" matches "AT Testing", "test_policy", etc.
Filters policies by name or ID (case-insensitive) and refreshes the table display
with only matching policies. Only filters from already-filtered list
(which excludes parent policies and excluded IDs).
"""
try:
filter_input = self.query_one("#policy_filter", TextArea)
filter_text = filter_input.text.strip()
table = self.query_one("#policy_table", DataTable)
table.clear()
# Clear the displayed policies list
self._displayed_policies = []
# Compile wildcard pattern if filter text is provided
pattern = None
if filter_text:
# Escape special regex chars but preserve wildcards
pattern_text = re.escape(filter_text.lower())
pattern_text = pattern_text.replace(r"\*", ".*").replace(r"\?", ".")
# Use search() for partial matching
pattern = re.compile(pattern_text, re.IGNORECASE)
# Filter policies based on search text
for policy_data in self._filtered_policies:
# Handle both Policy objects and dict/DataFrame rows
if isinstance(policy_data, Policy):
policy_name = policy_data.name.lower()
policy_id = policy_data.groupid.lower()
display_name = policy_data.name
key_id = policy_data.groupid
else:
policy_name = str(policy_data.get("name", "")).lower()
policy_id = str(policy_data.get("groupid", "Unknown")).lower()
display_name = policy_data.get("name")
key_id = policy_data.get("groupid")
# Match against filter text with wildcard support
if pattern:
# Use search() for partial matching
matches = pattern.search(policy_name) or pattern.search(policy_id)
else:
matches = True
if matches:
# Add to displayed policies list
self._displayed_policies.append(policy_data)
# Add row to table
table.add_row(
display_name,
key=key_id,
)
displayed_count = len(self._displayed_policies)
status_text = (
f"Showing {displayed_count} of {len(self._filtered_policies)} policies"
)
self.app.notify(status_text, severity="information", timeout=2)
# Clear selection when filter is applied
self.selected_policy = None
label = self.query_one("#selected_policy_label", Static)
label.update("")
except Exception as e:
logger.error(f"Error applying filter: {e}")
self.app.notify(f"❌ Filter error: {str(e)}", severity="error")
def _clear_filter(self) -> None:
"""
Clear the filter and display all available policies.
Resets the filter text and refreshes the table to show all policies
(already excluding parent policies and excluded IDs).
"""
try:
filter_input = self.query_one("#policy_filter", TextArea)
filter_input.text = ""
table = self.query_one("#policy_table", DataTable)
table.clear()
# Reset displayed policies to all filtered policies
self._displayed_policies = list(self._filtered_policies)
# Reload all policies
for policy_data in self._filtered_policies:
if isinstance(policy_data, Policy):
policy_id = policy_data.groupid
policy_name = policy_data.name
else:
policy_id = policy_data.get("groupid", "Unknown")
policy_name = policy_data.get("name", "Unknown")
# Add row with only policy name
table.add_row(
policy_name,
key=policy_id,
)
self.selected_policy = None
label = self.query_one("#selected_policy_label", Static)
label.update("")
except Exception as e:
logger.error(f"Error clearing filter: {e}")
def on_text_area_changed(self, event) -> None:
"""
Handle TextArea change events - specifically for Enter key in filter.
When the user types in the filter TextArea and the text ends with a newline,
treat it as pressing Enter and apply the filter.
"""
if event.text_area.id == "policy_filter":
# Check if the text ends with a newline (Enter was pressed)
if event.text_area.text.endswith("\n"):
# Remove the newline that was added
event.text_area.text = event.text_area.text.rstrip("\n")
# Apply the filter
self._apply_filter()
def _confirm_selection(self) -> None:
"""
Confirm the selected policy and post selection message.
Posts a PolicySelected message to the parent widget/screen with the
selected policy. If no policy is selected, displays an error notification.
"""
if self.selected_policy is None:
self.app.notify(
"Please select a policy first by clicking on a row in the table",
severity="warning",
timeout=3,
)
return
# Log confirmation for debugging
logger.info(f"Confirming selection of policy: {self.selected_policy.name}")
self.app.notify(
f"Confirmed: {self.selected_policy.name}", severity="success", timeout=2
)
self.post_message(self.PolicySelected(self.selected_policy))
+289
View File
@@ -0,0 +1,289 @@
# Copyright (C) 2025 James Brotosky, Brandon Wickline
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from collections import defaultdict
import logging
from rich.text import Text
from textual.containers import Horizontal, Vertical
from textual.widget import Widget
from textual.widgets import Input, OptionList, Static, Switch, Tree
from textual.widgets.option_list import Option
logger = logging.getLogger(__name__)
class PolicyTreeWidget(Widget):
"""Widget for displaying and searching a hierarchical policy tree."""
def __init__(self, policies, devices):
super().__init__()
self.policies = policies
self.devices = devices
self.last_highlighted_node = None
self.leaf_counts = defaultdict(int)
self.match_type = "Count" # Default to sorting by count
def compose(self):
# Create the switch and its label
switch = Switch(value=False, id="match_switch")
switch.styles.margin = (0, 0, 0, 0) # top, right, bottom, left
switch.styles.padding = (0, 0, 0, 0)
switch_label = Static("Sort: Count", id="match_switch_label")
switch_label.styles.margin = (1, 0, 0, 0)
switch_label.styles.padding = (0, 0, 0, 0)
# Create the tree
policy_tree = Tree("", id="policy_tree") # Label set in on_mount
policy_tree.styles.width = "2fr"
policy_tree.styles.height = "100%"
# Create the search box and details pane
label = Static("Device Search:")
search_box = Input(
placeholder="Search policies or devices...", id="tree_search"
)
details_pane = Static("", id="details_pane")
# Layout the UI
with Horizontal():
yield policy_tree
with Vertical() as right_pane:
right_pane.styles.width = "3fr"
# Use a Horizontal container for the switch and label
with Horizontal() as switch_container:
switch_container.styles.height = 3
switch_container.styles.margin = (0, 0, 0, 1)
switch_container.styles.padding = (0, 0, 0, 0)
yield switch
yield switch_label
# Add the search box and details pane
yield label
yield search_box
yield details_pane
def on_mount(self) -> None:
self._precompute_leaf_counts()
# Update root label with total leaf count
total_leaves = sum(
self.leaf_counts.get(policy.groupid, 0)
for policy in self.policies
if policy.parent == "global-policy-settings"
)
policy_tree = self.query_one("#policy_tree", Tree)
policy_tree.root.set_label(f"Agents in Policies: ({total_leaves})")
self._build_tree()
# Expand the root node
policy_tree.root.expand()
def _precompute_leaf_counts(self):
"""Precompute leaf counts for each policy group."""
device_counts = defaultdict(int)
for device in self.devices:
device_counts[device.groupid] += 1
child_map = defaultdict(list)
for policy in self.policies:
child_map[policy.parent].append(policy.groupid)
def count_leaves(groupid):
count = device_counts[groupid]
for child_id in child_map.get(groupid, []):
count += count_leaves(child_id)
self.leaf_counts[groupid] = count
return count
for policy in self.policies:
if policy.parent == "global-policy-settings":
count_leaves(policy.groupid)
def _build_tree(self):
policy_tree = self.query_one("#policy_tree", Tree)
policy_tree.clear() # Clear existing nodes
node_map = {}
# Sort top-level policies
top_policies = [
p for p in self.policies if p.parent == "global-policy-settings"
]
# Sort by count (default) or alphabetically
if getattr(self, "match_type", "Count") == "Count":
top_policies.sort(
key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True
)
else: # Alphabetical
top_policies.sort(key=lambda p: p.name.lower())
for policy in top_policies:
label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})"
node = policy_tree.root.add(label=label, data=policy)
node_map[policy.groupid] = node
# Sort and add child policies
children_by_parent = defaultdict(list)
for policy in self.policies:
if policy.parent != "global-policy-settings":
children_by_parent[policy.parent].append(policy)
for parent_id, children in children_by_parent.items():
if getattr(self, "match_type", "Count") == "Count":
children.sort(
key=lambda p: self.leaf_counts.get(p.groupid, 0), reverse=True
)
else: # Alphabetical
children.sort(key=lambda p: p.name.lower())
parent_node = node_map.get(parent_id)
if parent_node:
for policy in children:
label = f"{policy.name} ({self.leaf_counts.get(policy.groupid, 0)})"
node = parent_node.add(label=label, data=policy)
node_map[policy.groupid] = node
# Add devices (leaf nodes) - always sort alphabetically
devices_by_group = defaultdict(list)
for device in self.devices:
devices_by_group[device.groupid].append(device)
for group_id, devices in devices_by_group.items():
devices.sort(key=lambda d: d.hostname.lower()) # Always sort alphabetically
parent_node = node_map.get(group_id)
if parent_node:
for device in devices:
parent_node.add(label=device.hostname, data=device)
def _collect_tree_nodes(self, node, all_nodes):
all_nodes.append(node)
for child in node.children:
self._collect_tree_nodes(child, all_nodes)
def _remove_match_selector(self):
try:
existing = self.query("#match_selector")
for widget in existing:
if widget.is_attached:
widget.remove()
except Exception as exc:
logger.debug("Failed to remove match_selector: %s", exc)
def on_tree_node_selected(self, message: Tree.NodeSelected) -> None:
node = message.node
data = node.data
details_pane = self.query_one("#details_pane", Static)
if self.last_highlighted_node is not None:
original_label = str(self.last_highlighted_node.label).strip()
if isinstance(self.last_highlighted_node.label, Text):
original_label = self.last_highlighted_node.label.plain
self.last_highlighted_node.set_label(original_label)
label_text = str(node.label).strip()
if isinstance(node.label, Text):
label_text = node.label.plain
highlighted_label = Text(label_text, style="reverse bold")
node.set_label(highlighted_label)
self.last_highlighted_node = node
if data:
details = "\n".join(
f"{key}: {value}" for key, value in data.__dict__.items()
)
else:
details = f"Selected: {node.label}"
details_pane.update(details)
message.stop()
def on_switch_changed(self, event: Switch.Changed):
self.match_type = "Alpha" if event.value else "Count"
self.query_one("#match_switch_label", Static).update(
f"Sort: {self.match_type.capitalize()}"
)
self._build_tree()
def on_input_submitted(self, message: Input.Submitted) -> None:
self._remove_match_selector()
query = message.value.strip().lower()
tree = self.query_one("#policy_tree", Tree)
details_pane = self.query_one("#details_pane", Static)
all_nodes = []
self._collect_tree_nodes(tree.root, all_nodes)
label_to_node = {}
for node in all_nodes:
label_text = str(node.label).lower()
label_to_node[label_text] = node
if node.data:
data_dict = (
node.data.__dict__ if hasattr(node.data, "__dict__") else node.data
)
for key, value in data_dict.items():
if isinstance(value, str):
label_to_node[value.lower()] = node
matches = sorted([label for label in label_to_node if query in label])
if matches:
try:
option_list = self.query_one("#match_selector", OptionList)
option_list.clear_options()
option_list.display = True
except:
option_list = OptionList(id="match_selector")
details_pane.parent.mount(option_list)
for label in matches:
option_list.add_option(Option(label, id=f"match_{label}"))
details_pane.update(f"Found {len(matches)} matches. Select one below.")
else:
self._remove_match_selector()
details_pane.update("No matches found.")
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
selected_id = event.option.id.replace("match_", "")
tree = self.query_one("#policy_tree", Tree)
details_pane = self.query_one("#details_pane", Static)
all_nodes = []
self._collect_tree_nodes(tree.root, all_nodes)
label_to_node = {str(node.label).lower(): node for node in all_nodes}
match_node = label_to_node.get(selected_id.lower())
if match_node:
node = match_node
path = []
while node:
path.insert(0, node)
node = node.parent
for node in path:
node.expand()
tree.select_node(match_node)
tree.scroll_to_node(match_node)
match_node.set_label(Text(str(match_node.label), style="reverse bold"))
details_pane.update(f"Selected: {match_node.label}")
try:
option_list = self.query_one("#match_selector", OptionList)
option_list.remove()
except:
pass
+870
View File
@@ -0,0 +1,870 @@
# Copyright (C) 2025 James Brotosky, Brandon Wickline
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
import os
import os.path
import re
from typing import List
import dotenv
import pandas as pd
from models.execution import ExecutionHistoryRecord
from models.policy import Allowlist, Policy
from services.API import AirlockAPIWrapper
from utils.configmanager import get_system_list, get_system_value, load_env
from utils.selector import Selector
from utils.utils import (
areYouSure,
clear_screen,
colorText,
formatHTML,
get_sanitized_input,
locked,
open_directory,
print_x_wide,
regulator,
)
logger = logging.getLogger(__name__)
dotenv.load_dotenv()
def selectPolicies(api: AirlockAPIWrapper, allow_multiple=True) -> List[Policy]:
policies = [Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()]
logger.debug("Prompting for Policies")
print(colorText("Please select policy/policies", "white"))
selected = Selector.select_objects(policies, allow_multiple, prompt_each=True)
if selected is None:
return []
# Normalize to always return a list
logger.debug("Returning {selected.dict}")
return selected if isinstance(selected, list) else [selected]
def selectAllowlists(
api: AirlockAPIWrapper, policy=all, allow_multiple=True
) -> List[Allowlist]:
if policy == "all":
allowlists = [
Allowlist(**row.to_dict()) for _, row in api.allowlist_find_all().iterrows()
]
else:
allowlists = [
Allowlist(**row.to_dict())
for _, row in api.policy_list_allowlists(policy[0].groupid).iterrows()
]
logger.debug("Prompting for Allowlist(s)")
print(colorText("Please select allowlist(s)", "white"))
selected = Selector.select_objects(allowlists, allow_multiple, prompt_each=True)
if selected is None:
return []
# Normalize to always return a list
logger.debug(f"Returning {selected}")
return selected if isinstance(selected, list) else [selected]
def sortHashes(
api: AirlockAPIWrapper, selected_policies: List[Policy], type=[1, 2, 6, 7]
):
working_dir = load_env("WORKING_DIR")
history_days = Selector.select_value(
prompt="Enter how many days of history to pull (1150): ",
value_type=int,
valid_range=(1, 150),
)
logger.debug(f"{history_days} day selected for history")
if history_days is None:
logging.warning("No history range selected. Aborting.")
return
policy_executions = ExecutionHistoryRecord.from_policies(
api, selected_policies, type_=type, history_days=history_days
)
logger.debug(f"Executions contains {policy_executions}")
enriched_executions = ExecutionHistoryRecord.enrich_with_hashes(
api, policy_executions
)
categorized_executions = (
ExecutionHistoryRecord.categorize_executions_by_hash_decision(
enriched_executions
)
)
approved, unapproved, needs_review, unknown = (
ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions)
)
categories = {
"needs_review": needs_review,
"approved": approved,
"unapproved": unapproved,
"leftover": unknown,
}
for label, records in categories.items():
if not records:
continue # Skip empty or falsy categories
csv_path = f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_{label}_executions.csv"
html_path = f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{label}.html"
# Convert ExecutionHistoryRecord objects to dictionaries
df = pd.DataFrame([r.__dict__ for r in records])
# Optional: flatten hash_obj if needed
if not df.empty and "hash_obj" in df.columns:
hash_df = df["hash_obj"].apply(lambda h: h.to_dict() if h else {})
df = pd.concat([df.drop(columns=["hash_obj"]), hash_df], axis=1)
# Save to CSV
df.to_csv(csv_path, index=False)
logger.info(f"Saved {label} executions to {csv_path}")
# Generate HTML
formatHTML(df, html_path)
logger.info(f"Generated HTML report at {html_path}")
def buildPathsandPublishers(selected_policies: List[Policy], split):
working_dir = load_env("WORKING_DIR")
df1 = pd.DataFrame()
df2 = pd.DataFrame()
all_approved_hashes = pd.DataFrame()
path1 = (
f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv"
)
path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv"
path_exclusion_constant = get_system_value("PATH_EXCLUSION_CONST", cast_type=int)
if os.path.exists(path1):
df1 = pd.read_csv(path1)
else:
logger.warning(f"File not found: {path1}")
if os.path.exists(path2):
df2 = pd.read_csv(path2)
else:
logger.warning(f"File not found: {path2}")
if df1.empty and df2.empty:
logger.warning("Both DataFrames are empty. Skipping sort.")
all_approved_hashes = pd.DataFrame()
logger.debug(all_approved_hashes.head)
else:
all_approved_hashes = pd.concat([df1, df2], ignore_index=True)
if "filename" in all_approved_hashes.columns:
all_approved_hashes = all_approved_hashes.sort_values(by="filename")
else:
logger.warning(
"Warning: 'filename' column not found in concatenated DataFrame."
)
if not all_approved_hashes.empty and path_exclusion_constant:
primary_path_exclusions = calculatePath(
all_approved_hashes,
path_exclusion_constant,
split,
)
remaining_hashes = all_approved_hashes[
~all_approved_hashes["sha256"].isin(primary_path_exclusions["sha256"])
]
secondary_path_exclusions = calculatePath(
remaining_hashes, (path_exclusion_constant - 1), split
)
remaining_hashes = remaining_hashes[
~remaining_hashes["sha256"].isin(secondary_path_exclusions["sha256"])
]
dataframes = {
"all_approved_hashes": all_approved_hashes,
"primary_Paths": primary_path_exclusions,
"secondary_Paths": secondary_path_exclusions,
"hashes_not_approvable_by_path": remaining_hashes,
}
logger.debug("Preparing to sort dataframes")
for name, df in dataframes.items():
logger.debug(f" DataFrame headers: {list(df.columns)}")
if "hashes" in name:
df.sort_values(by="filename", inplace=True)
else:
df.sort_values(by="longestcfp", inplace=True)
df.to_csv(
f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_{name}.csv",
index=False,
)
formatHTML(
df,
f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{name}.html",
)
if not all_approved_hashes.empty:
# Drop all not signed, only keep unique values
publist = all_approved_hashes[
all_approved_hashes["publisher"] != "Not Signed"
].drop_duplicates(subset=["publisher"])
# Remove Bad publisher if somehow they made it this far
pattern = regulator(get_system_list("BAD_PUBLISHERS"))
publist = publist[~publist["publisher"].str.contains(pattern, na=False)]
publist = publist[["publisher"]]
publist.sort_values(by="publisher", inplace=True)
publist.to_csv(
f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_publishers.csv",
index=False,
)
else:
logger.debug("Approved Hashes list appears empty")
def buildPreflights(selected_policies: List[Policy]):
working_dir = load_env("WORKING_DIR")
df1 = pd.DataFrame()
df2 = pd.DataFrame()
approved_hashes = pd.DataFrame()
approved_publishers = pd.DataFrame()
hash = f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_all_approved_hashes.csv"
path1 = f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv"
path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_secondary_Paths.csv"
publishers = f"{working_dir}\\Approved\\{selected_policies[0].name}_publishers.csv"
# Read in and combine the two path generations
if os.path.exists(path1):
df1 = pd.read_csv(path1)
else:
logger.warning(f"File not found: {path1}")
if os.path.exists(path2):
df2 = pd.read_csv(path2)
else:
logger.warning(f"File not found: {path2}")
if df1.empty and df2.empty:
logger.warning("Both DataFrames are empty. Skipping sort.")
approved_paths = pd.DataFrame()
else:
approved_paths = pd.concat([df1, df2], ignore_index=True)
approved_paths = approved_paths.drop_duplicates(subset="longestcfp", keep="first")
# We create a list of hashes that are left over if we exclude the ones that are covered by the path exclusions.
if os.path.exists(hash):
hashes = pd.read_csv(hash)
approved_hashes = hashes[~hashes["filename"].isin(approved_paths["longestcfp"])]
approved_hashes = approved_hashes.drop_duplicates(subset="sha256", keep="first")
else:
logger.warning(f"File not found: {hash}")
if os.path.exists(publishers):
approved_publishers = pd.read_csv(publishers)
else:
logger.warning(f"File not found: {publishers}")
dataframes = {
"approved_paths": approved_paths,
"approved_hashes": approved_hashes,
"approved_publishers": approved_publishers,
}
for name, df in dataframes.items():
logger.debug(f" DataFrame headers: {list(df.columns)}")
if name == "approved_paths":
df.sort_values(by="longestcfp", inplace=True)
elif name == "approved_hashes":
df.sort_values(by="filename", inplace=True)
elif name == "approved_publishers":
df.sort_values(by="publisher", inplace=True)
df.to_csv(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_{name}.csv",
index=False,
)
formatHTML(
df,
f"{working_dir}\\Preflight\\HTML\\{selected_policies[0].name}_{name}.html",
)
def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"):
min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int)
def clean_split(path):
if not isinstance(path, (str, bytes, os.PathLike)):
return []
parts = str(os.path.normpath(path)).split(os.sep)
parts = [p for p in parts if p] # Remove empty strings
return parts
# Diagnostic: log any non-string entries
non_string_entries = df[
~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike)))
]
if not non_string_entries.empty:
print(f"[WARNING] Non-string entries found in column '{col}':")
print(non_string_entries)
df = df.copy()
split_paths = df[col].apply(clean_split)
if min_files_for_path is not None:
df = df[
split_paths.apply(lambda parts: len(parts) >= min_files_for_path)
].copy()
split_paths = split_paths[df.index]
df["group_key"] = split_paths.apply(
lambda parts: os.sep.join(parts[:path_exclusion_constant])
)
grouped = df.groupby("group_key")
new_rows = []
for _, group_df in grouped:
paths = group_df[col].tolist()
split_parts = [clean_split(p) for p in paths]
def longest_common_prefix(paths):
if not paths:
return []
prefix = paths[0]
for path in paths[1:]:
prefix = [a for a, b in zip(prefix, path) if a == b]
if not prefix:
break
return prefix
common_prefix = longest_common_prefix(split_parts)
prefix_str = os.sep.join(common_prefix)
for i, parts in enumerate(split_parts):
filename = parts[-1]
middle = (
os.sep.join(parts[len(common_prefix) : -1])
if len(parts) > len(common_prefix) + 1
else ""
)
row = group_df.iloc[i].copy()
row["longestcfp"] = prefix_str
row["middle"] = middle
row["filename_only"] = filename
row["file_extension"] = os.path.splitext(filename)[1].lower()
new_rows.append(row)
return pd.DataFrame(new_rows).drop(columns=["group_key"])
def calculatePath(approved_hashes, path_exclusion_constant, split):
if split:
dfs_by_policy = [group for _, group in approved_hashes.groupby("policy")]
else:
dfs_by_policy = [approved_hashes]
badpathparts = get_system_list("BAD_PATH_PARTS")
min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int)
processed_dfs = []
for df in dfs_by_policy:
haslcp = splitFilepathsGrouped(df, path_exclusion_constant, "filename")
haslcp = haslcp.drop_duplicates()
forbidden = regulator(badpathparts, True)
forbidden_lcfp = haslcp["longestcfp"].str.contains(forbidden, na=False)
logger.debug("Removing forbidden filepaths for path exceptions")
print(colorText("Removing forbidden filepaths for path exceptions", "green"))
lcp_not_forbidden = haslcp[~forbidden_lcfp].copy()
lcp_not_forbidden_review = lcp_not_forbidden[
[
"policyname",
"longestcfp",
"middle",
"filename_only",
"file_extension",
"sha256",
]
]
unique_sha_counts = (
lcp_not_forbidden_review.groupby("longestcfp")["sha256"]
.nunique()
.reset_index()
)
unique_sha_counts.columns = ["longestcfp", "unique_sha256_count"]
lcp_not_forbidden_review = lcp_not_forbidden_review.merge(
unique_sha_counts, on="longestcfp", how="left"
)
lcp_not_forbidden_review = lcp_not_forbidden_review[
lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path
]
processed_dfs.append(lcp_not_forbidden_review)
pathExclusions = pd.concat(processed_dfs, ignore_index=True)
return pathExclusions
def testChange(selected_policies, destination_policy, destination_allowlist):
working_dir = load_env("WORKING_DIR")
logger.info("These path exclusions would be added to:")
logger.info(destination_policy)
pathexclusions = pd.read_csv(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
)
hashes = pd.read_csv(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
)
unique_combinations = pathexclusions[
["longestcfp", "file_extension"]
].drop_duplicates()
drive_letter_pattern = re.compile(r"^[a-zA-Z]:\\")
processed_paths = [
(path if drive_letter_pattern.match(path) else f"\\\\{path}") + f"\\**{ext}"
for path, ext in unique_combinations.itertuples(index=False, name=None)
]
for path in processed_paths:
logger.info(path)
print(colorText("These publishers would added", "yellow"))
processed_publishers = []
if os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"
):
publishers = pd.read_csv(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"
)
if publishers.empty:
print(colorText("The publishers list is empty.", "red"))
else:
processed_publishers = (
publishers[publishers["publisher"] != "Not Signed"]["publisher"]
.drop_duplicates()
.tolist()
)
for publisher in processed_publishers:
print(publisher)
print(colorText("These hashes would be added to:", "yellow"))
print(destination_allowlist)
processed_hashes = hashes["sha256"].unique().tolist()
print_x_wide(processed_hashes, 3)
return processed_paths, processed_hashes, processed_publishers
def menu_policy_enforce(
api: AirlockAPIWrapper,
): # TODO Need to clean up 6 and 7 into functions
selected_policies = []
destination_policy = []
destination_allowlist = []
processed_paths = []
processed_hashes = []
processed_publishers = []
working_dir = load_env("WORKING_DIR")
while True:
printEnforceChecklist(
selected_policies, destination_policy, destination_allowlist
)
choice = get_sanitized_input("\nEnter your choice: ")
if choice == "1":
clear_screen()
selected_policies = selectPolicies(api, True)
elif choice == "2":
clear_screen()
print(
colorText(
"Please choose destination_name Policy for Path Exclusions", "white"
)
)
destination_policy = selectPolicies(api, False)
print(colorText("Please choose Allowlist for Hashes", "white"))
destination_allowlist = selectAllowlists(api, destination_policy, False)
elif choice == "3":
clear_screen()
sortHashes(
api,
selected_policies,
type=[1, 2, 6, 7],
)
elif choice == "4":
clear_screen()
if os.path.exists(
f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_approved_executions.csv"
):
buildPathsandPublishers(selected_policies, False)
else:
print(
"File not found. Please make sure it's saved correctly and try again."
)
elif choice == "5":
clear_screen()
if os.path.exists(
f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv"
) and os.path.exists(
f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv"
):
buildPreflights(selected_policies)
else:
print(
"File not found. Please make sure it's saved correctly and try again."
)
elif choice == "6":
clear_screen()
if (
os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
)
and os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
)
and destination_policy
and destination_allowlist
):
processed_paths, processed_hashes, processed_publishers = testChange(
selected_policies, destination_policy, destination_allowlist
)
else:
# Log which condition(s) failed
missing_items = []
if not os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
):
missing_items.append("approved_paths.csv not found")
if not os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
):
missing_items.append("approved_hashes.csv not found")
if not destination_policy:
missing_items.append("destination_policy is empty or None")
if not destination_allowlist:
missing_items.append("destination_allowlist is empty or None")
logger.error("Preflight check failed due to the following:")
for item in missing_items:
logger.error(f" - {item}")
elif choice == "7":
clear_screen()
areYouSure()
confirmation = get_sanitized_input("Type 'I AGREE' to continue: ")
if (
processed_paths
and processed_hashes
and processed_publishers
and destination_policy
and destination_allowlist
and confirmation.strip() == "I AGREE"
):
print(colorText("Proceeding with the code...", "yellow"))
api.hash_add_to_allowlist(
destination_allowlist[0].applicationid, processed_hashes
)
api.policy_add_path_exclusions(
destination_policy[0].groupid, processed_paths
)
if processed_publishers:
api.policy_add_publishers(
destination_policy[0].groupid, processed_publishers
)
locked()
else:
logger.error("Confirmation block failed. Reasons:")
if not processed_publishers or processed_hashes or processed_paths:
logger.error(" - Test not performed.")
if not destination_policy:
logger.error(" - `destination_policy` is missing or invalid.")
if not destination_allowlist:
logger.error(" - `destination_allowlist` is missing or invalid.")
if confirmation.strip() != "I AGREE":
logger.error(
" - User did not confirm with 'I AGREE'. Received: '%s'",
confirmation.strip(),
)
elif choice.upper() == "F":
open_directory(working_dir)
elif choice.upper() == "B":
break
else:
print(colorText("Invalid choice. Please try again.", "red"))
def section_header(title):
print(
colorText(
"\n --------------------------------------------------------------------",
"cyan",
)
)
print(colorText(f" ------------- {title} -------------", "cyan"))
print(
colorText(
" --------------------------------------------------------------------",
"cyan",
)
)
def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist):
working_dir = load_env("WORKING_DIR")
section_header("Prepare to Enforce Policy ")
print(
colorText(
"\nSequentially follow these steps to prepare a policy for enforcement:",
"white",
)
)
# Step 1: Originating Policies
print(
colorText(
"\n1. Choose which policy or policies to gather execution info from", "cyan"
)
)
if not selected_policies:
print(colorText(" [✗] No policies have been chosen", "red"))
else:
print(colorText("The following policies have been chosen:", "green"))
for policy in selected_policies:
print(colorText(f" [✓] {policy.name}", "green"))
# Step 2: Destination Policy and Allowlist
print(
colorText("2. Choose the destination policy and associated allowlist", "cyan")
)
if destination_policy:
print(
colorText(
f" [✓] {destination_policy[0].name} has been selected as the destination policy",
"green",
)
)
else:
print(colorText(" [✗] No destination policy has been chosen", "red"))
if destination_allowlist:
print(
colorText(
f" [✓] {destination_allowlist[0].name} has been selected as allowlist",
"green",
)
)
else:
print(colorText(" [✗] No allowlist has been chosen", "red"))
# Step 3: Data Preparation
print(
colorText(
f"3. Select to begin pulling execution history. The executions will be sorted and placed in {working_dir}\\data\\Needs_Review",
"cyan",
)
)
if selected_policies:
policy_id = selected_policies[0].name
review_path = f"{working_dir}\\Needs_Review\\Review_First\\{policy_id}_approved_executions.csv"
print(
colorText(
(
" [✓] Data has been fetched"
if os.path.exists(review_path)
else " [✗] Data has not been fetched"
),
"green" if os.path.exists(review_path) else "red",
)
)
else:
print(
colorText(
" [✗] No policies selected, cannot check data fetch status", "red"
)
)
# Step 4: Manual Review
print(colorText("4. Manually review the files:", "cyan"))
print(
colorText(
" Remove the rows containing hashes you do not approve of", "cyan"
)
)
print(
colorText(
f" When complete, save both csv files to {working_dir}\\data\\Approved and choose this option.",
"cyan",
)
)
print(
colorText(
" This will start the process to generate possible filepath approvals",
"cyan",
)
)
if selected_policies:
policy_id = selected_policies[0].name
approved_path = f"{working_dir}\\Approved\\{policy_id}_approved_executions.csv"
second_review_path = (
f"{working_dir}\\Needs_Review\\Review_Second\\{policy_id}_primary_Paths.csv"
)
print(
colorText(
(
" [✓] Reviewed hashes have been loaded"
if os.path.exists(approved_path)
else " [✗] Reviewed hashes have not been loaded"
),
"green" if os.path.exists(approved_path) else "red",
)
)
print(
colorText(
(
" [✓] Path review list created"
if os.path.exists(second_review_path)
else " [✗] Path review list has not been created"
),
"green" if os.path.exists(second_review_path) else "red",
)
)
else:
print(
colorText(
" [✗] No policies selected, cannot check reviewed hashes or path list",
"red",
)
)
# Step 5: Path Review
print(
colorText(
f"5. Manually review the files in {working_dir}\\Needs_Review\\Review_Second\\",
"cyan",
)
)
print(
colorText(
" Remove the rows containing path exclusions or publishers you do not approve of.",
"cyan",
)
)
print(
colorText(
f" When complete, save the files to {working_dir}\\data\\Approved",
"cyan",
)
)
print(
colorText(" Choose this option when done to build your preflights", "cyan")
)
if selected_policies:
policy_id = selected_policies[0].name
reviewed_path = f"{working_dir}\\Approved\\{policy_id}_primary_Paths.csv"
preflight_paths = f"{working_dir}\\Preflight\\{policy_id}_approved_paths.csv"
preflight_hashes = f"{working_dir}\\Preflight\\{policy_id}_approved_hashes.csv"
print(
colorText(
(
" [✓] Reviewed path list detected"
if os.path.exists(reviewed_path)
else " [✗] Path review list has not been detected"
),
"green" if os.path.exists(reviewed_path) else "red",
)
)
preflight_ready = os.path.exists(preflight_paths) and os.path.exists(
preflight_hashes
)
print(
colorText(
(
" [✓] Preflight Path Exclusion List has been generated"
if preflight_ready
else " [✗] Preflight Path Exclusion List has not been generated"
),
"green" if preflight_ready else "red",
)
)
else:
print(
colorText(
" [✗] No policies selected, cannot check preflight status", "red"
)
)
# Final Steps
print(
colorText(
"6. Test ------------------------------------------------------", "cyan"
)
)
print(
colorText(
" Prints to console the changes that would be made, must be done to proceed. ",
"cyan",
)
)
print(
colorText(
"7. Liftoff ------------------------------------------------------", "cyan"
)
)
print(
colorText(
" Apply path exclusions and approved publishers to selected policy",
"cyan",
)
)
print(colorText(" Apply approved hashes to allowlist", "cyan"))
# Utility Options
print(colorText("F. Open Working Directory", "cyan"))
print(colorText("B. Back", "cyan"))
+176
View File
@@ -0,0 +1,176 @@
# Copyright (C) 2025 James Brotosky, Brandon Wickline
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import logging
from textual.containers import Horizontal, Vertical
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Button, Footer, Header, Static
logger = logging.getLogger(__name__)
class ResultsDisplay(Widget):
"""Widget for displaying operation results in a two-column layout."""
CSS = """
ResultsDisplay {
height: 100%;
}
#results_screen {
height: 100%;
}
#results_title {
text-align: center;
margin: 1 0;
text-style: bold;
}
#results_layout {
height: 1fr;
margin: 1 0;
}
#left_column, #right_column {
width: 1fr;
height: 100%;
border: solid green;
padding: 1;
}
#right_column {
border: solid red;
}
#success_label, #failure_label {
text-style: bold;
margin-bottom: 1;
}
#success_results, #failure_results {
height: 1fr;
overflow-y: auto;
background: $surface;
border: round $primary;
padding: 1;
}
.copy_button {
margin-top: 1;
width: 100%;
}
"""
class CopySuccess(Message):
"""Posted when success results are copied."""
pass
class CopyFailure(Message):
"""Posted when failure results are copied."""
pass
class GoBack(Message):
"""Posted when back button is pressed."""
pass
def __init__(
self, operation: str, successful_results: str, unsuccessful_results: str
) -> None:
super().__init__()
self.operation = operation
self.successful_results = successful_results
self.unsuccessful_results = unsuccessful_results
def compose(self):
with Vertical(id="results_screen"):
yield Header(show_clock=True, icon="⚙️")
# Title
title = Static(f"{self.operation} - Results", id="results_title")
yield title
# Two-column layout
with Horizontal(id="results_layout"):
# Left Column - Success
with Vertical(id="left_column"):
yield Static("✅ Successful", id="success_label")
yield Static(self.successful_results, id="success_results")
yield Button(
"Copy Success List",
id="copy_success",
classes="copy_button",
)
# Right Column - Failure
with Vertical(id="right_column"):
yield Static("❌ Failed", id="failure_label")
yield Static(self.unsuccessful_results, id="failure_results")
yield Button(
"Copy Failure List",
id="copy_failure",
classes="copy_button",
)
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id
if btn_id == "copy_success":
success_widget = self.query_one("#success_results", Static)
try:
import pyperclip
pyperclip.copy(str(success_widget.renderable))
self.app.notify(
"Success list copied to clipboard!",
severity="information",
timeout=2,
)
self.post_message(self.CopySuccess())
except ImportError:
self.app.notify(
"❌ pyperclip not installed. Run: pip install pyperclip",
severity="warning",
)
except Exception as e:
self.app.notify(f"¢ Failed to copy: {str(e)}", severity="error")
event.stop()
elif btn_id == "copy_failure":
failure_widget = self.query_one("#failure_results", Static)
try:
import pyperclip
pyperclip.copy(str(failure_widget.renderable))
self.app.notify(
"Failure list copied to clipboard!",
severity="information",
timeout=2,
)
self.post_message(self.CopyFailure())
except ImportError:
self.app.notify(
"❌ pyperclip not installed. Run: pip install pyperclip",
severity="warning",
)
except Exception as e:
self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error")
event.stop()