diff --git a/Loxide.py b/Loxide.py index fae9cc8..46f97af 100644 --- a/Loxide.py +++ b/Loxide.py @@ -23,24 +23,556 @@ import logging import os +from typing import Optional +import dotenv +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.message import Message +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import ( + Button, + DirectoryTree, + Footer, + Header, + Static, + Tab, + Tabs, +) import urllib3 +from models.agent import Agent +from models.policy import Policy from services.API import AirlockAPIWrapper from services.security import getAPI -from TUI.TUI import run_Loxide -from utils.configmanager import get_system_value +from TUI.Screens.executionhistoryscreen import ExecutionHistoryScreen +from TUI.Screens.moveagentworkflowscreen import MoveAgentWorkflowScreen +from TUI.Screens.otpactivityscreen import OTPActivitiesScreen +from TUI.Screens.otprevokescreen import OTPRevokeScreen +from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen +from TUI.Screens.policyprepworkflowscreen import PolicyPrepWorkflowScreen +from TUI.Screens.quietagentworkflowscreen import QuietAgentWorkflowScreen +from TUI.Themes.theme_amber_terminal import get_amber_terminal_theme +from TUI.Themes.theme_retro_terminal import get_retro_terminal_theme +from TUI.Themes.themeselector import ThemeSelector +from TUI.Widgets.agentmoveoperations import AgentMoveOperations +from TUI.Widgets.multiagentselector import MultiAgentSelector +from TUI.Widgets.policytreewidget import PolicyTreeWidget +from TUI.Widgets.resultsdisplay import ResultsDisplay +from TUI.Widgets.serverlogwidget import ServerLogWidget +from utils.configmanager import ( + get_system_value, + get_user_value, + load_env, + save_user_config, +) from utils.setup import get_base_directory, setup -from utils.utils import irtang +from utils.utils import irtang, open_directory +dotenv.load_dotenv() urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +# --------------------------------------------------------------------------- +# GLOBAL STASH +# --------------------------------------------------------------------------- +_APP_RESTART_REASON = None + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# helper to persist TEXTUAL_THEME to *user* config and mirror to .env +# --------------------------------------------------------------------------- +def _persist_user_theme(theme_name: str) -> None: + """ + Store the chosen Textual theme in the user's config using the config manager. + No need to touch .env - config manager handles everything. + """ + base_dir = get_base_directory() + config_dir = base_dir / "config" + + try: + save_user_config(config_dir, {"TEXTUAL_THEME": theme_name}) + logger.debug("Updated user config with TEXTUAL_THEME=%s", theme_name) + except Exception as exc: + logger.error("Failed to save TEXTUAL_THEME: %s", exc) + + +# --------------------------------------------------------------------------- +# 1) SCREEN +# --------------------------------------------------------------------------- +class MainMenuScreen(Screen): + api: AirlockAPIWrapper + current_tab = reactive("") + + BUTTON_DEFS = { + "agent_actions": [ + { + "label": "🖥️ - Multi-Agent Operations", + "id": "move_agent_workflow_button", + "description": "Select agents to: Move policies, Generate OTPs, Toggle audit/enforcement, View history, Export data", + }, + { + "label": "🎫 - Review and approve OTP Activities", + "id": "otp_activities_button", + }, + { + "label": "🛑 - Revoke Active OTP Session", + "id": "otp_revoke_button", + }, + ], + "policy": [ + { + "label": "⚖️ - Prepare Policy For Enforcement", + "id": "policy_prep_button", + }, + { + "label": "🔕 - Find and Move Quiet Hosts to Enforcement", + "id": "find_quiet_button", + }, + ], + } + + def __init__(self) -> None: + super().__init__() + self.extras = get_user_value("EXTRAS", str, "NOTTODAY") + wd = load_env("WORKING_DIR") or os.getcwd() + if not os.path.isdir(wd): + wd = os.getcwd() + self.working_dir = wd + + def _make_buttons_for(self, tab_id: str) -> Vertical: + defs = self.BUTTON_DEFS.get(tab_id, []) + widgets = [] + for item in defs: + # Support both old tuple format and new dict format + if isinstance(item, dict): + label = item["label"] + btn_id = item["id"] + description = item.get("description") + else: + # Old tuple format: (label, id) + label, btn_id = item + description = None + + btn = Button(label, id=btn_id) + btn.styles.width = "100%" + widgets.append(btn) + + # Add description text if provided + if description: + desc_text = Static(description, classes="button_description") + desc_text.styles.width = "100%" + desc_text.styles.color = "ansi_bright_black" + desc_text.styles.text_align = "center" + desc_text.styles.margin = (0, 0, 1, 0) + widgets.append(desc_text) + + return Vertical(*widgets) + + def compose(self) -> ComposeResult: + yield Header(show_clock=True, icon="⚙") + + tabs = [ + Tab("Agents", id="agent_actions"), + Tab("Tree View", id="p_tree"), + Tab("Server Log", id="server_log"), + Tab("Directory", id="dir"), + Tab("Settings", id="settings"), + ] + + if self.extras == "POLICYPREP": + tabs.insert(2, Tab("Policy Prep", id="policy")) + + yield Tabs(*tabs, id="tabs") + yield Vertical(id="content") + yield Footer() + + def on_mount(self) -> None: + self.switch_tab("agent_actions") + + def on_key(self, event) -> None: + """Handle up/down arrow keys for button navigation.""" + if event.key == "down": + self._focus_nearby_button(1) + event.prevent_default() + event.stop() + elif event.key == "up": + self._focus_nearby_button(-1) + event.prevent_default() + event.stop() + # left/right are handled by Textual's default tab navigation + + # focus helpers + def _get_content_buttons(self) -> list[Button]: + content = self.query_one("#content", Vertical) + return list(content.query(Button)) + + def _focus_first_button(self) -> None: + buttons = self._get_content_buttons() + if buttons: + buttons[0].focus() + + def _focus_tabs(self) -> None: + tabs = self.query_one("#tabs", Tabs) + tabs.focus() + + def _focus_nearby_button(self, direction: int) -> None: + buttons = self._get_content_buttons() + if not buttons: + return + + try: + current = next(i for i, b in enumerate(buttons) if b.has_focus) + except StopIteration: + if direction > 0: + buttons[0].focus() + else: + buttons[-1].focus() + return + + if direction < 0 and current == 0: + self._focus_tabs() + return + + new_index = current + direction + if 0 <= new_index < len(buttons): + buttons[new_index].focus() + + def switch_tab(self, tab_id: str) -> None: + self.current_tab = tab_id + content = self.query_one("#content", Vertical) + content.remove_children() + + if tab_id in self.BUTTON_DEFS: + content.mount(self._make_buttons_for(tab_id)) + elif tab_id == "server_log": + content.mount(ServerLogWidget(self.app.api)) + elif tab_id == "dir": + content.mount(DirectoryTree(self.working_dir, id="dir_tree")) + elif tab_id == "p_tree": + content.mount(PolicyTreeWidget(self.app.policies, self.app.devices)) + elif tab_id == "settings": + content.mount(ThemeSelector()) + else: + content.mount(Static(f"Unknown tab: {tab_id}")) + + def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + self.switch_tab(event.tab.id) + + def on_multi_agent_selector_agents_selected( + self, message: MultiAgentSelector.AgentsSelected + ) -> None: + """Handle selected agents from AgentSelector.""" + global _APP_RESTART_REASON + selected_agents = message.selected_agents + logger.info("Selected agents: %s", selected_agents) + # TODO: Implement actual handling of selected agents + _APP_RESTART_REASON = ("multi_agent_action", selected_agents) + self.app.exit() + + def on_theme_selector_theme_selected( + self, message: ThemeSelector.ThemeSelected + ) -> None: + """Handle theme selection from ThemeSelector.""" + global _APP_RESTART_REASON + _persist_user_theme(message.theme_name) + _APP_RESTART_REASON = ("restart",) + self.app.exit() + + def on_agent_move_operations_operation_complete( + self, message: AgentMoveOperations.OperationComplete + ) -> None: + """Handle completion of agent move operation - show results.""" + logger.info( + "Agent move operation completed: %s, %d successful, %d unsuccessful", + message.operation, + len(message.successful), + len(message.unsuccessful), + ) + + # Format results for display + successful_text = "\n".join( + [f"{agent.hostname}" for agent, _ in message.successful] + ) + unsuccessful_text = "\n".join( + [f"{agent.hostname}: {error}" for agent, error in message.unsuccessful] + ) + + # Remove the operations widget + try: + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + except Exception: + pass + + # Show results + self.query_one("#content", Vertical).mount( + ResultsDisplay(message.operation, successful_text, unsuccessful_text) + ) + + def on_results_display_go_back(self, message: ResultsDisplay.GoBack) -> None: + """Handle back button from results display.""" + try: + results_widget = self.query_one(ResultsDisplay) + results_widget.remove() + except Exception: + pass + # Return to main menu + self.app.pop_screen() + + def on_policy_tree_widget_view_execution_history( + self, message: PolicyTreeWidget.ViewExecutionHistory + ) -> None: + """Handle request to view execution history for a device from tree view.""" + logger.info("Viewing execution history for device: %s", message.device.hostname) + self.app.push_screen(ExecutionHistoryScreen([message.device])) + message.stop() + + def on_policy_tree_widget_generate_otp( + self, message: PolicyTreeWidget.GenerateOTP + ) -> None: + """Handle request to generate OTP for a device from tree view.""" + logger.info("Generating OTP for device: %s", message.device.hostname) + self.app.push_screen(OTPWorkflowScreen([message.device])) + message.stop() + + def on_policy_tree_widget_toggle_enforcement( + self, message: PolicyTreeWidget.ToggleEnforcement + ) -> None: + """Handle request to toggle enforcement for a device from tree view.""" + logger.info("Toggling enforcement for device: %s", message.device.hostname) + + try: + from services.agenthandler import moveAgentToRelatedPolicy + from utils.configmanager import get_system_json + + policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") + + # Determine current mode and toggle + if message.device.groupid in policy_relationship_map: + # Currently in enforcement, move to audit + result = moveAgentToRelatedPolicy(self.app.api, message.device, "audit") + mode = "audit" + else: + # Currently in audit, move to enforcement + result = moveAgentToRelatedPolicy( + self.app.api, message.device, "enforcement" + ) + mode = "enforcement" + + logger.info(f"Successfully toggled {message.device.hostname} to {mode}") + + # Refresh data at the app level + self.app.refresh_data() + + # Refresh the tree widget with new data + try: + tree_widget = self.query_one(PolicyTreeWidget) + tree_widget.refresh_data(self.app.policies, self.app.devices) + except: + pass + + except Exception as e: + logger.error( + f"Failed to toggle enforcement for {message.device.hostname}: {e}" + ) + self.app.bell() + + message.stop() + + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + path = event.path + logger.debug("Directory file selected: %s", path) + try: + open_directory(str(path)) + except Exception as exc: + logger.error("Failed to open %s: %s", path, exc) + self.app.bell() + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + logger.debug("Button pressed: %s", button_id) + + match button_id: + case "move_agent_workflow_button": + self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) + event.stop() + + case "otp_generate_button": + self.app.push_screen(OTPWorkflowScreen(self.app.devices)) + event.stop() + + case "find_quiet_button": + self.app.push_screen( + QuietAgentWorkflowScreen(self.app.api, self.app.policies) + ) + event.stop() + return + + case "otp_activities_button": + self.app.push_screen(OTPActivitiesScreen()) + event.stop() + return + + case "otp_revoke_button": + self.app.push_screen(OTPRevokeScreen()) + event.stop() + return + + case "policy_prep_button": + # Use the new TUI workflow screen instead of legacy + self.app.push_screen( + PolicyPrepWorkflowScreen(self.app.api, self.app.policies) + ) + event.stop() + return + + case _: + self.app.bell() + logger.warning("Unknown button pressed: %s", button_id) + return + + +# --------------------------------------------------------------------------- +# 2) APP +# --------------------------------------------------------------------------- +class Loxide(App[Message]): + api: AirlockAPIWrapper + working_dir: str + policies: Optional[list[Policy]] + devices: Optional[list[Agent]] + + CSS = """ + #logo { + width: 100%; + content-align: center middle; + text-align: center; + } + """ + BINDINGS = [ + ("q", "quit", "Quit"), + ("f", "open_fe", "Launch Explorer"), + ("r", "refresh", "Refresh"), + ] + + def __init__(self, api: AirlockAPIWrapper): + self._textual_theme = get_user_value("TEXTUAL_THEME", str, "textual-dark") + super().__init__() + self.api = api + wd = load_env("WORKING_DIR") or os.getcwd() + if not os.path.isdir(wd): + wd = os.getcwd() + self.working_dir = wd + # Initial data load + self.refresh_data() + + def refresh_data(self) -> None: + """Public method to refresh policies and devices from the API.""" + try: + self.policies = [ + Policy(**row.to_dict()) + for _, row in self.api.policy_find_all().iterrows() + ] + self.devices = [ + Agent(**row.to_dict()) + for _, row in self.api.agent_find_all().iterrows() + ] + if self.policies and self.devices: + for agent in self.devices: + agent.enrich_with_policies(self.policies) + logger.debug( + f"Enriched {len(self.devices)} agents with policy information" + ) + except Exception as exc: + logger.error("Failed to load policies/devices: %s", exc) + self.policies = None + self.devices = None + + def on_mount(self, api: AirlockAPIWrapper) -> None: + self.register_theme(get_retro_terminal_theme()) + self.register_theme(get_amber_terminal_theme()) + self.theme = self._textual_theme + self.push_screen(MainMenuScreen()) + + def action_refresh(self) -> None: + self.refresh_data() + + def action_quit(self) -> None: + global _APP_RESTART_REASON + _APP_RESTART_REASON = None + self.exit() + + def action_open_fe(self) -> None: + """Open the working directory in the OS file manager (footer binding).""" + path_to_open = self.working_dir or os.getcwd() + try: + open_directory(path_to_open) + except Exception as exc: + logger.error("Failed to open directory %s: %s", path_to_open, exc) + self.bell() # optional feedback + + +# --------------------------------------------------------------------------- +# 3) PUBLIC ENTRYPOINT - Updated to accept attach_notification_handler +# --------------------------------------------------------------------------- +def run_Loxide(api: AirlockAPIWrapper, attach_notification_handler=None) -> None: + global _APP_RESTART_REASON + base_dir = get_base_directory() + env_path = base_dir / ".env" + dotenv.load_dotenv(dotenv_path=env_path, override=True) + + max_attempts = 5 + attempts = 0 + + while attempts < max_attempts: + attempts += 1 + logger.debug("Starting app loop iteration (attempt %d)", attempts) + _APP_RESTART_REASON = None + app = Loxide(api) + + # Attach the notification handler if provided + if attach_notification_handler: + attach_notification_handler(app) + + try: + app.run() + except SystemExit as exc: + if exc.code != 0: + logger.debug("Caught SystemExit from Textual: %s", exc) + raise + + reason = _APP_RESTART_REASON + logger.debug("After app.run(), _APP_RESTART_REASON = %r", reason) + + if not reason: + logger.debug("No restart reason, exiting loop") + break + + if reason[0] == "restart": + logger.debug("Restarting app loop") + continue + + if reason[0] == "multi_agent_action": + logger.info("Multi-agent action with selected agents: %s", reason[1]) + continue + + logger.error("Unknown restart reason: %r", reason) + break + + +# --------------------------------------------------------------------------- +# 4) MAIN FUNCTION - Updated to get and pass attach_notification_handler +# --------------------------------------------------------------------------- def main(): irtang() # Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored - setup() - base_dir = get_base_directory() + # setup() now returns a function to attach the notification handler + attach_notification_handler = setup() logger = logging.getLogger(__name__) try: @@ -67,7 +599,7 @@ def main(): base_url=str(url), api_key=api_key, ) - run_Loxide(api) + run_Loxide(api, attach_notification_handler) if __name__ == "__main__": diff --git a/TUI/allowlistselectionscreen.py b/TUI/Screens/allowlistselectionscreen.py similarity index 93% rename from TUI/allowlistselectionscreen.py rename to TUI/Screens/allowlistselectionscreen.py index 72426f2..cb04f2d 100644 --- a/TUI/allowlistselectionscreen.py +++ b/TUI/Screens/allowlistselectionscreen.py @@ -1,3 +1,17 @@ +# 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 . from __future__ import annotations import logging @@ -150,14 +164,11 @@ class AllowlistSelectionWidget(Static): # Action buttons at bottom with Horizontal(id="action_buttons"): - self.back_btn = Button("⬅ Back", id="back_btn") self.add_btn = Button("➕ Add to Allowlist", id="add_to_allowlist_btn") - self.back_btn.styles.width = "50%" - self.add_btn.styles.width = "50%" + self.add_btn.styles.width = "100%" self.add_btn.disabled = True # Disabled until allowlist selected - yield self.back_btn yield self.add_btn async def on_mount(self) -> None: @@ -380,9 +391,9 @@ class AllowlistSelectionWidget(Static): if found_col: self.hash_column = found_col - preview_lines.append(f"✓ Found hash column: **{found_col}**\n") + preview_lines.append(f"✅ Found hash column: **{found_col}**\n") else: - preview_lines.append("⚠️ **No hash column found**\n") + preview_lines.append("❌ **No hash column found**\n") preview_lines.append("Available columns:\n") for col in self.selected_data.columns: if col != "_row_id": @@ -464,7 +475,7 @@ class AllowlistSelectionWidget(Static): self.selected_allowlist = self.allowlists[actual_allowlist_index] self.add_btn.disabled = False self.add_btn.label = ( - f"➕ Add to '{self.selected_allowlist.get('name', 'Unknown')}'" + f"➕ Add to '{self.selected_allowlist.get('name', 'Unknown')}'" ) # Update preview with selection @@ -523,11 +534,6 @@ class AllowlistSelectionWidget(Static): btn = getattr(event, "button", None) or getattr(event, "sender", None) btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) - if btn is self.back_btn or btn_id == "back_btn": - await self.app.pop_screen() - event.stop() - return - if btn is self.refresh_btn or btn_id == "refresh_allowlists_btn": await self.load_allowlists() event.stop() @@ -553,7 +559,7 @@ class AllowlistSelectionWidget(Static): try: # Disable button during operation self.add_btn.disabled = True - self.add_btn.label = "⏳ Adding hashes..." + self.add_btn.label = "Adding hashes..." # Call API to add hashes app_id = self.selected_allowlist.get("applicationid") @@ -564,10 +570,10 @@ class AllowlistSelectionWidget(Static): ) result = self.api.hash_add_to_allowlist(app_id, self.hashes_to_add) - + logger.debug(f"Hash adding api call: {result}") # Success notification self.app.notify( - f"✅ Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", + f"Successfully added {len(self.hashes_to_add)} hashes to '{allowlist_name}'", title="Success", severity="information", timeout=5, @@ -575,7 +581,7 @@ class AllowlistSelectionWidget(Static): # Update preview to show success self.preview_area.text = ( - f"## ✅ SUCCESS\n\n" + f"## SUCCESS\n\n" f"Added **{len(self.hashes_to_add)} hashes** to allowlist:\n" f"**{allowlist_name}** (ID: {app_id})\n\n" f"### Operation Details:\n" @@ -586,13 +592,13 @@ class AllowlistSelectionWidget(Static): ) # Change button to "Done" - self.add_btn.label = "✅ Done" + self.add_btn.label = "Done - Press q to return to main menu" self.add_btn.disabled = True except Exception as exc: logger.exception(f"Failed to add hashes to allowlist: {exc}") self.app.notify( - f"❌ Failed to add hashes: {str(exc)}", + f"Failed to add hashes: {str(exc)}", title="Error", severity="error", timeout=10, @@ -600,7 +606,7 @@ class AllowlistSelectionWidget(Static): # Re-enable button self.add_btn.disabled = False - self.add_btn.label = "⟳ Retry Add to Allowlist" + self.add_btn.label = "Retry Add to Allowlist" class AllowlistSelectionScreen(Screen): @@ -609,9 +615,9 @@ class AllowlistSelectionScreen(Screen): """ BINDINGS = [ - Binding("b", "back", "Back"), + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), Binding("r", "refresh", "Refresh Allowlists"), - Binding("enter", "confirm", "Add to Allowlist"), ] def __init__( @@ -641,10 +647,15 @@ class AllowlistSelectionScreen(Screen): yield self.widget yield Footer() - async def action_back(self) -> None: + async def action_go_back(self) -> None: """Go back to previous screen.""" await self.app.pop_screen() + async def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + await self.app.pop_screen() + async def action_refresh(self) -> None: """Refresh the allowlists.""" if hasattr(self, "widget") and self.widget: diff --git a/TUI/Screens/executionhistoryscreen.py b/TUI/Screens/executionhistoryscreen.py new file mode 100644 index 0000000..abf0648 --- /dev/null +++ b/TUI/Screens/executionhistoryscreen.py @@ -0,0 +1,639 @@ +# 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 . + + +from datetime import datetime, timedelta +import logging +import os +from typing import List + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Label, + Select, + Static, +) + +from models.agent import Agent +from models.execution import ExecutionHistoryRecord +from utils.configmanager import load_env + +logger = logging.getLogger(__name__) + + +class ExecutionHistoryScreen(Screen): + """ + A screen for viewing and exporting execution history for selected agents. + + This screen allows users to: + 1. Select a start date and end date using dropdown selects + 2. Fetch execution history for all selected agents + 3. View the results in a DataTable + 4. Export the results to CSV using a keybinding + + Attributes: + agents (List[Agent]): List of agents to fetch execution history for + execution_data (pd.DataFrame): Combined execution history data + working_dir (str): Directory for CSV exports + """ + + DEFAULT_CSS = """ + ExecutionHistoryScreen { + align: center top; + } + + #main_container { + width: 95%; + height: 1fr; + border: solid $primary; + padding: 1; + } + + #title { + text-style: bold; + color: $text; + text-align: center; + margin-bottom: 1; + } + + #date_container { + height: auto; + margin-bottom: 1; + } + + #start_date_row, #end_date_row { + height: auto; + align-horizontal: left; + margin-bottom: 1; + } + + .date_label { + width: 8; + margin-right: 1; + } + + .date_selector { + width: 18; + margin: 0 1; + } + + #quick_buttons_row { + height: auto; + align-horizontal: center; + margin-bottom: 1; + } + + .quick_select_btn { + margin: 0 1; + } + + #button_row { + height: auto; + align-horizontal: center; + margin-top: 1; + margin-bottom: 1; + } + + Button { + margin: 0 1; + } + + #status_label { + text-align: center; + color: $accent; + margin-bottom: 1; + } + + #results_container { + height: 1fr; + display: none; + } + + #results_button_row { + height: auto; + align-horizontal: center; + margin-bottom: 1; + } + + #history_table { + height: 1fr; + border: solid $primary; + } + + DataTable > .datatable--header { + text-style: bold; + background: $primary 20%; + } + """ + + BINDINGS = [ + Binding("escape", "close_screen", "Close"), + Binding("e", "export_csv", "Export CSV"), + Binding("q", "close_screen", "Quit"), + ] + + def __init__(self, agents: List[Agent]): + """ + Initialize the ExecutionHistoryScreen. + + Args: + agents (List[Agent]): List of agents to fetch execution history for + """ + super().__init__() + self.agents = agents + self.execution_data = pd.DataFrame() + self.working_dir = load_env("WORKING_DIR") or os.getcwd() + + # Generate dropdown options + today = datetime.now().date() + + # Month options - format is (display_text, value) + self.month_options = [ + ("January", "01"), + ("February", "02"), + ("March", "03"), + ("April", "04"), + ("May", "05"), + ("June", "06"), + ("July", "07"), + ("August", "08"), + ("September", "09"), + ("October", "10"), + ("November", "11"), + ("December", "12"), + ] + + # Day options (1-31) - format is (display_text, value) + self.day_options = [(f"{i}", f"{i:02d}") for i in range(1, 32)] + + # Year options (current year back 5 years) - format is (display_text, value) + current_year = today.year + self.year_options = [ + (str(year), str(year)) for year in range(current_year, current_year - 6, -1) + ] + + # Default dates: last 30 days + start_date = today - timedelta(days=30) + self.start_month = f"{start_date.month:02d}" + self.start_day = f"{start_date.day:02d}" + self.start_year = str(start_date.year) + + self.end_month = f"{today.month:02d}" + self.end_day = f"{today.day:02d}" + self.end_year = str(today.year) + + def compose(self) -> ComposeResult: + """Build the UI layout.""" + yield Header(show_clock=True, icon="📊") + + with Vertical(id="main_container"): + title_text = f"Execution History - {len(self.agents)} Agent(s)" + yield Static(title_text, id="title") + + # Date selection area + with Vertical(id="date_container"): + yield Label("Select Date Range:") + + # Start date row + with Horizontal(id="start_date_row"): + yield Label("From:", classes="date_label") + yield Select( + options=self.month_options, + value=self.start_month, + id="start_month_select", + classes="date_selector", + ) + yield Select( + options=self.day_options, + value=self.start_day, + id="start_day_select", + classes="date_selector", + ) + yield Select( + options=self.year_options, + value=self.start_year, + id="start_year_select", + classes="date_selector", + ) + + # End date row + with Horizontal(id="end_date_row"): + yield Label("To:", classes="date_label") + yield Select( + options=self.month_options, + value=self.end_month, + id="end_month_select", + classes="date_selector", + ) + yield Select( + options=self.day_options, + value=self.end_day, + id="end_day_select", + classes="date_selector", + ) + yield Select( + options=self.year_options, + value=self.end_year, + id="end_year_select", + classes="date_selector", + ) + + # Quick select buttons + with Horizontal(id="quick_buttons_row"): + yield Button( + "1 Day", + id="quick_1day", + classes="quick_select_btn", + variant="default", + ) + yield Button( + "1 Week", + id="quick_1week", + classes="quick_select_btn", + variant="default", + ) + yield Button( + "30 Days", + id="quick_30days", + classes="quick_select_btn", + variant="default", + ) + + # Buttons + with Horizontal(id="button_row"): + yield Button("Fetch History", id="fetch_btn", variant="primary") + yield Button("Close", id="close_btn", variant="error") + + # Status + yield Static( + "Select date range and click 'Fetch History'", id="status_label" + ) + + # Results container (hidden initially, shown after fetch) + with Vertical(id="results_container"): + with Horizontal(id="results_button_row"): + yield Button("Export CSV", id="export_btn", variant="success") + yield Button("Back", id="back_btn", variant="default") + yield DataTable(id="history_table") + + yield Footer() + + def on_mount(self) -> None: + """Initialize the table when screen is mounted.""" + table = self.query_one("#history_table", DataTable) + table.cursor_type = "row" + table.zebra_stripes = True + + # Initially empty - will populate after fetch + logger.info(f"ExecutionHistoryScreen mounted with {len(self.agents)} agents") + + def on_select_changed(self, event: Select.Changed) -> None: + """Handle date selection changes.""" + select_id = event.select.id + + if select_id == "start_month_select": + self.start_month = event.value + logger.debug(f"Start month changed to: {self.start_month}") + elif select_id == "start_day_select": + self.start_day = event.value + logger.debug(f"Start day changed to: {self.start_day}") + elif select_id == "start_year_select": + self.start_year = event.value + logger.debug(f"Start year changed to: {self.start_year}") + elif select_id == "end_month_select": + self.end_month = event.value + logger.debug(f"End month changed to: {self.end_month}") + elif select_id == "end_day_select": + self.end_day = event.value + logger.debug(f"End day changed to: {self.end_day}") + elif select_id == "end_year_select": + self.end_year = event.value + logger.debug(f"End year changed to: {self.end_year}") + + def _set_quick_date_range(self, days: int) -> None: + """Set the date range based on quick select button.""" + today = datetime.now().date() + start_date = today - timedelta(days=days) + + # Update internal values + self.start_month = f"{start_date.month:02d}" + self.start_day = f"{start_date.day:02d}" + self.start_year = str(start_date.year) + + self.end_month = f"{today.month:02d}" + self.end_day = f"{today.day:02d}" + self.end_year = str(today.year) + + # Update the Select widgets + try: + self.query_one("#start_month_select", Select).value = self.start_month + self.query_one("#start_day_select", Select).value = self.start_day + self.query_one("#start_year_select", Select).value = self.start_year + + self.query_one("#end_month_select", Select).value = self.end_month + self.query_one("#end_day_select", Select).value = self.end_day + self.query_one("#end_year_select", Select).value = self.end_year + + self.app.notify( + f"Date range set to last {days} day(s)", + severity="information", + timeout=2, + ) + logger.info(f"Quick select: Set date range to last {days} days") + except Exception as e: + logger.error(f"Failed to update date selects: {e}") + + def _show_date_selection(self) -> None: + """Show the date selection view and hide results.""" + try: + self.query_one("#date_container").styles.display = "block" + self.query_one("#button_row").styles.display = "block" + self.query_one("#status_label").styles.display = "block" + self.query_one("#results_container").styles.display = "none" + except Exception as e: + logger.error(f"Failed to show date selection: {e}") + + def _show_results(self) -> None: + """Hide date selection view and show results.""" + try: + self.query_one("#date_container").styles.display = "none" + self.query_one("#button_row").styles.display = "none" + self.query_one("#status_label").styles.display = "none" + self.query_one("#results_container").styles.display = "block" + except Exception as e: + logger.error(f"Failed to show results: {e}") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button clicks.""" + if event.button.id == "fetch_btn": + self._fetch_execution_history() + elif event.button.id == "export_btn": + self._export_to_csv() + elif event.button.id == "close_btn": + self.app.pop_screen() + elif event.button.id == "back_btn": + self._show_date_selection() + elif event.button.id == "quick_1day": + self._set_quick_date_range(days=1) + elif event.button.id == "quick_1week": + self._set_quick_date_range(days=7) + elif event.button.id == "quick_30days": + self._set_quick_date_range(days=30) + + def _fetch_execution_history(self) -> None: + """Fetch execution history for all selected agents.""" + status_label = self.query_one("#status_label", Static) + status_label.update("⏳ Fetching execution history...") + + # Disable buttons during fetch + fetch_btn = self.query_one("#fetch_btn", Button) + export_btn = self.query_one("#export_btn", Button) + fetch_btn.disabled = True + export_btn.disabled = True + + api = self.app.api + all_history = [] + + try: + # Construct dates from dropdowns + start_date_str = f"{self.start_year}-{self.start_month}-{self.start_day}" + end_date_str = f"{self.end_year}-{self.end_month}-{self.end_day}" + + # Validate dates + try: + start_dt = datetime.strptime(start_date_str, "%Y-%m-%d") + end_dt = datetime.strptime(end_date_str, "%Y-%m-%d") + except ValueError as e: + status_label.update(f"❌ Invalid date: {str(e)}") + fetch_btn.disabled = False + export_btn.disabled = False + self.app.notify(f"Invalid date selected: {str(e)}", severity="error") + return + + if start_dt > end_dt: + status_label.update("❌ Error: Start date must be before end date") + fetch_btn.disabled = False + export_btn.disabled = False + return + + # Fetch history for each agent + for i, agent in enumerate(self.agents): + try: + status_label.update( + f"⏳ Fetching history for {agent.hostname} ({i+1}/{len(self.agents)})..." + ) + + # Call API - note the API expects 'dateto' first, then 'datefrom' + history = api.history_execution( + today=end_date_str, + date_selected=start_date_str, + agent_name=agent.hostname, + ) + + if history: + # Add agent hostname to each record for identification + for record in history: + record["agent_hostname"] = agent.hostname + all_history.extend(history) + logger.info( + f"Fetched {len(history)} records for {agent.hostname}" + ) + else: + logger.info(f"No history found for {agent.hostname}") + + except Exception as e: + logger.error(f"Failed to fetch history for {agent.hostname}: {e}") + self.app.notify( + f"Warning: Failed to fetch history for {agent.hostname}", + severity="warning", + ) + + # Convert to DataFrame + if all_history: + status_label.update( + "⏳ Enriching execution data with hash information..." + ) + + # Normalize field names (handle API typos) + for record in all_history: + if "policver" in record and "policyver" not in record: + record["policyver"] = record.pop("policver") + + # Convert dict records to ExecutionHistoryRecord objects + execution_records = [] + for record in all_history: + try: + execution_records.append(ExecutionHistoryRecord(**record)) + except TypeError as e: + logger.warning(f"Failed to create ExecutionHistoryRecord: {e}") + # If it fails, just keep the dict + continue + + # Enrich with hash data if we have ExecutionHistoryRecord objects + if execution_records: + try: + enriched_records = ExecutionHistoryRecord.enrich_with_hashes( + api, execution_records + ) + logger.info( + f"Enriched {len(enriched_records)} records with hash data" + ) + + # Convert back to DataFrame + self.execution_data = pd.DataFrame( + [r.__dict__ for r in enriched_records] + ) + + # Flatten hash_obj if present + if ( + not self.execution_data.empty + and "hash_obj" in self.execution_data.columns + ): + hash_df = self.execution_data["hash_obj"].apply( + lambda h: ( + h.to_dict() if h and hasattr(h, "to_dict") else {} + ) + ) + self.execution_data = pd.concat( + [ + self.execution_data.drop(columns=["hash_obj"]), + hash_df, + ], + axis=1, + ) + except Exception as e: + logger.warning(f"Failed to enrich with hashes: {e}") + # Fall back to plain DataFrame + self.execution_data = pd.DataFrame(all_history) + else: + # If we couldn't create any ExecutionHistoryRecord objects, just use raw data + self.execution_data = pd.DataFrame(all_history) + + self._populate_table() + self._show_results() # Switch to results view + self.app.notify( + f"Successfully loaded {len(self.execution_data)} records", + severity="information", + ) + else: + status_label.update( + "ℹ️ No execution history found for selected agents/dates" + ) + self.app.notify("No execution history found", severity="information") + self.execution_data = pd.DataFrame() + + except Exception as e: + logger.error(f"Error fetching execution history: {e}") + status_label.update(f"❌ Error: {str(e)}") + self.app.notify(f"Failed to fetch history: {str(e)}", severity="error") + + finally: + # Re-enable buttons + fetch_btn.disabled = False + export_btn.disabled = False + + def _populate_table(self) -> None: + """Populate the DataTable with execution history data.""" + table = self.query_one("#history_table", DataTable) + table.clear(columns=True) + + if self.execution_data.empty: + return + + # Define preferred column order (your specified order) + preferred_order = [ + "policyname", + "policyver", + "hostname", + "username", + "publisher", + "filename", + "pprocess", + "gprocess", + "sha256", + "commandline", + "agent_hostname", # Our custom field + ] + + # Get available columns in preferred order, then add any remaining columns + available_cols = [] + for col in preferred_order: + if col in self.execution_data.columns: + available_cols.append(col) + + # Add any remaining columns not in preferred order + for col in self.execution_data.columns: + if col not in available_cols: + available_cols.append(col) + + # Add columns to table + for col in available_cols: + table.add_column(col, key=col) + + # Add rows + for idx, row in self.execution_data.iterrows(): + row_data = [] + for col in available_cols: + value = row[col] + # Convert to string, handle None/NaN + if pd.isna(value): + row_data.append("") + else: + row_data.append(str(value)) + table.add_row(*row_data, key=str(idx)) + + logger.info(f"Populated table with {len(self.execution_data)} rows") + + def _export_to_csv(self) -> None: + """Export the current execution data to CSV.""" + if self.execution_data.empty: + self.app.notify("No data to export", severity="warning") + return + + try: + # Create filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"execution_history_{timestamp}.csv" + filepath = os.path.join(self.working_dir, filename) + + # Export to CSV + self.execution_data.to_csv(filepath, index=False, encoding="utf-8-sig") + + self.app.notify( + f"✅ Exported {len(self.execution_data)} records to: {filepath}", + severity="information", + timeout=5, + ) + logger.info(f"Exported execution history to: {filepath}") + + except Exception as e: + logger.error(f"Failed to export CSV: {e}") + self.app.notify(f"Failed to export CSV: {str(e)}", severity="error") + + def action_export_csv(self) -> None: + """Keybinding action to export CSV.""" + self._export_to_csv() + + def action_close_screen(self) -> None: + """Close this screen and return to previous.""" + self.app.pop_screen() diff --git a/TUI/Screens/moveagentworkflowscreen.py b/TUI/Screens/moveagentworkflowscreen.py new file mode 100644 index 0000000..16dbfba --- /dev/null +++ b/TUI/Screens/moveagentworkflowscreen.py @@ -0,0 +1,114 @@ +# 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 . +from typing import List, Optional + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.css.query import NoMatches +from textual.screen import Screen + +from models.agent import Agent +from TUI.Widgets.agentmoveoperations import AgentMoveOperations +from TUI.Widgets.multiagentselector import MultiAgentSelector +from TUI.Widgets.resultsdisplay import ResultsDisplay + + +class MoveAgentWorkflowScreen(Screen): + """Screen that handles the agent movement workflow.""" + + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + ] + + def __init__(self, all_agents: Optional[List[Agent]]): + super().__init__() + self.all_agents = all_agents + self.selected_agents = None + self.workflow_stage = "select_agents" # Track current stage + + def compose(self) -> ComposeResult: + """Start with the multi-agent selector.""" + yield MultiAgentSelector(self.all_agents) + + def action_go_back(self) -> None: + """Handle escape key to go back one step within the workflow.""" + if self.workflow_stage == "select_agents": + # At first stage, go back to main menu + self.app.pop_screen() + elif self.workflow_stage == "operations": + # Go back to agent selection + try: + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + except NoMatches: + pass + self.mount(MultiAgentSelector(self.all_agents)) + self.workflow_stage = "select_agents" + elif self.workflow_stage == "results": + # Go back to operations + try: + results_widget = self.query_one(ResultsDisplay) + results_widget.remove() + except NoMatches: + pass + self.mount(AgentMoveOperations(self.selected_agents)) + self.workflow_stage = "operations" + + def action_main_menu(self) -> None: + """Handle q key to go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + + def on_multi_agent_selector_agents_selected( + self, message: MultiAgentSelector.AgentsSelected + ) -> None: + """Handle selected agents - switch to operations screen.""" + self.selected_agents = message.selected_agents + + # Remove the MultiAgentSelector + selector = self.query_one(MultiAgentSelector) + selector.remove() + + # Mount the AgentMoveOperations with the selected Agent objects + self.mount(AgentMoveOperations(self.selected_agents)) + self.workflow_stage = "operations" + + def on_agent_move_operations_operation_complete( + self, message: AgentMoveOperations.OperationComplete + ) -> None: + """Handle completion of move operation - transition to results screen.""" + # Format successful results + success_lines = [] + for agent, result in message.successful: + success_lines.append(f"✔ {agent.hostname}") + + # Format unsuccessful results + failure_lines = [] + for agent, error in message.unsuccessful: + failure_lines.append(f"❌ — {agent.hostname}: {error}") + + successful_text = "\n".join(success_lines) if success_lines else "(none)" + unsuccessful_text = "\n".join(failure_lines) if failure_lines else "(none)" + + # Remove the operations widget + ops_widget = self.query_one(AgentMoveOperations) + ops_widget.remove() + + # Mount the results display + self.mount( + ResultsDisplay(message.operation, successful_text, unsuccessful_text) + ) + self.workflow_stage = "results" diff --git a/TUI/otpactivityscreen.py b/TUI/Screens/otpactivityscreen.py similarity index 92% rename from TUI/otpactivityscreen.py rename to TUI/Screens/otpactivityscreen.py index 5947cee..3b6714f 100644 --- a/TUI/otpactivityscreen.py +++ b/TUI/Screens/otpactivityscreen.py @@ -1,3 +1,17 @@ +# 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 . from __future__ import annotations from datetime import datetime @@ -11,7 +25,7 @@ from textual.containers import Horizontal, Vertical from textual.screen import Screen from textual.widgets import Button, DataTable, Footer, Header, Static -from TUI.allowlistselectionscreen import AllowlistSelectionScreen +from TUI.Screens.allowlistselectionscreen import AllowlistSelectionScreen from utils.configmanager import load_env logger = logging.getLogger(__name__) @@ -31,8 +45,7 @@ class OTPActivitiesWidget(Static): """ Reusable widget that contains the sessions table (left) and an Activity Preview (right). The right side shows an Activity Preview that takes ~75% vertical space, and a lower area - with Back and Continue buttons. The Continue button pushes ActivityDetailScreen with the - currently-loaded activities. + with Continue button. """ DEFAULT_CSS = """ @@ -85,15 +98,10 @@ class OTPActivitiesWidget(Static): with Vertical(id="activity_preview_container"): self.activities_table = DataTable(id="activity_preview_table") yield self.activities_table - # Buttons area at the bottom (Back, Continue) + # Button area at the bottom (Continue) with Horizontal(id="activity_buttons"): - # Back takes left side, Continue right side - self.back_btn = Button("Back", id="activity_back_btn") self.continue_btn = Button("Continue", id="activity_continue_btn") - # Stretch buttons nicely - self.back_btn.styles.width = "50%" - self.continue_btn.styles.width = "50%" - yield self.back_btn + self.continue_btn.styles.width = "100%" yield self.continue_btn async def on_mount(self) -> None: @@ -122,7 +130,7 @@ class OTPActivitiesWidget(Static): async def on_button_pressed(self, event) -> None: # type: ignore[override] """ - Handle Back / Continue buttons for the Activity Preview area. + Handle Continue button for the Activity Preview area. """ # Try to resolve the button object from the event btn = ( @@ -136,18 +144,12 @@ class OTPActivitiesWidget(Static): or getattr(event, "button_id", None) or getattr(event, "id", None) ) - # ---- Back ---- - if btn is self.back_btn or btn_id == getattr(self.back_btn, "id", None): - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - event.stop() - return # ---- Continue ---- if btn is self.continue_btn or btn_id == getattr(self.continue_btn, "id", None): if self._activities_df is None or self._activities_df.empty: logger.info("Continue pressed but no activities loaded.") - await self.post_message( - Static("No activities loaded to continue with.") + self.app.notify( + "No activities loaded to continue with.", severity="warning" ) return # Copy activities DataFrame to pass to new screen @@ -463,7 +465,7 @@ class OTPActivitiesWidget(Static): try: self._activities_df.to_csv(file_path, index=False) logger.info("Exported activities to %s", file_path) - await self.post_message(Static(f"✅ Exported activities to: {file_path}")) + await self.post_message(Static(f"Exported activities to: {file_path}")) except Exception as exc: logger.exception("Failed to export activities to %s: %s", file_path, exc) await self.post_message(Static("Failed to export activities; check logs.")) @@ -472,7 +474,7 @@ class OTPActivitiesWidget(Static): class ActivityDetailWidget(Static): """ Interactive widget for Activity Detail screen. - Shows the provided DataFrame in a DataTable and offers Export + Back buttons. + Shows the provided DataFrame in a DataTable and offers Export button. Now includes Select All/None and Add to Allowlist functionality. """ @@ -529,12 +531,10 @@ class ActivityDetailWidget(Static): # Original buttons at bottom with Horizontal(id="detail_buttons"): - self.detail_back_btn = Button("Back", id="detail_back_btn") self.add_allowlist_btn = Button( - "📋 Add Selected to Allowlist", id="add_allowlist_btn" + "Add Selected to Allowlist", id="add_allowlist_btn" ) yield self.add_allowlist_btn - yield self.detail_back_btn async def on_mount(self) -> None: await self._build_table(rebuild=True) @@ -547,12 +547,12 @@ class ActivityDetailWidget(Static): # Update button labels with count count = len(self.selected_row_ids) - total = len(self.activities_df) + len(self.activities_df) if has_selection: - self.add_allowlist_btn.label = f"📋 Add {count} Selected to Allowlist" + self.add_allowlist_btn.label = f"Add {count} Selected to Allowlist" else: - self.add_allowlist_btn.label = "📋 Add Selected to Allowlist" + self.add_allowlist_btn.label = "Add Selected to Allowlist" async def _build_table(self, rebuild: bool = True) -> None: """Rebuild the DataTable. If rebuild=False, only refresh rows.""" @@ -591,7 +591,7 @@ class ActivityDetailWidget(Static): vals.append("" if pd.isna(v) else str(v)) # Check if this row is selected - checkbox = "☑" if row_id in self.selected_row_ids else "☐" + checkbox = "☑️" if row_id in self.selected_row_ids else "☐" # Add row to table row_key = self.detail_table.add_row(checkbox, *vals) @@ -616,7 +616,7 @@ class ActivityDetailWidget(Static): self.detail_table.update_cell(row_key, "select", "☐") # Unchecked else: self.selected_row_ids.add(row_id) - self.detail_table.update_cell(row_key, "select", "☑") # Checked + self.detail_table.update_cell(row_key, "select", "☑️") # Checked self._update_button_states() @@ -652,19 +652,13 @@ class ActivityDetailWidget(Static): logger.exception("Failed to sort by column %s: %s", column_key, exc) return - # ✅ Only refresh rows, not columns + # Only refresh rows, not columns await self._build_table(rebuild=False) async def on_button_pressed(self, event) -> None: btn = getattr(event, "button", None) or getattr(event, "sender", None) btn_id = getattr(btn, "id", None) or getattr(event, "button_id", None) - if btn is self.detail_back_btn or btn_id == "detail_back_btn": - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - event.stop() - return - if btn is self.add_allowlist_btn or btn_id == "add_allowlist_btn": await self._open_allowlist_screen() return @@ -676,7 +670,7 @@ class ActivityDetailWidget(Static): # Update all checkboxes in the table for row_key, row_id in self.row_key_to_id.items(): - self.detail_table.update_cell(row_key, "select", "☑") + self.detail_table.update_cell(row_key, "select", "☑️") self._update_button_states() logger.info(f"Selected all {len(self.selected_row_ids)} rows") @@ -688,7 +682,7 @@ class ActivityDetailWidget(Static): # Update all checkboxes in the table for row_key, row_id in self.row_key_to_id.items(): - self.detail_table.update_cell(row_key, "select", "☐") + self.detail_table.update_cell(row_key, "select", "☑️") self._update_button_states() logger.info("Cleared all selections") @@ -730,14 +724,12 @@ class ActivityDetailWidget(Static): async def _export_detail_activities(self) -> None: if self.activities_df is None or self.activities_df.empty: logger.info("No activities to export.") - await self.mount( - Static("❌ No activities to export.", classes="notification") - ) + await self.mount(Static("No activities to export.", classes="notification")) return if not self.selected_row_ids: logger.info("No rows selected for export.") await self.mount( - Static("❌ No rows selected for export.", classes="notification") + Static("No rows selected for export.", classes="notification") ) return try: @@ -750,7 +742,7 @@ class ActivityDetailWidget(Static): logger.info("Exported selected activities to %s", file_path) await self.mount( Static( - f"✅ Exported selected activities to: {filename}", + f"Exported selected activities to: {filename}", classes="notification", ) ) @@ -758,12 +750,12 @@ class ActivityDetailWidget(Static): logger.exception("Failed to export detail activities: %s", exc) await self.mount( Static( - "❌ Failed to export activities; check logs.", + "¢ Failed to export activities; check logs.", classes="notification", ) ) - # ✅ Helper methods + # Helper methods def get_selected_data(self) -> pd.DataFrame: """Return a DataFrame of the selected rows.""" if not self.selected_row_ids: @@ -795,7 +787,8 @@ class ActivityDetailScreen(Screen): """ BINDINGS = [ - Binding("b", "back", "Back"), + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), Binding("e", "export", "Export"), Binding("a", "select_all", "Select All"), Binding("n", "select_none", "Select None"), @@ -819,11 +812,16 @@ class ActivityDetailScreen(Screen): yield self.widget yield Footer() - async def action_back(self) -> None: + async def action_go_back(self) -> None: try: await self.app.pop_screen() except Exception: - logger.debug("ActivityDetailScreen.action_back pop_screen failed.") + logger.debug("ActivityDetailScreen.action_go_back pop_screen failed.") + + async def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + await self.app.pop_screen() async def action_export(self) -> None: # Delegate to widget export helper @@ -851,9 +849,10 @@ class OTPActivitiesScreen(Screen): """ BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), Binding("r", "refresh_sessions", "Refresh Sessions"), Binding("e", "export_activities", "Export activities"), - Binding("q", "quit", "Quit"), ] def compose(self) -> ComposeResult: @@ -875,6 +874,15 @@ class OTPActivitiesScreen(Screen): else: await self.widget.load_sessions_from_api(api) + async def action_go_back(self) -> None: + """Go back one screen.""" + await self.app.pop_screen() + + async def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + await self.app.pop_screen() + # Simple actions bound to keys async def action_refresh_sessions(self) -> None: api = getattr(self.app, "api", None) @@ -884,10 +892,6 @@ class OTPActivitiesScreen(Screen): logger.info("Refreshing OTP sessions via API.") await self.widget.load_sessions_from_api(api) - async def action_quit(self) -> None: - # Pop the screen or exit app - await self.app.pop_screen() - # If you want an explicit method to fetch activities for a particular otpid from outside: async def fetch_activities_for_otpid(self, otpid, hostname=None) -> None: api = getattr(self.app, "api", None) diff --git a/TUI/Screens/otprevokescreen.py b/TUI/Screens/otprevokescreen.py new file mode 100644 index 0000000..93c7a23 --- /dev/null +++ b/TUI/Screens/otprevokescreen.py @@ -0,0 +1,392 @@ +# 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 . + +from __future__ import annotations + +import logging +from typing import List, Optional + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.message import Message +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Static + +logger = logging.getLogger(__name__) + + +class OTPRevokeWidget(Static): + """ + Widget for managing OTP session revocation. + Displays active OTP sessions and allows selection for revocation. + """ + + class SessionsRevoked(Message): + """Message sent when sessions are revoked.""" + + def __init__(self, revoked_sessions: List[dict]): + super().__init__() + self.revoked_sessions = revoked_sessions + + DEFAULT_CSS = """ + OTPRevokeWidget { + height: 1fr; + } + #main_container { + width: 100%; + height: 100%; + layout: vertical; + } + #sessions_container { + height: 1fr; + border: none; + padding: 1; + } + #button_container { + height: auto; + width: 100%; + padding: 1; + align: center middle; + } + #button_container Button { + min-width: 16; + margin: 0 1; + } + #result_container { + height: auto; + max-height: 10; + border: solid #444444; + padding: 1; + margin: 1; + overflow-y: auto; + } + .panel-title { + text-style: bold; + margin: 0 0 1 0; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="main_container"): + # Sessions table + yield Static("OTP Sessions", classes="panel-title") + with Vertical(id="sessions_container"): + self.sessions_table = DataTable(id="sessions_table") + self.sessions_table.styles.width = "100%" + self.sessions_table.styles.height = "1fr" + yield self.sessions_table + + # Action buttons + with Horizontal(id="button_container"): + yield Button("Refresh", id="refresh_btn") + yield Button("Select All", id="select_all_btn") + yield Button("Clear Selection", id="select_none_btn") + yield Button("Revoke Selected", id="revoke_btn", variant="error") + + # Results display + with Vertical(id="result_container"): + yield Static("Revocation Results", classes="panel-title") + self.results_display = Static("No actions performed yet.") + yield self.results_display + + async def on_mount(self) -> None: + """Initialize the widget when mounted.""" + # Configure sessions table + self.sessions_table.clear() + self.sessions_table.add_columns( + "", "OTP ID", "Hostname", "Status", "Purpose", "Granted" + ) + + # Enable row selection with checkbox column + self.sessions_table.cursor_type = "row" + try: + self.sessions_table.zebra_stripes = True + except Exception: + pass + + # Initialize state + self._sessions_df: Optional[pd.DataFrame] = None + self._filtered_df: Optional[pd.DataFrame] = None + self._selected_otpids: set = set() + + async def load_sessions_from_api(self, api) -> None: + """Load active OTP sessions from the API.""" + try: + # Fetch only active sessions + active_df = api.otp_find_active() + + # Ensure we have a DataFrame + if not isinstance(active_df, pd.DataFrame): + active_df = pd.DataFrame(active_df) + + # Add status column + active_df["status"] = "active" + + # Sort by otpid if column exists + if "otpid" in active_df.columns and not active_df.empty: + active_df = active_df.sort_values(by="otpid", ascending=False) + + # Store the full dataframe + self._sessions_df = active_df + self._filtered_df = active_df.copy() + + # Display in table + await self._refresh_table() + + # Update status + active_count = len(active_df) + + status_msg = f"Loaded {active_count} active sessions" + logger.info(status_msg) + self.results_display.update(status_msg) + + except Exception as e: + logger.exception(f"Failed to load OTP sessions: {e}") + self.results_display.update(f"Error loading sessions: {str(e)}") + + async def _refresh_table(self) -> None: + """Refresh the table display with current filtered data.""" + if self._filtered_df is None or self._filtered_df.empty: + self.sessions_table.clear() + return + + # Ensure expected columns exist + expected_cols = ["otpid", "hostname", "status", "purpose", "granted"] + for col in expected_cols: + if col not in self._filtered_df.columns: + self._filtered_df[col] = "" + + # Clear and repopulate table + self.sessions_table.clear(columns=False) + + for _, row in self._filtered_df.iterrows(): + otpid = str(row.get("otpid", "")) + # Check if this row is selected + checkbox = "☑️" if otpid in self._selected_otpids else "☐" + + self.sessions_table.add_row( + checkbox, + str(otpid), + str(row.get("hostname", "")), + str(row.get("status", "")), + str(row.get("purpose", "")), + str(row.get("granted", "")), + ) + + async def on_button_pressed(self, event) -> None: + """Handle button presses.""" + btn = event.button + + if btn.id == "refresh_btn": + # Refresh sessions + api = getattr(self.app, "api", None) + if api: + await self.load_sessions_from_api(api) + + elif btn.id == "select_all_btn": + # Select all visible rows + if ( + self._filtered_df is not None + and not self._filtered_df.empty + and "otpid" in self._filtered_df.columns + ): + self._selected_otpids = set(str(x) for x in self._filtered_df["otpid"]) + await self._refresh_table() + + elif btn.id == "select_none_btn": + # Clear selection + self._selected_otpids.clear() + await self._refresh_table() + + elif btn.id == "revoke_btn": + # Revoke selected sessions + await self._revoke_selected() + + async def on_data_table_row_selected(self, event) -> None: + """Handle row selection in the table.""" + if event.data_table != self.sessions_table: + return + + try: + # Get the row index from the cursor row + row_index = self.sessions_table.cursor_row + + if ( + self._filtered_df is not None + and not self._filtered_df.empty + and "otpid" in self._filtered_df.columns + and row_index < len(self._filtered_df) + ): + # Get the OTP ID for this row + otpid = str(self._filtered_df.iloc[row_index]["otpid"]) + + # Toggle selection + if otpid in self._selected_otpids: + self._selected_otpids.remove(otpid) + else: + self._selected_otpids.add(otpid) + + # Refresh table to update checkbox + await self._refresh_table() + + # Restore cursor position + self.sessions_table.move_cursor(row=row_index) + + except Exception as e: + logger.exception(f"Error handling row selection: {e}") + + async def _revoke_selected(self) -> None: + """Revoke the selected OTP sessions.""" + if not self._selected_otpids: + self.results_display.update("No sessions selected for revocation") + return + + api = getattr(self.app, "api", None) + if not api: + self.results_display.update("API not available") + return + + # Collect results + results = [] + success_count = 0 + failure_count = 0 + + for otpid in self._selected_otpids: + try: + # Get hostname for this session + hostname = "Unknown" + if self._sessions_df is not None: + # Convert otpid to same type as in DataFrame for comparison + otpid_compare = otpid + if len(self._sessions_df) > 0: + first_otpid = self._sessions_df["otpid"].iloc[0] + if isinstance(first_otpid, int): + otpid_compare = int(otpid) + + match = self._sessions_df[ + self._sessions_df["otpid"] == otpid_compare + ] + if not match.empty: + hostname = match.iloc[0].get("hostname", "Unknown") + + # Revoke the session + result = api.otp_revoke(otpid) + + if result and result.get("status") != "error": + success_count += 1 + results.append(f"Revoked OTP {otpid} for {hostname}") + logger.info(f"Revoked OTP {otpid} for {hostname}: {result}") + else: + failure_count += 1 + error_msg = ( + result.get("message", "Unknown error") + if result + else "No response" + ) + results.append( + f"Failed to revoke OTP {otpid} for {hostname}: {error_msg}" + ) + logger.error(f"Failed to revoke OTP {otpid}: {error_msg}") + + except Exception as e: + failure_count += 1 + results.append(f"Error revoking OTP {otpid}: {str(e)}") + logger.exception(f"Exception revoking OTP {otpid}: {e}") + + # Update results display + summary = ( + f"Revocation complete: {success_count} succeeded, {failure_count} failed\n" + ) + details = "\n".join(results[-5:]) # Show last 5 results + if len(results) > 5: + details = f"... (showing last 5 of {len(results)} results)\n" + details + + self.results_display.update(summary + details) + + # Clear selection and refresh + self._selected_otpids.clear() + await self.load_sessions_from_api(api) + + # Post message about revoked sessions + if success_count > 0: + self.post_message(self.SessionsRevoked(results)) + + +class OTPRevokeScreen(Screen): + """ + Main screen for OTP session revocation workflow. + This replaces the otp_revoke function from otp.py. + """ + + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + Binding("r", "refresh", "Refresh"), + Binding("a", "select_all", "Select All"), + Binding("n", "select_none", "Clear Selection"), + Binding("d", "revoke", "Revoke Selected"), + ] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + self.widget = OTPRevokeWidget() + yield self.widget + yield Footer() + + async def on_mount(self) -> None: + """Load sessions when screen mounts.""" + api = getattr(self.app, "api", None) + if api: + await self.widget.load_sessions_from_api(api) + else: + logger.warning("OTPRevokeScreen mounted but no self.app.api found.") + + async def action_refresh(self) -> None: + """Refresh the sessions list.""" + api = getattr(self.app, "api", None) + if api: + await self.widget.load_sessions_from_api(api) + + async def action_select_all(self) -> None: + """Select all visible sessions.""" + if ( + self.widget._filtered_df is not None + and not self.widget._filtered_df.empty + and "otpid" in self.widget._filtered_df.columns + ): + self.widget._selected_otpids = set( + str(x) for x in self.widget._filtered_df["otpid"] + ) + await self.widget._refresh_table() + + async def action_select_none(self) -> None: + """Clear all selections.""" + self.widget._selected_otpids.clear() + await self.widget._refresh_table() + + async def action_revoke(self) -> None: + """Revoke selected sessions.""" + await self.widget._revoke_selected() + + async def action_go_back(self) -> None: + """Go back to previous screen.""" + await self.app.pop_screen() + + async def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + await self.app.pop_screen() diff --git a/TUI/Screens/otpworkflowscreen.py b/TUI/Screens/otpworkflowscreen.py new file mode 100644 index 0000000..124569c --- /dev/null +++ b/TUI/Screens/otpworkflowscreen.py @@ -0,0 +1,52 @@ +# 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 . + +from typing import List, Optional + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import Screen + +from models.agent import Agent +from TUI.Widgets.OTP_generate import OTPGenerator + + +class OTPWorkflowScreen(Screen): + """Screen that handles the OTP generation workflow without agent selection.""" + + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + ] + + def __init__(self, selected_agents: Optional[List[Agent]]): + super().__init__() + self.selected_agents = selected_agents + + def compose(self) -> ComposeResult: + """Directly show the OTP generator for the selected agents.""" + yield OTPGenerator(self.selected_agents) + + def action_go_back(self) -> None: + """Handle escape key to go back one screen.""" + self.app.pop_screen() + + def action_main_menu(self) -> None: + """Handle q key to go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + + def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: + """Handle OTP generation request - pass it up to the app level if needed.""" diff --git a/TUI/Screens/policyprepworkflowscreen.py b/TUI/Screens/policyprepworkflowscreen.py new file mode 100644 index 0000000..3b068b8 --- /dev/null +++ b/TUI/Screens/policyprepworkflowscreen.py @@ -0,0 +1,3697 @@ +# 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 . + + +import datetime +import logging +import os +import re +from typing import Dict, List, Optional + +import pandas as pd +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import Button, DataTable, Footer, Header, Input, Static + +from models.execution import ExecutionHistoryRecord +from models.policy import Allowlist, Policy +from services.API import AirlockAPIWrapper +from TUI.Widgets.policyselector import PolicySelector +from utils.configmanager import get_system_list, get_system_value, load_env + +logger = logging.getLogger(__name__) + + +class PolicyPrepWorkflowScreen(Screen): + """ + A Textual screen for the Policy Preparation workflow. + + This screen provides a multi-step workflow: + 1. Select source policies to gather execution data from + 2. Select destination policy and associated allowlist + 3. Fetch and sort execution history + 4. Manual review of approved/needs_review files + 5. Generate path exclusions and publisher lists + 6. Second manual review of paths/publishers + 7. Test - preview changes + 8. Liftoff - apply changes + + Attributes: + api (AirlockAPIWrapper): API wrapper for Airlock operations + policies (List[Policy]): List of all available policies + source_policies (List[Policy]): Selected source policies + destination_policy (Optional[Policy]): Destination policy + destination_allowlist (Optional[Allowlist]): Associated allowlist + workflow_stage (str): Current stage of the workflow + working_dir (str): Working directory for exports + """ + + DEFAULT_CSS = """ + DataTable > .datatable--row.selected { + background: $accent; + color: $background; + } + + /* Highlighted cursor row */ + DataTable:focus > .datatable--cursor { + background: $secondary 20%; + } + + /* When a row is both selected and has cursor, selection wins */ + DataTable:focus > .datatable--cursor.selected { + background: $accent; + color: $background; + } + + #workflow_title { + text-style: bold; + color: $text; + } + + #workflow_status { + color: $accent; + } + + #checklist_area { + max-height: 12; + margin: 0 1; + } + + #content_area { + height: 1fr; + overflow-y: auto; + scrollbar-gutter: stable; + padding: 1 1; + } + + Horizontal { + height: auto; + min-height: 3; + } + + Button { + min-width: 15; + } + + Button.variant-error { + background: $error; + color: $text; + } + + Button.variant-success { + background: $success; + color: $text; + } + """ + + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + Binding("f", "open_folder", "Open Folder"), + Binding("d", "delete_rows", "Delete Selected"), + Binding("c", "copy_rows", "Copy Selected"), + Binding("a", "select_all", "Select All"), + Binding("n", "select_none", "Select None"), + Binding("space", "toggle_selection", "Toggle Selection", show=False), + # Note: 'r' key handled in on_key() for range selection mode + ] + + workflow_stage = reactive("select_source") # Tracks current workflow stage + + def __init__(self, api: AirlockAPIWrapper, policies: List[Policy]): + """ + Initialize the PolicyPrepWorkflowScreen. + + Args: + api (AirlockAPIWrapper): API wrapper for Airlock operations + policies (List[Policy]): List of all available policies + """ + super().__init__() + self.api = api + self.policies = policies + self.source_policies: List[Policy] = [] + self.destination_policy: Optional[Policy] = None + self.destination_allowlist: Optional[Allowlist] = None + self.working_dir = load_env("WORKING_DIR") or os.getcwd() + self.history_days: Optional[int] = None + self.path_split: str = "\\" # Path separator for Windows (single backslash) + + # Data storage + self.approved_df: Optional[pd.DataFrame] = None + self.needs_review_df: Optional[pd.DataFrame] = None + self.unapproved_df: Optional[pd.DataFrame] = None + self.primary_paths_df: Optional[pd.DataFrame] = None + self.secondary_paths_df: Optional[pd.DataFrame] = None + self.publishers_df: Optional[pd.DataFrame] = None + self.remaining_hashes_df: Optional[pd.DataFrame] = None + + # Test data for preview + self.test_results: Optional[Dict] = None + + # Multi-select tracking + self.last_clicked_row: Optional[str] = None + self.last_clicked_table: Optional[str] = None + + # Range selection mode (activated by 'r' key) + self._range_mode = False + + # Track if we're navigating with keyboard (to prevent selection) + self._keyboard_navigation = False + + # Tab review tracking for Step 5 (First Review) + self.approved_tab_reviewed = False + self.needs_review_tab_reviewed = False + + # Tab review tracking for Step 6 (Path Review) + self.paths_tab_reviewed = False + self.publishers_tab_reviewed = False + + # Lock to prevent concurrent table creation + self._creating_review_table = False + self._creating_path_table = False + + def compose(self) -> ComposeResult: + """Build the UI layout for the workflow screen.""" + yield Header(show_clock=True, icon="⚙️") + + # Title area + title = Static("Policy Preparation Workflow", id="workflow_title") + title.styles.text_align = "center" + title.styles.margin = (0, 0, 0, 1) + yield title + + # Status area + status = Static("Step 1: Select Source Policies", id="workflow_status") + status.styles.margin = (0, 0, 1, 1) + yield status + + # Main content area - dynamically populated based on workflow stage + yield Vertical(id="content_area") + + # Checklist area - always visible + yield Vertical(id="checklist_area") + + yield Footer() + + def on_mount(self) -> None: + """Initialize the screen when mounted.""" + self._update_checklist() + self._show_introduction() + + def watch_workflow_stage(self, old_value: str, new_value: str) -> None: + """React to workflow stage changes.""" + logger.debug(f"Workflow stage changed from {old_value} to {new_value}") + self._update_status_message() + self._update_checklist() + + def _update_status_message(self) -> None: + """Update the status message based on current workflow stage.""" + status_widget = self.query_one("#workflow_status", Static) + + stage_messages = { + "introduction": "Step 0: Workflow Introduction", + "select_source": "Step 1: Select Source Policies", + "select_destination": "Step 2: Select Destination Policy", + "select_allowlist": "Step 3: Select Destination Allowlist", + "fetch_data": "Step 4: Fetch Execution History", + "fetching": "Processing: Fetching and sorting execution data...", + "first_review": "Step 5: First Manual Review", + "building_paths": "Processing: Building path exclusions and publishers...", + "second_review": "Step 6: Second Manual Review", + "test": "Step 7: Test - Preview Changes", + "liftoff": "Step 8: Liftoff - Apply Changes", + "complete": "✅ Workflow Complete!", + } + + status_widget.update(stage_messages.get(self.workflow_stage, "Processing...")) + + def _update_checklist(self) -> None: + """Update the preparation checklist display.""" + checklist = self.query_one("#checklist_area", Vertical) + checklist.remove_children() + + # Checklist container with border + checklist_container = Vertical() + checklist_container.styles.border = ("round", "blue") + checklist_container.styles.margin = (0, 1) + checklist_container.styles.padding = (0, 1) + + # Mount the container to the checklist area FIRST + checklist.mount(checklist_container) + + # Title + checklist_title = Static("Prep Checklist") + checklist_title.styles.text_style = "bold" + checklist_title.styles.text_align = "center" + checklist_container.mount(checklist_title) + + # Create two-column layout + row1 = Horizontal() + row1.styles.height = "auto" + checklist_container.mount(row1) + + col1 = Vertical() + col1.styles.width = "50%" + col2 = Vertical() + col2.styles.width = "50%" + row1.mount(col1) + row1.mount(col2) + + # Step 1: Source Policies + step1_status = "✓" if self.source_policies else "✖" + step1_count = f" ({len(self.source_policies)})" if self.source_policies else "" + step1 = Static(f"{step1_status} Source{step1_count}") + if self.source_policies: + step1.styles.color = "green" + else: + step1.styles.text_style = "dim" + col1.mount(step1) + + # Step 2: Destination Policy + step2_status = "✓" if self.destination_policy else "✖" + step2 = Static(f"{step2_status} Destination") + if self.destination_policy: + step2.styles.color = "green" + else: + step2.styles.text_style = "dim" + col1.mount(step2) + + # Step 3: Allowlist + step3_status = "✓" if self.destination_allowlist else "✖" + step3 = Static(f"{step3_status} Allowlist") + if self.destination_allowlist: + step3.styles.color = "green" + else: + step3.styles.text_style = "dim" + col1.mount(step3) + + # Step 4: Data Fetched + data_fetched = self.approved_df is not None or self.needs_review_df is not None + step4_status = "✓" if data_fetched else "✖" + if data_fetched: + total = 0 + if self.approved_df is not None: + total += len(self.approved_df) + if self.needs_review_df is not None: + total += len(self.needs_review_df) + step4 = Static(f"{step4_status} Data ({total})") + else: + step4 = Static(f"{step4_status} Data") + if data_fetched: + step4.styles.color = "green" + else: + step4.styles.text_style = "dim" + col1.mount(step4) + + # Step 5: First Review + first_review_path = os.path.join(self.working_dir, "Approved") + first_review_done = False + if self.source_policies: + approved_file = os.path.join( + first_review_path, + f"{self.source_policies[0].name}_approved_executions.csv", + ) + first_review_done = os.path.exists(approved_file) + + step5_status = "✓" if first_review_done else "✖" + step5 = Static(f"{step5_status} Review 1") + if first_review_done: + step5.styles.color = "green" + else: + step5.styles.text_style = "dim" + col2.mount(step5) + + # Step 6: Paths Generated + paths_generated = self.primary_paths_df is not None + step6_status = "✓" if paths_generated else "✖" + if paths_generated: + step6 = Static(f"{step6_status} Paths ({len(self.primary_paths_df)})") + else: + step6 = Static(f"{step6_status} Paths") + if paths_generated: + step6.styles.color = "green" + else: + step6.styles.text_style = "dim" + col2.mount(step6) + + # Step 7: Second Review + step7_status = ( + "✓" if self.workflow_stage in ["test", "liftoff", "complete"] else "✖" + ) + step7 = Static(f"{step7_status} Review 2") + if step7_status == "✓": + step7.styles.color = "green" + else: + step7.styles.text_style = "dim" + col2.mount(step7) + + # Step 8: Tested + step8_status = "✓" if self.workflow_stage in ["liftoff", "complete"] else "✖" + step8 = Static(f"{step8_status} Tested") + if step8_status == "✓": + step8.styles.color = "green" + else: + step8.styles.text_style = "dim" + col2.mount(step8) + + def _show_introduction(self) -> None: + """Show workflow introduction and overview.""" + self.workflow_stage = "introduction" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Title + title = Static("Welcome to Policy Preparation Workflow") + title.styles.margin = (1, 1) + title.styles.text_style = "bold" + title.styles.text_align = "center" + content.mount(title) + + # Description + description = Static( + "This workflow will help you:\n" + " • Fetch execution history from selected policies\n" + " • Review and approve safe executions\n" + " • Calculate efficient path exclusions\n" + " • Generate publisher trust rules\n" + " • Apply changes to your destination policy" + ) + description.styles.margin = (1, 2) + content.mount(description) + + # Process steps + steps_title = Static("The Process:") + steps_title.styles.margin = (1, 2, 0, 2) + steps_title.styles.text_style = "bold" + content.mount(steps_title) + + steps = Static( + " 📋 Step 1: Select source policies (data collection)\n" + " 🎯 Step 2: Select destination policy (where changes go)\n" + " 📝 Step 3: Select destination allowlist\n" + " 📊 Step 4: Fetch execution data (5-30 minutes, depending on policy size)\n" + " ✅ Step 5: Review approved/needs review executions\n" + " 📁 Step 6: Review path exclusions and publishers\n" + " 🔍 Step 7: Preview changes before applying\n" + " 🚀 Step 8: Liftoff - Apply to production" + ) + steps.styles.margin = (0, 2) + content.mount(steps) + + # Time estimate + estimate = Static( + "⏱️ Estimated Time: 1-3 hours for large policies (policies with 100k+ executions may take longer)" + ) + estimate.styles.margin = (1, 2) + estimate.styles.color = "cyan" + content.mount(estimate) + + # Tips + tips_title = Static("💡 Tips:") + tips_title.styles.margin = (1, 2, 0, 2) + tips_title.styles.text_style = "bold" + content.mount(tips_title) + + tips = Static( + " • Start with a test policy first\n" + " • Review carefully - changes affect all agents\n" + " • Use path rules when possible (more efficient)\n" + " • Publishers are powerful - use cautiously" + ) + tips.styles.margin = (0, 2) + tips.styles.color = "yellow" + content.mount(tips) + + # Buttons + button_container = Horizontal() + button_container.styles.margin = (2, 2) + button_container.styles.align = ("center", "middle") + content.mount(button_container) + + continue_btn = Button( + "Continue to Policy Selection", id="start_workflow", variant="success" + ) + cancel_btn = Button("Cancel", id="cancel_workflow", variant="default") + + button_container.mount(continue_btn) + button_container.mount(cancel_btn) + + def _show_source_policy_selection(self) -> None: + """Show the source policy selection screen.""" + self.workflow_stage = "select_source" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + instruction = Static("Select source policies (click rows to toggle selection):") + instruction.styles.margin = (0, 1, 0, 1) + content.mount(instruction) + + # Create a DataTable for multi-select + table = DataTable(id="source_policy_table") + table.styles.height = "30vh" + table.styles.overflow_y = "auto" + table.cursor_type = "row" + table.zebra_stripes = True + + # Add columns - checkbox first, then data columns + table.add_columns("○", "Name", "ID", "Parent") + + # Sort policies by name for easier selection + sorted_policies = sorted(self.policies, key=lambda p: p.name.lower()) + + # Add rows + for policy in sorted_policies: + # Skip parent policies + if policy.parent == "global-policy-settings": + continue + checkbox = "○" # All start unchecked + table.add_row( + checkbox, + policy.name, + str(policy.groupid), + policy.parent or "N/A", + key=str(policy.groupid), + ) + + content.mount(table) + + # CRITICAL: Prevent default first-row selection + # Move focus away from the table so no row is highlighted initially + try: + content.focus() + except Exception as e: + logger.debug(f"Could not clear table focus: {e}") + + # Control buttons + control_container = Horizontal() + control_container.styles.height = "auto" + control_container.styles.margin = (0, 1) + + # Mount the container first + content.mount(control_container) + + # Then add buttons to it + + select_none_btn = Button("Clear Selection", id="select_none_source") + select_none_btn.styles.width = "1fr" + select_none_btn.styles.margin = (0, 1, 0, 0) + + continue_btn = Button( + "→ Continue", id="continue_source_selection", variant="primary" + ) + continue_btn.styles.width = "1fr" + continue_btn.styles.margin = (0, 0, 0, 1) + + control_container.mount(select_none_btn) + control_container.mount(continue_btn) + + # Track selected policies + if not hasattr(self, "selected_source_policy_ids"): + self.selected_source_policy_ids = set() + else: + self.selected_source_policy_ids.clear() + + def _show_destination_policy_selection(self) -> None: + """Show the destination policy selection screen.""" + self.workflow_stage = "select_destination" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + instruction = Static( + f"Selected Source: {', '.join([p.name for p in self.source_policies])}\n\n" + "Select the destination policy for enforcement:" + ) + instruction.styles.margin = (0, 1, 1, 1) + content.mount(instruction) + + # Create policy selector with policies sorted alphabetically by name + sorted_policies = sorted(self.policies, key=lambda p: p.name.lower()) + policy_selector = PolicySelector(sorted_policies) + content.mount(policy_selector) + + def _show_allowlist_selection(self) -> None: + """Show the allowlist selection screen.""" + self.workflow_stage = "select_allowlist" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + instruction = Static( + f"Destination Policy: {self.destination_policy.name}\n\n" + "Select the allowlist to use:" + ) + instruction.styles.margin = (0, 1, 1, 1) + content.mount(instruction) + + # Fetch allowlists for the destination policy + try: + allowlists_df = self.api.policy_list_allowlists( + self.destination_policy.groupid + ) + allowlists = [ + Allowlist(**row.to_dict()) for _, row in allowlists_df.iterrows() + ] + + if not allowlists: + content.mount( + Static("No allowlists found for this policy!", id="no_allowlists") + ) + return + + # Sort allowlists alphabetically by name + allowlists = sorted(allowlists, key=lambda al: al.name.lower()) + + # Add instruction + instruction = Static("Click a row to select the allowlist for this policy:") + instruction.styles.margin = (0, 1, 1, 1) + content.mount(instruction) + + # Create table for allowlist selection + table = DataTable(id="allowlist_table") + table.styles.height = "auto" + table.styles.max_height = "50%" + table.cursor_type = "row" + + table.add_columns("ID", "Name", "Version") + for al in allowlists: + table.add_row(str(al.applicationid), al.name, str(al.version)) + + content.mount(table) + + # Store allowlists for reference + self.allowlists = allowlists + + except Exception as e: + logger.error(f"Failed to fetch allowlists: {e}") + content.mount(Static(f"Error fetching allowlists: {str(e)}")) + + def _show_fetch_data(self) -> None: + """Show the data fetching options screen.""" + self.workflow_stage = "fetch_data" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Validate we have source policies before showing this screen + if not hasattr(self, "source_policies") or not self.source_policies: + logger.error("_show_fetch_data called but no source_policies set!") + self.app.notify( + "Error: No source policies selected. Returning to policy selection.", + severity="error", + ) + self._show_source_policy_selection() + return + + instruction = Static( + f"Ready to fetch execution history from: {', '.join([p.name for p in self.source_policies])}\n\n" + "Enter the number of days of history to fetch (or press Enter to use default 150):" + ) + instruction.styles.margin = (0, 1, 1, 1) + content.mount(instruction) + + # Days input + days_container = Horizontal() + days_container.styles.margin = (1, 1) + days_container.styles.height = "auto" + content.mount(days_container) + + days_label = Static("History Days (1-365): ") + days_label.styles.width = "auto" + + days_input = Input( + value="150", placeholder="150", id="history_days_input", type="integer" + ) + days_input.styles.width = 30 + days_input.styles.min_width = 20 + + days_container.mount(days_label) + days_container.mount(days_input) + + # Type selection + type_instruction = Static("\nSelect execution types to include:") + type_instruction.styles.margin = (1, 1, 0, 1) + content.mount(type_instruction) + + type_info = Static( + "Default: Types 1, 2, 6, 7 (Standard executions)\n" + "You can customize this if needed." + ) + type_info.styles.margin = (0, 1, 1, 1) + type_info.styles.text_style = "dim" + content.mount(type_info) + + # Fetch button + button_container = Horizontal() + button_container.styles.margin = (2, 1) + content.mount(button_container) + + fetch_btn = Button("Fetch Data", id="fetch_data_btn", variant="primary") + fetch_btn.styles.margin = (0, 1, 0, 0) + + skip_btn = Button("Skip (Use Existing)", id="skip_fetch_btn") + + button_container.mount(fetch_btn) + button_container.mount(skip_btn) + + # Set focus to the input field so it's ready for typing + def focus_input(): + try: + days_input.focus() + except Exception as e: + logger.debug(f"Could not focus input: {e}") + + self.call_after_refresh(focus_input) + + def _fetch_execution_data(self, history_days: int) -> None: + """Fetch and sort execution data.""" + self.workflow_stage = "fetching" + + # Show notification that fetch is starting + self.app.notify( + "Starting data fetch - this may take 5-30 minutes for large policies with millions of executions", + severity="information", + timeout=5, + ) + + # Clear the screen to provide a blank canvas for Rust progress output + # (Rust output displays over the TUI, so we clear everything except header/footer) + try: + # Clear title + title_widget = self.query_one("#workflow_title", Static) + title_widget.update("") + + # Clear status + status_widget = self.query_one("#workflow_status", Static) + status_widget.update("") + + # Clear content area + content = self.query_one("#content_area", Vertical) + content.remove_children() + except Exception as e: + logger.debug(f"Could not clear screen for fetch: {e}") + + # Delay the fetch start to ensure UI refresh completes first + # This prevents Rust output from starting before the screen is cleared + self.set_timer(0.5, lambda: self._perform_fetch(history_days)) + + def _perform_fetch(self, history_days: int) -> None: + """Perform the actual data fetching.""" + try: + # Validate we have source policies + logger.info(f"_perform_fetch called with history_days={history_days}") + logger.info( + f"self.source_policies exists: {hasattr(self, 'source_policies')}" + ) + + if hasattr(self, "source_policies"): + logger.info(f"self.source_policies type: {type(self.source_policies)}") + logger.info( + f"self.source_policies length: {len(self.source_policies) if self.source_policies else 0}" + ) + if self.source_policies: + logger.info( + f"First policy: {self.source_policies[0].name if self.source_policies else 'N/A'}" + ) + + if ( + not hasattr(self, "source_policies") + or not self.source_policies + or len(self.source_policies) == 0 + ): + logger.error( + f"No source policies selected. hasattr={hasattr(self, 'source_policies')}, value={getattr(self, 'source_policies', 'ATTR_MISSING')}" + ) + self.app.notify("No source policies selected!", severity="error") + self._show_fetch_data() + return + + logger.info( + f"Starting fetch for {len(self.source_policies)} policies, {history_days} days of history" + ) + + # Fetch execution history + policy_executions = ExecutionHistoryRecord.from_policies( + self.api, + self.source_policies, + type_=[1, 2, 6, 7], + history_days=history_days, + ) + + logger.info(f"Fetched {len(policy_executions)} total execution records") + + if not policy_executions: + logger.warning("No execution records returned from API") + self.app.notify( + f"No execution history found for the last {history_days} days", + severity="warning", + ) + self._show_fetch_data() + return + + # Enrich with hash data + logger.info("Enriching executions with hash data...") + enriched_executions = ExecutionHistoryRecord.enrich_with_hashes( + self.api, policy_executions + ) + + # Categorize by hash decision + logger.info("Categorizing executions by hash decision...") + categorized_executions = ( + ExecutionHistoryRecord.categorize_executions_by_hash_decision( + enriched_executions + ) + ) + + # Sort by decision + logger.info("Sorting executions by decision...") + approved, unapproved, needs_review, unknown = ( + ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions) + ) + + logger.info( + f"Sorted: {len(approved)} approved, {len(unapproved)} unapproved, " + f"{len(needs_review)} needs review, {len(unknown)} unknown" + ) + + # Store the data - convert all unhashable objects to strings + self.approved_df = self._sanitize_dataframe( + pd.DataFrame([r.__dict__ for r in approved]) + if approved + else pd.DataFrame() + ) + self.unapproved_df = self._sanitize_dataframe( + pd.DataFrame([r.__dict__ for r in unapproved]) + if unapproved + else pd.DataFrame() + ) + self.needs_review_df = self._sanitize_dataframe( + pd.DataFrame([r.__dict__ for r in needs_review]) + if needs_review + else pd.DataFrame() + ) + + # Save to files + logger.info("Saving fetched data to files...") + self._save_fetched_data() + + # Show results + logger.info("Showing results...") + self._show_fetch_results() + + except Exception as e: + logger.error(f"Failed to fetch execution data: {e}", exc_info=True) + self.app.notify(f"Failed to fetch data: {str(e)}", severity="error") + self._show_fetch_data() + + def _sanitize_dataframe(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Convert any unhashable objects (like custom Hash objects) to strings. + This prevents 'unhashable type' errors in pandas operations like drop_duplicates(). + + Args: + df: DataFrame that may contain unhashable objects + + Returns: + Sanitized DataFrame with all objects converted to hashable types + """ + if df.empty: + return df + + df = df.copy() + for col in df.columns: + if df[col].dtype == "object": + try: + # Check if column contains unhashable custom objects + sample = df[col].iloc[0] if len(df) > 0 else None + if sample is not None: + # Try to hash it - if it fails, convert to string + try: + hash(sample) + except TypeError: + # Unhashable type - convert entire column to string + df[col] = df[col].astype(str) + logger.debug( + f"Converted unhashable column '{col}' to strings" + ) + except Exception as e: + logger.debug(f"Error checking column '{col}': {e}") + + return df + + def _save_fetched_data(self) -> None: + """Save fetched data to CSV files.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + review_dir = os.path.join(self.working_dir, "Needs_Review", "Review_First") + + os.makedirs(review_dir, exist_ok=True) + + # Save each category + categories = { + "approved": self.approved_df, + "needs_review": self.needs_review_df, + "unapproved": self.unapproved_df, + } + + for label, df in categories.items(): + if df is not None and not df.empty: + csv_path = os.path.join( + review_dir, f"{policy_name}_{label}_executions.csv" + ) + + df.to_csv(csv_path, index=False) + + logger.info(f"Saved {label} executions to {csv_path}") + + def _show_fetch_results(self) -> None: + """Show the results of data fetching.""" + logger.debug("=== _show_fetch_results called ===") + self.workflow_stage = "first_review" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + logger.info( + f"Approved count: {len(self.approved_df) if self.approved_df is not None else 0}" + ) + logger.info( + f"Needs review count: {len(self.needs_review_df) if self.needs_review_df is not None else 0}" + ) + logger.info( + f"Unapproved count: {len(self.unapproved_df) if self.unapproved_df is not None else 0}" + ) + + # Results summary + approved_count = len(self.approved_df) if self.approved_df is not None else 0 + review_count = ( + len(self.needs_review_df) if self.needs_review_df is not None else 0 + ) + unapproved_count = ( + len(self.unapproved_df) if self.unapproved_df is not None else 0 + ) + + summary = Static( + f"Data Fetch Complete!\n\n" + f"Approved: {approved_count} executions\n" + f"Needs Review: {review_count} executions\n" + f"Unapproved: {unapproved_count} executions (automatically excluded)\n" + ) + summary.styles.margin = (1, 1) + content.mount(summary) + + logger.info("Mounted summary widget") + + # Tab selection for review + tab_container = Horizontal() + tab_container.styles.margin = (1, 1) + content.mount(tab_container) + + approved_tab_btn = Button( + "Review Approved", id="show_approved_tab", variant="primary" + ) + approved_tab_btn.styles.margin = (0, 1, 0, 0) + + review_tab_btn = Button("Review Needs Review", id="show_needs_review_tab") + + tab_container.mount(approved_tab_btn) + tab_container.mount(review_tab_btn) + + logger.info("Mounted tab buttons") + + # Show approved table by default + logger.debug("About to call _show_review_table('approved')") + self._show_review_table("approved") + logger.debug("=== _show_fetch_results complete ===") + + def _show_review_table(self, table_type: str) -> None: + """Show an editable DataTable for reviewing executions.""" + # Prevent concurrent execution + if self._creating_review_table: + logger.warning( + f"Already creating review table, ignoring duplicate call for {table_type}" + ) + return + + self._creating_review_table = True + + # Disable all tab buttons to prevent race conditions + self._disable_tab_buttons() + + try: + self._show_review_table_impl(table_type) + finally: + self._creating_review_table = False + # Re-enable tab buttons after table is created + self._enable_tab_buttons() + + def _disable_tab_buttons(self) -> None: + """Disable all tab switching buttons to prevent race conditions.""" + try: + content = self.query_one("#content_area", Vertical) + tab_buttons = content.query("Button") + for btn in tab_buttons: + if btn.id in [ + "show_approved_tab", + "show_needs_review_tab", + "show_paths_tab", + "show_publishers_tab", + "show_remaining_tab", + ]: + btn.disabled = True + logger.debug(f"Disabled button: {btn.id}") + except Exception as e: + logger.debug(f"Error disabling tab buttons: {e}") + + def _enable_tab_buttons(self) -> None: + """Re-enable all tab switching buttons.""" + try: + content = self.query_one("#content_area", Vertical) + tab_buttons = content.query("Button") + for btn in tab_buttons: + if btn.id in [ + "show_approved_tab", + "show_needs_review_tab", + "show_paths_tab", + "show_publishers_tab", + "show_remaining_tab", + ]: + btn.disabled = False + logger.debug(f"Enabled button: {btn.id}") + except Exception as e: + logger.debug(f"Error enabling tab buttons: {e}") + + def _show_review_table_impl(self, table_type: str) -> None: + """Internal implementation of _show_review_table.""" + logger.debug(f"_show_review_table called with type: {table_type}") + content = self.query_one("#content_area", Vertical) + + # Mark tab as reviewed + if table_type == "approved": + self.approved_tab_reviewed = True + logger.debug("Marked approved tab as reviewed") + else: + self.needs_review_tab_reviewed = True + logger.debug("Marked needs_review tab as reviewed") + + # Determine which dataframe and table ID to show + if table_type == "approved": + df = self.approved_df + table_id = "approved_review_table" + title = "Approved Executions - Select rows to REMOVE:" + else: + df = self.needs_review_df + table_id = "needs_review_table" + title = "Needs Review Executions - Select rows to REMOVE:" + + # Sort DataFrame by filename (case-insensitive) and save back + if df is not None and not df.empty and "filename" in df.columns: + df = df.sort_values(by="filename", key=lambda x: x.str.lower()) + # Save sorted DataFrame back + if table_type == "approved": + self.approved_df = df + else: + self.needs_review_df = df + + # Remove the SPECIFIC table we're about to create if it exists + # Buttons are now disabled during creation, so this should be quick + try: + existing_specific = content.query_one(f"#{table_id}", DataTable) + if existing_specific: + logger.debug(f"Removing existing table with ID: {table_id}") + existing_specific.remove() + content.refresh(layout=True) + except Exception as e: + # Table doesn't exist - good! + logger.debug(f"No existing table with ID {table_id} found: {e}") + + # Remove ALL existing DataTables to be safe + try: + existing_tables = content.query("DataTable") + if existing_tables: + logger.debug(f"Found {len(existing_tables)} existing tables to remove") + for table in existing_tables: + logger.debug(f"Removing table: {table.id}") + try: + table.remove() + except Exception as e: + logger.debug(f"Error removing table {table.id}: {e}") + content.refresh(layout=True) + except Exception as e: + logger.debug(f"Error querying/removing tables: {e}") + + # Remove existing instruction and help text + try: + existing_statics = content.query("Static") + logger.debug( + f"Found {len(existing_statics)} existing Static widgets to remove" + ) + for static in existing_statics: + try: + static.remove() + except Exception as e: + logger.debug(f"Error removing Static: {e}") + except Exception as e: + logger.debug(f"Error removing Static widgets: {e}") + + # Final refresh to ensure all removals are processed + try: + content.refresh(layout=True) + import time + + time.sleep(0.2) # Give DOM time to process removals (200ms) + except Exception as e: + logger.debug(f"Error refreshing content: {e}") + + # Note: We no longer remove review_controls or review_continue_container + # They are reused between tabs to avoid DuplicateIds errors + + logger.debug( + f"DataFrame for {table_type}: {'empty' if df is None or df.empty else f'{len(df)} rows'}" + ) + + if df is None or df.empty: + empty_msg = Static(f"No {table_type} executions to review") + empty_msg.styles.margin = (2, 1) + content.mount(empty_msg) + logger.info(f"No data for {table_type}, mounted empty message") + return + + # Instructions (no ID needed - we remove all Statics anyway) + instruction = Static(title) + instruction.styles.margin = (1, 1) + instruction.styles.text_style = "bold" + content.mount(instruction) + + # Help text (no ID needed) + help_text = Static( + "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" + "Space to toggle cursor row, 'd' to delete, 'c' to copy, 'a' select all, arrows navigate" + ) + help_text.styles.margin = (0, 1, 1, 1) + help_text.styles.text_style = "dim" + content.mount(help_text) + + # Final safety check - WAIT until table is confirmed gone + # Use longer waits (1-2 seconds) to ensure DOM has time to process + max_wait_attempts = 5 + for wait_attempt in range(max_wait_attempts): + try: + existing_check = content.query_one(f"#{table_id}", DataTable) + if existing_check: + logger.warning( + f"Table {table_id} still exists (attempt {wait_attempt + 1}/{max_wait_attempts}). Removing and waiting..." + ) + existing_check.remove() + content.refresh(layout=True) + import time + + time.sleep(0.5) # Wait 0.5s for DOM to process + else: + # Table is gone, break out + logger.debug( + f"Table {table_id} confirmed removed after {wait_attempt} attempts" + ) + break + except Exception: + # Good - table doesn't exist, break out + logger.debug(f"Table {table_id} not found (good)") + break + + # Final verification - if table STILL exists after all attempts, force remove it and wait longer + try: + final_check = content.query_one(f"#{table_id}", DataTable) + if final_check: + logger.error( + f"CRITICAL: Table {table_id} still exists after {max_wait_attempts} attempts!" + ) + final_check.remove() + content.refresh(layout=True) + import time + + time.sleep(1.0) # Wait a full second + + # Check one more time + try: + still_there = content.query_one(f"#{table_id}", DataTable) + if still_there: + # This should never happen - log and skip mounting + logger.error( + f"FATAL: Cannot remove {table_id} even after 1 second wait. Skipping mount to prevent DuplicateIds." + ) + self.app.notify( + "Table refresh failed. Please try again.", severity="error" + ) + return + except Exception: + # Good - finally gone + logger.info(f"Table {table_id} finally removed after extended wait") + pass + except Exception: + # Good - table doesn't exist + pass + + # Create the review table + review_table = DataTable(id=table_id) + review_table.styles.height = "40vh" # Increased since we removed button rows + review_table.cursor_type = "row" # Need row cursor for clicking/navigation + review_table.zebra_stripes = True + + # Specified columns in order + important_cols = [ + "policyname", + "policyver", + "hostname", + "username", + "publisher", + "filename", + "pprocess", + "gprocess", + "sha256", + "commandline", + ] + available_cols = [col for col in important_cols if col in df.columns] + + if available_cols: + # Add checkbox column first + review_table.add_columns("○", *available_cols) + + # Add rows with row keys for tracking + for idx, row in df.iterrows(): + checkbox = "○" # All start unchecked + row_data = [str(row.get(col, "")) for col in available_cols] + review_table.add_row(checkbox, *row_data, key=str(idx)) + + logger.debug(f"About to mount {table_id}") + + # Final safety check - make sure no table with this ID exists before mounting + try: + final_check = content.query_one(f"#{table_id}", DataTable) + if final_check: + logger.warning( + f"Table {table_id} still exists. Waiting and removing..." + ) + final_check.remove() + content.refresh(layout=True) + import time + + time.sleep(0.05) + except Exception: + # Good - table doesn't exist + pass + + content.mount(review_table) + logger.debug(f"Successfully mounted {table_id} with {len(df)} rows") + + # CRITICAL: Prevent default first-row selection + # Textual auto-focuses new DataTables, causing first row to be selected + # Solution: temporarily disable focus, then re-enable + try: + review_table.can_focus = False + # Schedule re-enabling focus after UI settles + self.set_timer(0.1, lambda: setattr(review_table, "can_focus", True)) + logger.debug("Disabled initial table focus to prevent default selection") + except Exception as e: + logger.debug(f"Could not prevent default focus: {e}") + + # Row count display only (removed Select All, Clear, Delete buttons) + try: + control_container = content.query_one("#review_controls", Horizontal) + # Clear existing content + control_container.remove_children() + except Exception: + # Doesn't exist, create it + control_container = Horizontal(id="review_controls") + control_container.styles.margin = (1, 1) + control_container.styles.height = "auto" + control_container.styles.min_height = 1 + content.mount(control_container) + + row_count = Static(f"Total rows: {len(df)}") + row_count.styles.margin = (0, 1, 0, 1) + + control_container.mount(row_count) + + # Continue button (always at bottom) + if not content.query("#review_continue_container"): + continue_container = Horizontal(id="review_continue_container") + continue_container.styles.margin = (2, 1, 1, 1) + continue_container.styles.height = "auto" + continue_container.styles.min_height = 3 + # Removed dock="bottom" - was hiding content above + + # Mount the container to the content area FIRST + content.mount(continue_container) + + # NOW mount buttons into the container + export_btn = Button("Export to CSV", id="export_review") + export_btn.styles.margin = (0, 1, 0, 0) + + continue_btn = Button( + "→ Finish Review & Continue", + id="continue_from_review", + variant="success", + ) + + continue_container.mount(export_btn) + continue_container.mount(continue_btn) + + # Track selected rows + if not hasattr(self, "selected_rows"): + self.selected_rows = set() + else: + self.selected_rows.clear() + + # Store current review type + self.current_review_type = table_type + + def _delete_selected_rows(self) -> None: + """Delete selected rows from the current dataframe.""" + if not hasattr(self, "selected_rows") or not self.selected_rows: + self.app.notify("No rows selected for deletion", severity="warning") + return + + # Determine which dataframe to modify + if self.current_review_type == "approved": + df = self.approved_df + table_id = "approved_review_table" + else: + df = self.needs_review_df + table_id = "needs_review_table" + + if df is None: + return + + # Get the table + try: + content = self.query_one("#content_area", Vertical) + table = content.query_one(f"#{table_id}", DataTable) + except Exception as e: + logger.error(f"Could not find table {table_id}: {e}") + return + + # Get indices to delete + indices_to_delete = [int(idx) for idx in self.selected_rows] + + # Remove rows from DataFrame + df_filtered = df.drop(index=indices_to_delete, errors="ignore") + + # Update the dataframe + if self.current_review_type == "approved": + self.approved_df = df_filtered + else: + self.needs_review_df = df_filtered + + # Remove rows from DataTable (don't rebuild entire table) + removed_count = 0 + failed_keys = [] + for idx in self.selected_rows: + try: + # Try to remove the row using the key + table.remove_row(idx) + removed_count += 1 + except Exception as e: + # Log but continue - some keys might not exist after DataFrame operations + logger.debug(f"Could not remove row {idx}: {e}") + failed_keys.append(idx) + + # Clear selection + self.selected_rows.clear() + + # Notify user + if failed_keys: + self.app.notify( + f"Deleted {removed_count} rows ({len(failed_keys)} already removed)", + severity="information", + ) + else: + self.app.notify(f"Deleted {removed_count} rows", severity="information") + + def _copy_selected_rows(self) -> None: + """Copy selected rows to clipboard as tab-separated values.""" + if not hasattr(self, "selected_rows") or not self.selected_rows: + self.app.notify("No rows selected to copy", severity="warning") + return + + # Determine which dataframe to use + if self.current_review_type == "approved": + df = self.approved_df + else: + df = self.needs_review_df + + if df is None: + return + + # Get selected rows + indices_to_copy = [int(idx) for idx in self.selected_rows] + selected_df = df.loc[df.index.isin(indices_to_copy)] + + if selected_df.empty: + self.app.notify("No valid rows to copy", severity="warning") + return + + # Convert to dictionary format (list of dicts) + try: + # Convert DataFrame to list of dictionaries + rows_as_dicts = selected_df.to_dict("records") + + # Format as Python dictionary representation + import json + + dict_data = json.dumps(rows_as_dicts, indent=2) + + # Copy to clipboard + import platform + import subprocess + + if platform.system() == "Windows": + # Windows clipboard + subprocess.run(["clip"], input=dict_data.encode("utf-8"), check=True) + elif platform.system() == "Darwin": + # macOS clipboard + subprocess.run(["pbcopy"], input=dict_data.encode("utf-8"), check=True) + else: + # Linux clipboard (try xclip first, then xsel) + try: + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=dict_data.encode("utf-8"), + check=True, + ) + except FileNotFoundError: + subprocess.run( + ["xsel", "--clipboard", "--input"], + input=dict_data.encode("utf-8"), + check=True, + ) + + self.app.notify( + f"Copied {len(selected_df)} rows as JSON", severity="information" + ) + except Exception as e: + logger.error(f"Failed to copy to clipboard: {e}") + self.app.notify(f"Failed to copy: {str(e)}", severity="error") + + def _show_path_building_screen(self) -> None: + """Show loading screen before building paths and publishers.""" + self.workflow_stage = "building_paths" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Add spacer to push text to bottom + spacer = Static("") + spacer.styles.height = "1fr" + content.mount(spacer) + + # Loading message at bottom (above checklist) + status = Static( + "Building path exclusions and publisher lists...\n" + "This may take a moment for large datasets." + ) + status.styles.margin = (1, 1) + status.styles.text_align = "center" + status.styles.color = "cyan" + content.mount(status) + + # Force UI refresh to show the loading screen + content.refresh() + + # Schedule the actual build to happen after UI updates + # Using set_timer with a small delay ensures the screen renders + self.set_timer(0.1, self._perform_path_build) + + def _build_paths_and_publishers(self) -> None: + """Build path exclusions and publisher lists.""" + # Note: This is now bypassed - we go straight from _show_path_building_screen to _perform_path_build + self.call_later(self._perform_path_build) + + def _perform_path_build(self) -> None: + """Perform the actual path and publisher building.""" + try: + if not self.source_policies: + raise ValueError("No source policies selected") + + # CRITICAL FIX: Use current in-memory DataFrames, not stale CSV files + # This ensures that any deletions made in the review step are reflected + df1 = self.approved_df if self.approved_df is not None else pd.DataFrame() + df2 = ( + self.needs_review_df + if self.needs_review_df is not None + else pd.DataFrame() + ) + + # If DataFrames are empty, try loading from saved files as fallback + if df1.empty and df2.empty: + logger.info("DataFrames empty, attempting to load from saved files...") + policy_name = self.source_policies[0].name + approved_path = os.path.join( + self.working_dir, + "Approved", + f"{policy_name}_approved_executions.csv", + ) + review_path = os.path.join( + self.working_dir, + "Approved", + f"{policy_name}_needs_review_executions.csv", + ) + + df1 = ( + pd.read_csv(approved_path) + if os.path.exists(approved_path) + else pd.DataFrame() + ) + df2 = ( + pd.read_csv(review_path) + if os.path.exists(review_path) + else pd.DataFrame() + ) + + if df1.empty and df2.empty: + self.app.notify( + "No approved files found! Please complete first review.", + severity="error", + ) + self._show_fetch_results() + return + + # Combine dataframes + all_approved = pd.concat([df1, df2], ignore_index=True) + if "filename" in all_approved.columns: + all_approved = all_approved.sort_values(by="filename") + + # Calculate paths + path_exclusion_const = get_system_value( + "PATH_EXCLUSION_CONST", cast_type=int + ) + if path_exclusion_const: + # Primary paths + self.primary_paths_df = self._calculate_paths( + all_approved, path_exclusion_const + ) + + # Secondary paths + if ( + not self.primary_paths_df.empty + and "sha256" in self.primary_paths_df.columns + ): + remaining = all_approved[ + ~all_approved["sha256"].isin(self.primary_paths_df["sha256"]) + ] + self.secondary_paths_df = self._calculate_paths( + remaining, path_exclusion_const - 1 + ) + else: + # If primary paths are empty, all remaining go to secondary + logger.warning( + "Primary paths DataFrame is empty or missing sha256 column" + ) + self.secondary_paths_df = pd.DataFrame() + remaining = all_approved + + # Remaining hashes + if ( + not self.secondary_paths_df.empty + and "sha256" in self.secondary_paths_df.columns + ): + self.remaining_hashes_df = remaining[ + ~remaining["sha256"].isin(self.secondary_paths_df["sha256"]) + ] + else: + logger.warning( + "Secondary paths DataFrame is empty or missing sha256 column" + ) + self.remaining_hashes_df = remaining + + # Extract publishers + if not all_approved.empty: + publist = all_approved[ + all_approved["publisher"] != "Not Signed" + ].drop_duplicates(subset=["publisher"]) + + # Remove bad publishers + bad_publishers = get_system_list("BAD_PUBLISHERS") + if bad_publishers: + pattern = "|".join(bad_publishers) + publist = publist[ + ~publist["publisher"].str.contains( + pattern, case=False, na=False, regex=True + ) + ] + + self.publishers_df = publist + + # Sort publishers alphabetically + if ( + not self.publishers_df.empty + and "publisher" in self.publishers_df.columns + ): + self.publishers_df = self.publishers_df.sort_values( + by="publisher", ascending=True + ).reset_index(drop=True) + + # Sort publishers alphabetically + if ( + not self.publishers_df.empty + and "publisher" in self.publishers_df.columns + ): + self.publishers_df = self.publishers_df.sort_values( + by="publisher", ascending=True + ).reset_index(drop=True) + + # Save to Review_Second folder + self._save_path_data() + + # Show results + self._show_path_results() + + except Exception as e: + logger.error(f"Failed to build paths: {e}", exc_info=True) + self.app.notify(f"Failed to build paths: {str(e)}", severity="error") + self._show_fetch_results() + + def _regulator(self, string_list: List[str], case_insensitive: bool = True) -> str: + """ + Create a regex pattern from a list of strings. + + Args: + string_list: List of strings to create pattern from + case_insensitive: Whether to make pattern case insensitive + + Returns: + Regex pattern string that matches any of the input strings + """ + if not string_list: + return "" + + # Escape special regex characters in each string + escaped = [re.escape(s) for s in string_list] + + # Join with | (OR operator) + pattern = "|".join(escaped) + + return pattern + + def _split_filepaths_grouped( + self, df: pd.DataFrame, path_exclusion_constant: int, col: str = "filename" + ) -> pd.DataFrame: + """ + Split filepaths, group by common prefix, and extract metadata. + + This is a port of the splitFilepathsGrouped function from prepPolicy.py. + + Args: + df: DataFrame with filepath column + path_exclusion_constant: Depth for path truncation + col: Column name containing filepaths + + Returns: + DataFrame with columns: longestcfp, middle, filename_only, file_extension, + plus all original columns + """ + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) + + def clean_split(path): + """Split a path into parts, handling various input types.""" + 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 + + # Check for non-string entries + non_string_entries = df[ + ~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike))) + ] + if not non_string_entries.empty: + logger.warning( + f"Non-string entries found in column '{col}': {len(non_string_entries)}" + ) + + df = df.copy() + split_paths = df[col].apply(clean_split) + + # Filter by minimum path length if configured + 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] + + # Group by path prefix + 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): + """Find the longest common prefix among a list of path parts.""" + 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) + + # Process each file in the group + 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) + + result = pd.DataFrame(new_rows).drop(columns=["group_key"]) + logger.info( + f"_split_filepaths_grouped: Processed {len(df)} rows → {len(result)} rows with metadata" + ) + return result + + def _calculate_paths( + self, df: pd.DataFrame, path_exclusion_constant: int + ) -> pd.DataFrame: + """ + Calculate path exclusions with full metadata including extensions and hash counts. + + This is a port of the calculatePath function from prepPolicy.py. + + Args: + df: DataFrame with execution data + path_exclusion_constant: Depth for path truncation + + Returns: + DataFrame with columns: policyname, longestcfp, middle, filename_only, + file_extension, sha256, unique_sha256_count + """ + logger.info( + f"=== _calculate_paths called with path_exclusion_constant={path_exclusion_constant} ===" + ) + logger.debug(f"Input DataFrame: {len(df)} rows") + logger.debug(f"Columns: {list(df.columns) if not df.empty else 'empty'}") + + if df.empty: + logger.warning("Input DataFrame is empty") + return pd.DataFrame() + + # Use 'filename' column (which typically contains full path) + if "filename" not in df.columns: + logger.error("'filename' column not found in DataFrame") + return pd.DataFrame() + + # Split filepaths and extract metadata + haslcp = self._split_filepaths_grouped(df, path_exclusion_constant, "filename") + haslcp = haslcp.drop_duplicates() + + logger.debug(f"After split_filepaths_grouped: {len(haslcp)} rows") + + # Filter forbidden paths + badpathparts = get_system_list("BAD_PATH_PARTS") + if badpathparts: + forbidden_pattern = self._regulator(badpathparts, True) + forbidden_lcfp = haslcp["longestcfp"].str.contains( + forbidden_pattern, case=False, na=False, regex=True + ) + + logger.debug( + f"Removing forbidden filepaths: {forbidden_lcfp.sum()} paths filtered" + ) + lcp_not_forbidden = haslcp[~forbidden_lcfp].copy() + else: + logger.info( + "No BAD_PATH_PARTS configured, skipping forbidden path filtering" + ) + lcp_not_forbidden = haslcp.copy() + + logger.debug(f"After forbidden filtering: {len(lcp_not_forbidden)} rows") + + # Select relevant columns + if "policyname" in lcp_not_forbidden.columns: + columns_to_keep = [ + "policyname", + "longestcfp", + "middle", + "filename_only", + "file_extension", + "sha256", + ] + else: + # If no policyname, skip it + columns_to_keep = [ + "longestcfp", + "middle", + "filename_only", + "file_extension", + "sha256", + ] + + # Only keep columns that exist + columns_to_keep = [ + col for col in columns_to_keep if col in lcp_not_forbidden.columns + ] + lcp_not_forbidden_review = lcp_not_forbidden[columns_to_keep] + + # Count unique SHA256s per path + unique_sha_counts = ( + lcp_not_forbidden_review.groupby("longestcfp")["sha256"] + .nunique() + .reset_index() + ) + unique_sha_counts.columns = ["longestcfp", "unique_sha256_count"] + + logger.info( + f"Calculated unique SHA256 counts for {len(unique_sha_counts)} paths" + ) + + # Merge counts back into main DataFrame + lcp_not_forbidden_review = lcp_not_forbidden_review.merge( + unique_sha_counts, on="longestcfp", how="left" + ) + + # Filter by minimum files per path + min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int) + if min_files_for_path is not None: + before_filter = len(lcp_not_forbidden_review) + lcp_not_forbidden_review = lcp_not_forbidden_review[ + lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path + ] + logger.debug( + f"After MIN_FILES_FOR_PATH filter ({min_files_for_path}): {len(lcp_not_forbidden_review)} rows (removed {before_filter - len(lcp_not_forbidden_review)})" + ) + + logger.debug( + f"Final result: {len(lcp_not_forbidden_review)} rows with columns: {list(lcp_not_forbidden_review.columns)}" + ) + + return lcp_not_forbidden_review + + def _save_path_data(self) -> None: + """Save path and publisher data to files.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + review_dir = os.path.join(self.working_dir, "Needs_Review", "Review_Second") + + os.makedirs(review_dir, exist_ok=True) + + # Save each dataframe + dataframes = { + "primary_paths": self.primary_paths_df, + "secondary_paths": self.secondary_paths_df, + "publishers": self.publishers_df, + "remaining_hashes": self.remaining_hashes_df, + } + + for name, df in dataframes.items(): + if df is not None and not df.empty: + csv_path = os.path.join(review_dir, f"{policy_name}_{name}.csv") + + df.to_csv(csv_path, index=False) + + logger.info(f"Saved {name} to {csv_path}") + + def _show_fetch_data_after_clearing_paths(self) -> None: + """Navigate back to fetch data screen and clear path data for regeneration.""" + logger.info( + "Going back to first review - clearing path data to force regeneration" + ) + # Clear path-related dataframes so they get regenerated with current data + self.primary_paths_df = None + self.secondary_paths_df = None + self.publishers_df = None + self.remaining_hashes_df = None + # Reset path review tracking + self.paths_tab_reviewed = False + self.publishers_tab_reviewed = False + # Now show the fetch data screen + self._show_fetch_data() + + def _show_path_results(self) -> None: + """Show the results of path building.""" + logger.debug("=== _show_path_results called ===") + self.workflow_stage = "second_review" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Results summary + primary_count = ( + len(self.primary_paths_df) if self.primary_paths_df is not None else 0 + ) + secondary_count = ( + len(self.secondary_paths_df) if self.secondary_paths_df is not None else 0 + ) + publishers_count = ( + len(self.publishers_df) if self.publishers_df is not None else 0 + ) + + logger.info(f"Primary paths: {primary_count}") + logger.info(f"Secondary paths: {secondary_count}") + logger.info(f"Publishers: {publishers_count}") + + summary = Static( + f"Path Analysis Complete!\n\n" + f"Primary Paths: {primary_count}\n" + f"Secondary Paths: {secondary_count}\n" + f"Publishers: {publishers_count}" + ) + summary.styles.margin = (1, 1) + content.mount(summary) + logger.info("Mounted summary widget") + + # Tab selection for different review types + tab_container = Horizontal() + tab_container.styles.margin = (1, 1) + content.mount(tab_container) + + paths_tab_btn = Button("Review Paths", id="show_paths_tab", variant="primary") + paths_tab_btn.styles.margin = (0, 1, 0, 0) + + publishers_tab_btn = Button("Review Publishers", id="show_publishers_tab") + publishers_tab_btn.styles.margin = (0, 1, 0, 0) + + remaining_tab_btn = Button("Remaining Hashes", id="show_remaining_tab") + + tab_container.mount(paths_tab_btn) + tab_container.mount(publishers_tab_btn) + tab_container.mount(remaining_tab_btn) + logger.info("Mounted tab buttons") + + # Show paths table by default + logger.debug("About to call _show_path_review_table('paths')") + self._show_path_review_table("paths") + logger.debug("=== _show_path_results complete ===") + + def _show_path_review_table(self, table_type: str) -> None: + """Show an editable DataTable for reviewing paths/publishers.""" + # Prevent concurrent execution + if self._creating_path_table: + logger.warning( + f"Already creating path table, ignoring duplicate call for {table_type}" + ) + return + + self._creating_path_table = True + + # Disable all tab buttons to prevent race conditions + self._disable_tab_buttons() + + try: + self._show_path_review_table_impl(table_type) + finally: + self._creating_path_table = False + # Re-enable tab buttons after table is created + self._enable_tab_buttons() + + def _show_path_review_table_impl(self, table_type: str) -> None: + """Internal implementation of _show_path_review_table.""" + logger.debug(f"_show_path_review_table called with type: {table_type}") + content = self.query_one("#content_area", Vertical) + + # Mark tab as reviewed (only paths and publishers, not remaining) + if table_type == "paths": + self.paths_tab_reviewed = True + logger.debug("Marked paths tab as reviewed") + elif table_type == "publishers": + self.publishers_tab_reviewed = True + logger.debug("Marked publishers tab as reviewed") + + # Determine which dataframe to show + if table_type == "paths": + # Combine primary and secondary paths for review + dfs = [] + if self.primary_paths_df is not None and not self.primary_paths_df.empty: + df_copy = self.primary_paths_df.copy() + df_copy["type"] = "primary" + dfs.append(df_copy) + if ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ): + df_copy = self.secondary_paths_df.copy() + df_copy["type"] = "secondary" + dfs.append(df_copy) + + if dfs: + df = pd.concat(dfs, ignore_index=True) + + # Aggregate by path to show one row per path + if not df.empty and "longestcfp" in df.columns: + # Group by longestcfp and type, aggregate extensions + aggregated_rows = [] + for (path, path_type), group in df.groupby(["longestcfp", "type"]): + # Get unique extensions and hash count + extensions = ( + group["file_extension"].unique() + if "file_extension" in group.columns + else [] + ) + extensions_str = ", ".join( + sorted(set(ext for ext in extensions if ext)) + ) + + # Get hash count (should be same for all rows with same longestcfp) + hash_count = ( + group["unique_sha256_count"].iloc[0] + if "unique_sha256_count" in group.columns + else 0 + ) + + aggregated_rows.append( + { + "longestcfp": path, + "file_extension": extensions_str, + "unique_sha256_count": hash_count, + "type": path_type, + } + ) + + df = pd.DataFrame(aggregated_rows) + logger.info(f"Aggregated paths: {len(df)} unique paths") + else: + df = pd.DataFrame() + + table_id = "paths_review_table" + title = "Path Exclusions - Select paths to REMOVE:" + # Show: path, extensions, hash count, type (primary/secondary) + columns = ( + ["longestcfp", "file_extension", "unique_sha256_count", "type"] + if not df.empty + else [] + ) + + elif table_type == "publishers": + df = self.publishers_df + table_id = "publishers_review_table" + title = "Approved Publishers - Select publishers to REMOVE:" + columns = ["publisher"] if df is not None and not df.empty else [] + + else: # remaining + df = self.remaining_hashes_df + table_id = "remaining_review_table" + title = "Remaining Hashes (not covered by paths) - For reference only:" + columns = ( + ["filename", "filepath", "sha256"] + if df is not None and not df.empty + else [] + ) + + # Remove the SPECIFIC table we're about to create if it exists + try: + existing_specific = content.query_one(f"#{table_id}", DataTable) + if existing_specific: + logger.debug(f"Removing existing table with ID: {table_id}") + existing_specific.remove() + except Exception as e: + logger.debug( + f"No existing table with ID {table_id} found (this is normal): {e}" + ) + + # Remove any other existing tables + try: + existing_tables = content.query("DataTable") + logger.debug(f"Found {len(existing_tables)} existing tables to remove") + for table in existing_tables: + table.remove() + except Exception as e: + logger.debug(f"Error removing existing tables: {e}") + + # Remove existing controls + try: + existing_controls = content.query("#path_review_controls") + logger.debug(f"Found {len(existing_controls)} existing controls to remove") + for control in existing_controls: + control.remove() + except Exception as e: + logger.debug(f"Error removing existing controls: {e}") + + # Remove existing instruction and help text (they accumulate without removal) + # Remove ALL Static widgets - they're just text that needs to be replaced + try: + existing_statics = content.query("Static") + logger.debug( + f"Found {len(existing_statics)} existing Static widgets to remove" + ) + for static in existing_statics: + static.remove() + except Exception as e: + logger.debug(f"Error removing Static widgets: {e}") + + # Force refresh to ensure removals complete + try: + content.refresh() + except Exception as e: + logger.debug(f"Error refreshing content: {e}") + + if df is None or df.empty: + empty_msg = Static(f"No {table_type} to review") + empty_msg.styles.margin = (2, 1) + content.mount(empty_msg) + return + + # Instructions (no ID needed) + instruction = Static(title) + instruction.styles.margin = (1, 1) + instruction.styles.text_style = "bold" + content.mount(instruction) + + # Help text (different for remaining hashes, no ID needed) + if table_type != "remaining": + help_text = Static( + "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" + "Space to toggle cursor row, 'd' to delete, 'c' to copy, 'a' select all, arrows navigate" + ) + else: + help_text = Static( + "These hashes cannot be approved via path exclusions.\n" + "They will need individual hash approval if required." + ) + help_text.styles.margin = (0, 1, 1, 1) + help_text.styles.text_style = "dim" + content.mount(help_text) + + # Create the review table + review_table = DataTable(id=table_id) + review_table.styles.height = "35vh" # Increased since we removed button rows + review_table.cursor_type = "row" # Need row cursor for clicking/navigation + review_table.zebra_stripes = True + + # Add columns - checkbox first, then data columns + if columns: + # For DataFrames, also check what columns actually exist + available_cols = [col for col in columns if col in df.columns] + if available_cols: + # Add checkbox column first + review_table.add_columns("○", *available_cols) + + # Add rows with row keys for tracking + for idx, row in df.iterrows(): + checkbox = "○" # All start unchecked + row_data = [] + for col in available_cols: + value = row.get(col, "") + # Format unique_sha256_count with commas + if col == "unique_sha256_count" and isinstance( + value, (int, float) + ): + value = f"{int(value):,}" + row_data.append(str(value)) + review_table.add_row(checkbox, *row_data, key=str(idx)) + + logger.debug(f"About to mount {table_id}") + + # Final safety check - WAIT until table is confirmed gone before mounting + # Use longer waits (1-2 seconds) to ensure DOM has time to process + max_wait_attempts = 5 + for wait_attempt in range(max_wait_attempts): + try: + existing_check = content.query_one(f"#{table_id}", DataTable) + if existing_check: + logger.warning( + f"Table {table_id} still exists (attempt {wait_attempt + 1}/{max_wait_attempts}). Removing and waiting..." + ) + existing_check.remove() + content.refresh(layout=True) + import time + + time.sleep(0.5) # Wait 0.5s for DOM to process + else: + # Table is gone, break out + logger.debug( + f"Table {table_id} confirmed removed after {wait_attempt} attempts" + ) + break + except Exception: + # Good - table doesn't exist, break out + logger.debug(f"Table {table_id} not found (good)") + break + + # Final verification - if table STILL exists after all attempts, force remove it and wait longer + try: + final_check = content.query_one(f"#{table_id}", DataTable) + if final_check: + logger.error( + f"CRITICAL: Table {table_id} still exists after {max_wait_attempts} attempts!" + ) + final_check.remove() + content.refresh(layout=True) + import time + + time.sleep(1.0) # Wait a full second + + # Check one more time + try: + still_there = content.query_one(f"#{table_id}", DataTable) + if still_there: + # This should never happen - log and skip mounting + logger.error( + f"FATAL: Cannot remove {table_id} even after 1 second wait. Skipping mount to prevent DuplicateIds." + ) + self.app.notify( + "Table refresh failed. Please try again.", severity="error" + ) + return + except Exception: + # Good - finally gone + logger.info(f"Table {table_id} finally removed after extended wait") + pass + except Exception: + # Good - table doesn't exist + pass + + content.mount(review_table) + logger.debug(f"Successfully mounted {table_id}") + + # CRITICAL: Prevent default first-row selection + # Textual auto-focuses new DataTables, causing first row to be selected + # Solution: temporarily disable focus, then re-enable + try: + review_table.can_focus = False + # Schedule re-enabling focus after UI settles + self.set_timer(0.1, lambda: setattr(review_table, "can_focus", True)) + logger.debug("Disabled initial table focus to prevent default selection") + except Exception as e: + logger.debug(f"Could not prevent default focus: {e}") + + # Row count display only (removed Select All, Clear, Delete buttons) + if table_type != "remaining": + # Check if controls container already exists, reuse if it does + try: + control_container = content.query_one( + "#path_review_controls", Horizontal + ) + # Clear existing content + control_container.remove_children() + logger.debug("Reusing existing path_review_controls container") + except Exception: + # Doesn't exist, create it + control_container = Horizontal(id="path_review_controls") + control_container.styles.margin = (1, 1) + control_container.styles.height = "auto" + control_container.styles.min_height = 1 + content.mount(control_container) + logger.debug("Created new path_review_controls container") + + row_count = Static(f"Total items: {len(df)}") + row_count.styles.margin = (0, 1, 0, 1) + + control_container.mount(row_count) + + # Continue button (always at bottom) + try: + continue_container = content.query_one( + "#path_continue_container", Horizontal + ) + # Container exists, check if buttons exist + try: + export_btn = continue_container.query_one("#export_path_review", Button) + continue_btn = continue_container.query_one("#build_preflight", Button) + logger.debug("Reusing existing path_continue_container with buttons") + # Buttons already exist, just reuse them + except Exception: + # Container exists but buttons don't, clear and create new + continue_container.remove_children() + logger.debug("Reusing container, creating new buttons") + + export_btn = Button("Export to CSV", id="export_path_review") + export_btn.styles.margin = (0, 1, 0, 0) + + continue_btn = Button( + "Build Preflight", id="build_preflight", variant="success" + ) + + continue_container.mount(export_btn) + continue_container.mount(continue_btn) + except Exception: + # Container doesn't exist, create it with buttons + continue_container = Horizontal(id="path_continue_container") + continue_container.styles.margin = (2, 1, 1, 1) + continue_container.styles.height = "auto" + continue_container.styles.min_height = 3 + content.mount(continue_container) + logger.debug("Created new path_continue_container") + + # Create and mount buttons + export_btn = Button("Export to CSV", id="export_path_review") + export_btn.styles.margin = (0, 1, 0, 0) + + continue_btn = Button( + "Build Preflight", id="build_preflight", variant="success" + ) + + continue_container.mount(export_btn) + continue_container.mount(continue_btn) + + # Track selected rows + if not hasattr(self, "selected_path_rows"): + self.selected_path_rows = set() + else: + self.selected_path_rows.clear() + + # Store current review type + self.current_path_review_type = table_type + + def _delete_selected_path_rows(self) -> None: + """Delete selected rows from the current path/publisher dataframe.""" + if not hasattr(self, "selected_path_rows") or not self.selected_path_rows: + self.app.notify("No rows selected for deletion", severity="warning") + return + + indices_to_delete = [int(idx) for idx in self.selected_path_rows] + + # Get the appropriate table + if self.current_path_review_type == "paths": + table_id = "paths_review_table" + elif self.current_path_review_type == "publishers": + table_id = "publishers_review_table" + else: + table_id = "remaining_review_table" + + # Get the table + try: + content = self.query_one("#content_area", Vertical) + table = content.query_one(f"#{table_id}", DataTable) + except Exception as e: + logger.error(f"Could not find table {table_id}: {e}") + return + + # Determine which dataframe to modify + if self.current_path_review_type == "paths": + # Need to handle primary and secondary paths + # For simplicity, rebuild both dataframes + # This is a simplified approach - in production you'd track which type each row belongs to + if self.primary_paths_df is not None: + self.primary_paths_df = self.primary_paths_df.drop( + index=[ + i for i in indices_to_delete if i < len(self.primary_paths_df) + ], + errors="ignore", + ) + if self.secondary_paths_df is not None: + offset = ( + len(self.primary_paths_df) + if self.primary_paths_df is not None + else 0 + ) + self.secondary_paths_df = self.secondary_paths_df.drop( + index=[i - offset for i in indices_to_delete if i >= offset], + errors="ignore", + ) + + elif self.current_path_review_type == "publishers": + if self.publishers_df is not None: + self.publishers_df = self.publishers_df.drop( + index=indices_to_delete, errors="ignore" + ) + + # Remove rows from DataTable (don't rebuild entire table) + for idx in self.selected_path_rows: + try: + table.remove_row(idx) + except Exception as e: + logger.debug(f"Could not remove row {idx}: {e}") + + # Clear selection + self.selected_path_rows.clear() + + self.app.notify( + f"Deleted {len(indices_to_delete)} items", severity="information" + ) + + def _copy_selected_path_rows(self) -> None: + """Copy selected path/publisher rows to clipboard as tab-separated values.""" + if not hasattr(self, "selected_path_rows") or not self.selected_path_rows: + self.app.notify("No rows selected to copy", severity="warning") + return + + # Determine which dataframe to use + df = None + if self.current_path_review_type == "paths": + # Combine primary and secondary paths + dfs = [] + if self.primary_paths_df is not None and not self.primary_paths_df.empty: + df_copy = self.primary_paths_df.copy() + df_copy["type"] = "primary" + dfs.append(df_copy) + if ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ): + df_copy = self.secondary_paths_df.copy() + df_copy["type"] = "secondary" + dfs.append(df_copy) + + if dfs: + df = pd.concat(dfs, ignore_index=True) + elif self.current_path_review_type == "publishers": + df = self.publishers_df + else: + df = self.remaining_hashes_df + + if df is None or df.empty: + self.app.notify("No data to copy", severity="warning") + return + + # Get selected rows + indices_to_copy = [int(idx) for idx in self.selected_path_rows] + selected_df = df.loc[df.index.isin(indices_to_copy)] + + if selected_df.empty: + self.app.notify("No valid rows to copy", severity="warning") + return + + # Convert to dictionary format (list of dicts) + try: + # Convert DataFrame to list of dictionaries + rows_as_dicts = selected_df.to_dict("records") + + # Format as Python dictionary representation + import json + + dict_data = json.dumps(rows_as_dicts, indent=2) + + # Copy to clipboard + import platform + import subprocess + + if platform.system() == "Windows": + # Windows clipboard + subprocess.run(["clip"], input=dict_data.encode("utf-8"), check=True) + elif platform.system() == "Darwin": + # macOS clipboard + subprocess.run(["pbcopy"], input=dict_data.encode("utf-8"), check=True) + else: + # Linux clipboard (try xclip first, then xsel) + try: + subprocess.run( + ["xclip", "-selection", "clipboard"], + input=dict_data.encode("utf-8"), + check=True, + ) + except FileNotFoundError: + subprocess.run( + ["xsel", "--clipboard", "--input"], + input=dict_data.encode("utf-8"), + check=True, + ) + + self.app.notify( + f"Copied {len(selected_df)} rows as JSON", severity="information" + ) + except Exception as e: + logger.error(f"Failed to copy to clipboard: {e}") + self.app.notify(f"Failed to copy: {str(e)}", severity="error") + + def _build_preflight(self) -> None: + """Build preflight files for testing.""" + try: + # This would contain the logic to build the final preflight files + # For now, we'll just show the test screen + self._show_test_screen() + except Exception as e: + logger.error(f"Failed to build preflight: {e}") + self.app.notify(f"Failed to build preflight: {str(e)}", severity="error") + + def _show_test_screen(self) -> None: + """Show the test/preview screen with detailed path listings.""" + self.workflow_stage = "test" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Check what we're actually applying + has_paths = ( + self.primary_paths_df is not None and not self.primary_paths_df.empty + ) or (self.secondary_paths_df is not None and not self.secondary_paths_df.empty) + has_publishers = self.publishers_df is not None and not self.publishers_df.empty + has_hashes = self.approved_df is not None and not self.approved_df.empty + + # Dynamic summary based on what we have + if has_paths or has_publishers: + summary_text = "Test Mode - Preview Changes\n\nReview the paths that will be added to your policy:" + else: + summary_text = "Test Mode - Preview Changes\n\nReview the hash approvals that will be added to your allowlist:" + + summary = Static(summary_text) + summary.styles.margin = (1, 1) + summary.styles.text_style = "bold" + content.mount(summary) + + # Policy and Allowlist info + info_text = "" + if self.destination_policy: + info_text += f"📋 Policy: {self.destination_policy.name}\n" + if self.destination_allowlist: + info_text += f"📋 Allowlist: {self.destination_allowlist.name}\n" + + if info_text: + info = Static(info_text) + info.styles.margin = (0, 1, 1, 1) + content.mount(info) + + # Show detailed path exclusions + if self.primary_paths_df is not None and not self.primary_paths_df.empty: + self._show_path_preview( + content, "Primary Path Exclusions", self.primary_paths_df + ) + + if self.secondary_paths_df is not None and not self.secondary_paths_df.empty: + self._show_path_preview( + content, "Secondary Path Exclusions", self.secondary_paths_df + ) + + # Show publishers + if self.publishers_df is not None and not self.publishers_df.empty: + pub_title = Static( + f"\n📝 Trusted Publishers ({len(self.publishers_df)} publishers):" + ) + pub_title.styles.margin = (1, 1, 0, 1) + pub_title.styles.text_style = "bold" + content.mount(pub_title) + + # Create scrollable table for publishers + pub_table = DataTable(id="publisher_preview_table") + pub_table.styles.height = "15vh" + pub_table.styles.margin = (0, 1) + pub_table.cursor_type = "row" + pub_table.zebra_stripes = True + pub_table.add_column("Publisher") + + for _, row in self.publishers_df.iterrows(): + pub_table.add_row(row["publisher"]) + + content.mount(pub_table) + + # Show hash count + if self.approved_df is not None and not self.approved_df.empty: + # Check if hashes are the only thing being applied + has_other_rules = ( + (self.primary_paths_df is not None and not self.primary_paths_df.empty) + or ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ) + or (self.publishers_df is not None and not self.publishers_df.empty) + ) + + if has_other_rules: + hash_text = f"\n🔐 Individual Hash Approvals: {len(self.approved_df):,} hashes\n (Files not covered by paths or publishers)" + else: + hash_text = f"\n🔐 Individual Hash Approvals: {len(self.approved_df):,} hashes\n (All approved files will be added by hash)" + + hash_info = Static(hash_text) + hash_info.styles.margin = (1, 1) + content.mount(hash_info) + + # Warning + warning = Static( + "\n⚠️ WARNING: These changes cannot be easily undone. ⚠️\n" + "Please review all paths carefully before proceeding." + ) + warning.styles.margin = (1, 1) + warning.styles.color = "yellow" + warning.styles.text_style = "bold" + content.mount(warning) + + # Buttons + button_container = Horizontal() + button_container.styles.margin = (2, 1) + content.mount(button_container) + + back_btn = Button( + "← Back to Review", id="back_to_path_review", variant="default" + ) + liftoff_btn = Button( + "Liftoff - Apply Changes 🚀", id="liftoff", variant="success" + ) + + button_container.mount(back_btn) + button_container.mount(liftoff_btn) + + def _show_path_preview( + self, content: Vertical, title: str, paths_df: pd.DataFrame + ) -> None: + """Show a preview of paths that will be added.""" + # Aggregate paths by longestcfp to get unique paths with all extensions + path_list = [] + + for path, group in paths_df.groupby("longestcfp"): + # Get all unique extensions for this path + extensions = ( + group["file_extension"].unique() + if "file_extension" in group.columns + else [] + ) + extensions = sorted(set(ext for ext in extensions if ext)) + + # Create path rules for each extension + for ext in extensions: + # Format the path as it will appear in Airlock + # Example: C:\Program Files\App\**.exe + formatted_path = f"{path}\\**{ext}" + + # Get hash count for this specific path+extension combo + hash_count = ( + len(group[group["file_extension"] == ext]) + if "file_extension" in group.columns + else 0 + ) + + path_list.append((formatted_path, hash_count)) + + # Show title with count + path_title = Static(f"\n📁 {title} ({len(path_list)} path rules):") + path_title.styles.margin = (1, 1, 0, 1) + path_title.styles.text_style = "bold" + content.mount(path_title) + + # Create scrollable table + path_table = DataTable(id=f"{title.lower().replace(' ', '_')}_table") + path_table.styles.height = "20vh" + path_table.styles.margin = (0, 1) + path_table.cursor_type = "row" + path_table.zebra_stripes = True + path_table.add_columns("Path Rule", "Files Covered") + + # Add rows + for path_rule, hash_count in sorted(path_list): + path_table.add_row(path_rule, str(hash_count)) + + content.mount(path_table) + + def _apply_changes(self) -> None: + """Apply the changes to policies and allowlists.""" + self.workflow_stage = "liftoff" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + status = Static("Applying changes...\nPlease wait...") + status.styles.margin = (2, 1) + content.mount(status) + + self.call_later(self._perform_apply) + + def _perform_apply(self) -> None: + """Perform the actual application of changes.""" + try: + results = [] + errors = [] + + # Apply path exclusions to policy (primary + secondary) + if self.destination_policy: + path_rules = [] + + # Process primary paths + if ( + self.primary_paths_df is not None + and not self.primary_paths_df.empty + ): + logger.info( + f"Processing {len(self.primary_paths_df)} primary paths" + ) + for _, row in self.primary_paths_df.groupby( + ["longestcfp", "file_extension"] + ): + path = row.iloc[0]["longestcfp"] + ext = row.iloc[0]["file_extension"] + # Format: C:\Path\**.ext + path_rule = f"{path}\\**{ext}" + path_rules.append(path_rule) + + # Process secondary paths + if ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ): + logger.info( + f"Processing {len(self.secondary_paths_df)} secondary paths" + ) + for _, row in self.secondary_paths_df.groupby( + ["longestcfp", "file_extension"] + ): + path = row.iloc[0]["longestcfp"] + ext = row.iloc[0]["file_extension"] + path_rule = f"{path}\\**{ext}" + path_rules.append(path_rule) + + # Apply path rules to policy + if path_rules: + try: + logger.info( + f"Applying {len(path_rules)} path exclusions to policy {self.destination_policy.name}" + ) + response = self.api.policy_add_path_exclusions( + str(self.destination_policy.groupid), path_rules + ) + results.append( + f"✓ Added {len(path_rules)} path exclusions to policy" + ) + logger.info(f"Path exclusions applied successfully: {response}") + except Exception as e: + error_msg = f"✗ Failed to add path exclusions: {str(e)}" + errors.append(error_msg) + logger.error(error_msg, exc_info=True) + + # Apply publishers to policy + if ( + self.destination_policy + and self.publishers_df is not None + and not self.publishers_df.empty + ): + try: + publishers = self.publishers_df["publisher"].unique().tolist() + logger.info( + f"Applying {len(publishers)} publishers to policy {self.destination_policy.name}" + ) + response = self.api.policy_add_publishers( + str(self.destination_policy.groupid), publishers + ) + results.append( + f"✓ Added {len(publishers)} trusted publishers to policy" + ) + logger.info(f"Publishers applied successfully: {response}") + except Exception as e: + error_msg = f"✗ Failed to add publishers: {str(e)}" + errors.append(error_msg) + logger.error(error_msg, exc_info=True) + + # Apply hashes to allowlist + if ( + self.destination_allowlist + and self.approved_df is not None + and not self.approved_df.empty + ): + try: + # Get unique hashes + hashes = self.approved_df["sha256"].unique().tolist() + logger.info( + f"Applying {len(hashes)} hashes to allowlist {self.destination_allowlist.name}" + ) + response = self.api.hash_add_to_allowlist( + str(self.destination_allowlist.applicationid), hashes + ) + results.append( + f"✓ Added {len(hashes):,} approved hashes to allowlist" + ) + logger.info(f"Hashes applied successfully: {response}") + except Exception as e: + error_msg = f"✗ Failed to add hashes: {str(e)}" + errors.append(error_msg) + logger.error(error_msg, exc_info=True) + + # Show completion with both results and errors + all_results = results + errors + self._show_completion(all_results, has_errors=len(errors) > 0) + + except Exception as e: + logger.error(f"Critical failure in _perform_apply: {e}", exc_info=True) + self.app.notify(f"Critical failure: {str(e)}", severity="error") + self._show_test_screen() + + def _show_completion(self, results: List[str], has_errors: bool = False) -> None: + """Show completion screen.""" + self.workflow_stage = "complete" + content = self.query_one("#content_area", Vertical) + content.remove_children() + + # Title depends on whether there were errors + if has_errors: + title_text = "Policy Preparation Completed with Errors\n\n" "Results:" + title_color = "yellow" + else: + title_text = ( + "Policy Preparation Complete!\n\n" + "The following changes have been applied:" + ) + title_color = "green" + + summary = Static(title_text) + summary.styles.margin = (1, 1) + summary.styles.text_style = "bold" + summary.styles.color = title_color + content.mount(summary) + + for result in results: + result_widget = Static(f" {result}") + result_widget.styles.margin = (0, 2) + # Color based on success/failure + if result.startswith("✓"): + result_widget.styles.color = "green" + elif result.startswith("✗"): + result_widget.styles.color = "red" + content.mount(result_widget) + + # Final message + if has_errors: + final = Static( + f"\n⚠️ Policy '{self.destination_policy.name}' was partially updated.\n" + "Please review errors above and retry failed operations manually." + ) + final.styles.margin = (2, 1) + final.styles.color = "yellow" + else: + final = Static( + f"\n✅ Policy '{self.destination_policy.name}' is now ready for enforcement!" + ) + final.styles.margin = (2, 1) + final.styles.color = "green" + content.mount(final) + + # Done button + done_btn = Button("Done", id="workflow_done") + done_btn.styles.margin = (2, 0, 0, 0) + done_btn.styles.width = "50%" + content.mount(done_btn) + + # Event handlers + def on_policy_selector_policy_selected( + self, message: PolicySelector.PolicySelected + ) -> None: + """Handle policy selection from PolicySelector widget.""" + if self.workflow_stage == "select_destination": + self.destination_policy = message.policy + logger.info(f"Selected destination policy: {self.destination_policy.name}") + self._show_allowlist_selection() + + def _refresh_table_checkboxes(self, table_id: str, selected_keys: set) -> None: + """Refresh checkbox column in a table based on selected keys.""" + try: + table = self.query_one(f"#{table_id}", DataTable) + + # Update checkboxes and apply styling to all cells in selected rows + row_index = 0 + for row_key in table.rows.keys(): + # Get the actual value from the RowKey object + row_key_str = ( + str(row_key.value) if hasattr(row_key, "value") else str(row_key) + ) + # Determine if this row should be checked + is_selected = row_key_str in selected_keys + checkbox = "✓" if is_selected else "○" + + # Update the checkbox cell (first column, index 0) + try: + table.update_cell_at((row_index, 0), checkbox) + except Exception as e: + logger.error(f"Could not update cell at row {row_index}: {e}") + + # CRITICAL: Apply visual styling to ALL cells in the row if selected + # This creates the visual "highlight" effect for multi-select + try: + if is_selected: + # Get the number of columns + num_cols = len(table.columns) + # Update each cell with Rich styling for background color + for col_idx in range(num_cols): + try: + # Get current cell value + current_value = str( + table.get_cell_at((row_index, col_idx)) + ) + # Wrap in Rich markup for background color + # Using reverse video to invert colors + styled_value = f"[reverse]{current_value}[/reverse]" + table.update_cell_at((row_index, col_idx), styled_value) + except Exception as cell_err: + logger.debug( + f"Could not style cell ({row_index}, {col_idx}): {cell_err}" + ) + else: + # Remove styling from deselected rows + num_cols = len(table.columns) + for col_idx in range(num_cols): + try: + current_value = str( + table.get_cell_at((row_index, col_idx)) + ) + # Remove Rich markup if present + if current_value.startswith("[reverse]"): + clean_value = current_value.replace( + "[reverse]", "" + ).replace("[/reverse]", "") + table.update_cell_at( + (row_index, col_idx), clean_value + ) + except Exception as cell_err: + logger.debug( + f"Could not unstyle cell ({row_index}, {col_idx}): {cell_err}" + ) + except Exception as style_err: + logger.debug(f"Error styling row {row_index}: {style_err}") + + row_index += 1 + + # Refresh table to show changes + table.refresh() + + except Exception as e: + logger.error(f"Error refreshing table {table_id}: {e}", exc_info=True) + + def _get_selected_set(self, table_id: str) -> set: + """Get the appropriate selection set for a table.""" + if table_id == "source_policy_table": + return self.selected_source_policy_ids + elif table_id in ["approved_review_table", "needs_review_table"]: + return self.selected_rows + elif table_id in [ + "paths_review_table", + "publishers_review_table", + "remaining_review_table", + ]: + return self.selected_path_rows + return set() + + def _range_select(self, table: DataTable, start_key: str, end_key: str) -> None: + """Toggle all rows between start and end (inclusive).""" + # Get all row keys in order + all_keys = [ + str(k.value if hasattr(k, "value") else k) for k in table.rows.keys() + ] + + try: + start_idx = all_keys.index(start_key) + end_idx = all_keys.index(end_key) + except ValueError: + # Key not found, fall back to single toggle + logger.warning("Range select failed: keys not found") + return + + # Ensure start < end + if start_idx > end_idx: + start_idx, end_idx = end_idx, start_idx + + # Toggle all rows in range + selected_set = self._get_selected_set(table.id) + range_keys = [all_keys[i] for i in range(start_idx, end_idx + 1)] + + # Determine if we're selecting or deselecting + # If any row in range is unselected, select all; otherwise deselect all + any_unselected = any(key not in selected_set for key in range_keys) + + if any_unselected: + # Select all in range + for row_key in range_keys: + selected_set.add(row_key) + action = "selected" + else: + # Deselect all in range + for row_key in range_keys: + selected_set.discard(row_key) + action = "deselected" + + # Refresh display + self._refresh_table_checkboxes(table.id, selected_set) + + # Notify user + count = end_idx - start_idx + 1 + self.app.notify(f"Range {action} ({count} rows)", timeout=2) + + def _toggle_single(self, table: DataTable, row_key: str) -> None: + """Toggle a single row without affecting others (Ctrl+Click).""" + selected_set = self._get_selected_set(table.id) + + # Toggle + if row_key in selected_set: + selected_set.remove(row_key) + else: + selected_set.add(row_key) + + # Refresh + self._refresh_table_checkboxes(table.id, selected_set) + self.app.notify(f"Selected {len(selected_set)} rows", timeout=1) + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + """Handle row highlighting (clicking) in data tables with range selection support.""" + table = event.data_table + row_key = ( + str(event.row_key.value) + if hasattr(event.row_key, "value") + else str(event.row_key) + ) + + # If this was triggered by keyboard navigation, skip selection and reset flag + if self._keyboard_navigation: + self._keyboard_navigation = False + logger.debug("KEYBOARD NAV: Ignoring row highlight from arrow keys") + return + + logger.info( + f"CLICK: table={table.id}, row={row_key}, range_mode={self._range_mode}" + ) + + # Handle source policy selection + if table.id == "source_policy_table": + if ( + self._range_mode + and self.last_clicked_row + and self.last_clicked_table == table.id + ): + # Range toggle + logger.info(f"RANGE TOGGLE: from {self.last_clicked_row} to {row_key}") + self._range_select(table, self.last_clicked_row, row_key) + self._range_mode = False # Exit range mode after operation + else: + # Normal toggle + if row_key in self.selected_source_policy_ids: + self.selected_source_policy_ids.remove(row_key) + else: + self.selected_source_policy_ids.add(row_key) + self._refresh_table_checkboxes( + table.id, self.selected_source_policy_ids + ) + self.app.notify( + f"Selected {len(self.selected_source_policy_ids)} policies", + timeout=1, + ) + + # Remember for next range-select + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + # Handle path/publisher review selections + elif table.id in [ + "paths_review_table", + "publishers_review_table", + "remaining_review_table", + ]: + if ( + self._range_mode + and self.last_clicked_row + and self.last_clicked_table == table.id + ): + # Range toggle + logger.info(f"RANGE TOGGLE: from {self.last_clicked_row} to {row_key}") + self._range_select(table, self.last_clicked_row, row_key) + self._range_mode = False # Exit range mode after operation + else: + # Normal toggle + if row_key in self.selected_path_rows: + self.selected_path_rows.remove(row_key) + else: + self.selected_path_rows.add(row_key) + self._refresh_table_checkboxes(table.id, self.selected_path_rows) + self.app.notify( + f"Selected {len(self.selected_path_rows)} items", timeout=1 + ) + + # Remember for next range-select + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + # Handle approved/needs review selections + elif table.id in ["approved_review_table", "needs_review_table"]: + if ( + self._range_mode + and self.last_clicked_row + and self.last_clicked_table == table.id + ): + # Range toggle + logger.info(f"RANGE TOGGLE: from {self.last_clicked_row} to {row_key}") + self._range_select(table, self.last_clicked_row, row_key) + self._range_mode = False # Exit range mode after operation + else: + # Normal toggle + if row_key in self.selected_rows: + self.selected_rows.remove(row_key) + else: + self.selected_rows.add(row_key) + self._refresh_table_checkboxes(table.id, self.selected_rows) + self.app.notify(f"Selected {len(self.selected_rows)} rows", timeout=1) + + # Remember for next range-select + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle row selection in data tables.""" + table = event.data_table + logger.debug(f"Row selected in table: {table.id}") + + # NOTE: Review table selections are handled in on_data_table_row_highlighted (clicks only) + # This event (row_selected) is triggered by arrow key navigation, which should NOT select + # Only handle special cases like allowlist selection + + # Ignore all review tables - they use row_highlighted for selection + if table.id in [ + "source_policy_table", + "approved_review_table", + "needs_review_table", + "paths_review_table", + "publishers_review_table", + "remaining_review_table", + ]: + return + + # Handle allowlist selection (this one uses row selection, not highlighting) + if table.id == "allowlist_table": + # Get selected allowlist + row_index = table.cursor_row + if hasattr(self, "allowlists") and row_index < len(self.allowlists): + self.destination_allowlist = self.allowlists[row_index] + logger.info(f"Selected allowlist: {self.destination_allowlist.name}") + self._show_fetch_data() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle input submission (Enter key press).""" + if event.input.id == "history_days_input": + # Trigger the fetch when user presses Enter in the days input + try: + history_days = int(event.input.value) + if 1 <= history_days <= 365: + self.history_days = history_days + self._fetch_execution_data(history_days) + else: + self.app.notify( + "Please enter a value between 1 and 365", severity="warning" + ) + except (ValueError, TypeError): + self.app.notify("Please enter a valid number", severity="warning") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + button_id = event.button.id + + # Introduction screen buttons + if button_id == "start_workflow": + self._show_source_policy_selection() + + elif button_id == "cancel_workflow": + self.app.pop_screen() + + # Source policy selection buttons + elif button_id == "select_none_source": + self.selected_source_policy_ids.clear() + # Refresh checkbox display + self._refresh_table_checkboxes( + "source_policy_table", self.selected_source_policy_ids + ) + self.app.notify("Cleared selection", timeout=1) + + elif button_id == "continue_source_selection": + if not self.selected_source_policy_ids: + self.app.notify( + "Please select at least one source policy", severity="warning" + ) + else: + # Get the actual policy objects + self.source_policies = [ + p + for p in self.policies + if str(p.groupid) in self.selected_source_policy_ids + ] + logger.info( + f"Selected source policies: {[p.name for p in self.source_policies]}" + ) + logger.info( + f"self.source_policies set to: {len(self.source_policies)} policies" + ) + + if not self.source_policies: + logger.error("source_policies list is empty after selection!") + self.app.notify( + "Error: Could not load selected policies. Please try again.", + severity="error", + ) + else: + self._show_destination_policy_selection() + + # Tab switching buttons - disable immediately to prevent double-clicks + elif button_id == "show_approved_tab": + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_review_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_review_table("approved") + finally: + event.button.disabled = False + + elif button_id == "show_needs_review_tab": + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_review_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_review_table("needs_review") + finally: + event.button.disabled = False + + elif button_id == "show_paths_tab": + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_path_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_path_review_table("paths") + finally: + event.button.disabled = False + + elif button_id == "show_publishers_tab": + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_path_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_path_review_table("publishers") + finally: + event.button.disabled = False + + elif button_id == "show_remaining_tab": + # Prevent rapid clicking - check if table creation is already in progress + if self._creating_path_table: + logger.debug("Ignoring tab click - table creation already in progress") + return + # Disable this button immediately + event.button.disabled = True + try: + self._show_path_review_table("remaining") + finally: + event.button.disabled = False + + # Row selection buttons + elif button_id == "select_all_rows": + self._select_all_rows() + + elif button_id == "select_none_rows": + self._select_none_rows() + + elif button_id == "delete_selected_rows": + self._delete_selected_rows() + + elif button_id == "select_all_path_rows": + self._select_all_path_rows() + + elif button_id == "select_none_path_rows": + self._select_none_path_rows() + + elif button_id == "delete_selected_path_rows": + self._delete_selected_path_rows() + + # Export buttons + elif button_id == "export_review": + self._export_review_data() + + elif button_id == "export_path_review": + self._export_path_review_data() + + # Original button handlers + elif button_id == "fetch_data_btn": + # Get history days from input + try: + days_input = self.query_one("#history_days_input", Input) + history_days = int(days_input.value) + if 1 <= history_days <= 365: + self.history_days = history_days + self._fetch_execution_data(history_days) + else: + self.app.notify( + "Please enter a value between 1 and 365", severity="warning" + ) + except (ValueError, TypeError): + self.app.notify("Please enter a valid number", severity="warning") + + elif button_id == "skip_fetch_btn": + # Check if data already exists + if self.source_policies: + policy_name = self.source_policies[0].name + approved_path = os.path.join( + self.working_dir, + "Needs_Review", + "Review_First", + f"{policy_name}_approved_executions.csv", + ) + if os.path.exists(approved_path): + # Load existing data + self.approved_df = pd.read_csv(approved_path) + review_path = approved_path.replace("approved", "needs_review") + if os.path.exists(review_path): + self.needs_review_df = pd.read_csv(review_path) + self._show_fetch_results() + else: + self.app.notify( + "No existing data found. Please fetch new data.", + severity="warning", + ) + + elif button_id == "continue_from_review": + # Check if both tabs have been reviewed + if not self.approved_tab_reviewed or not self.needs_review_tab_reviewed: + self.app.notify( + "Please review both 'Approved' and 'Needs Review' tabs before continuing.", + severity="warning", + timeout=5, + ) + return + + # Validate that review is complete + if (self.approved_df is None or self.approved_df.empty) and ( + self.needs_review_df is None or self.needs_review_df.empty + ): + self.app.notify( + "No data to continue with! Please review and keep some executions.", + severity="error", + ) + else: + # Save the reviewed data before continuing + logger.info( + f"Saving reviewed data: approved={len(self.approved_df) if self.approved_df is not None else 0}, needs_review={len(self.needs_review_df) if self.needs_review_df is not None else 0}" + ) + self._save_reviewed_data() + # Show loading screen then build paths + self._show_path_building_screen() + + elif button_id == "build_preflight": + # Check if both required tabs have been reviewed + if not self.paths_tab_reviewed or not self.publishers_tab_reviewed: + self.app.notify( + "Please review both 'Paths' and 'Publishers' tabs before continuing.", + severity="warning", + timeout=5, + ) + return + + # Validate that we have SOMETHING to build with (paths, publishers, or hashes) + has_paths = ( + self.primary_paths_df is not None and not self.primary_paths_df.empty + ) or ( + self.secondary_paths_df is not None + and not self.secondary_paths_df.empty + ) + has_publishers = ( + self.publishers_df is not None and not self.publishers_df.empty + ) + has_hashes = self.approved_df is not None and not self.approved_df.empty + + if not has_paths and not has_publishers and not has_hashes: + self.app.notify( + "No paths, publishers, or hashes to build preflight with!", + severity="error", + ) + else: + # Build with whatever we have + if not has_paths and not has_publishers and has_hashes: + self.app.notify( + f"Building preflight with {len(self.approved_df)} hash approvals only.", + severity="information", + timeout=3, + ) + self._build_preflight() + + elif button_id == "back_to_path_review": + # Go back to path review screen + self._show_path_review_table("paths") + + elif button_id == "liftoff": + # Confirm before applying + self.app.notify("Applying changes...", severity="information") + self._apply_changes() + + elif button_id == "workflow_done": + self.app.pop_screen() + + def _select_all_rows(self) -> None: + """Select all rows in the current review table.""" + table_id = None + df = None + if self.current_review_type == "approved": + table_id = "approved_review_table" + df = self.approved_df + else: + table_id = "needs_review_table" + df = self.needs_review_df + + if table_id and df is not None: + # Use actual DataFrame indices, not range(len(df)) + self.selected_rows = set(str(i) for i in df.index) + # Refresh checkbox display + self._refresh_table_checkboxes(table_id, self.selected_rows) + self.app.notify(f"Selected all {len(self.selected_rows)} rows", timeout=1) + + def _select_none_rows(self) -> None: + """Clear all row selections in the current review table.""" + self.selected_rows.clear() + # Refresh checkbox display + table_id = ( + "approved_review_table" + if self.current_review_type == "approved" + else "needs_review_table" + ) + self._refresh_table_checkboxes(table_id, self.selected_rows) + self.app.notify("Cleared selection", timeout=1) + + def _select_all_path_rows(self) -> None: + """Select all rows in the current path review table.""" + table_id = None + if self.current_path_review_type == "paths": + table_id = "paths_review_table" + # For paths, the combined DataFrame uses ignore_index=True, so indices are 0..n-1 + total = 0 + if self.primary_paths_df is not None: + total += len(self.primary_paths_df) + if self.secondary_paths_df is not None: + total += len(self.secondary_paths_df) + self.selected_path_rows = set(str(i) for i in range(total)) + elif ( + self.current_path_review_type == "publishers" + and self.publishers_df is not None + ): + table_id = "publishers_review_table" + # For publishers, use actual DataFrame indices + self.selected_path_rows = set(str(i) for i in self.publishers_df.index) + + # Refresh checkbox display + if table_id: + self._refresh_table_checkboxes(table_id, self.selected_path_rows) + self.app.notify(f"Selected all {len(self.selected_path_rows)} items", timeout=1) + + def _select_none_path_rows(self) -> None: + """Clear all row selections in the current path review table.""" + self.selected_path_rows.clear() + # Refresh checkbox display + table_id = ( + "paths_review_table" + if self.current_path_review_type == "paths" + else "publishers_review_table" + ) + self._refresh_table_checkboxes(table_id, self.selected_path_rows) + self.app.notify("Cleared selection", timeout=1) + + def _save_reviewed_data(self) -> None: + """Save the reviewed dataframes to the Approved folder.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + approved_dir = os.path.join(self.working_dir, "Approved") + os.makedirs(approved_dir, exist_ok=True) + + # Save approved executions + if self.approved_df is not None and not self.approved_df.empty: + filepath = os.path.join( + approved_dir, f"{policy_name}_approved_executions.csv" + ) + self.approved_df.to_csv(filepath, index=False) + logger.info(f"Saved approved executions to {filepath}") + + # Save needs_review as approved (since user reviewed them) + if self.needs_review_df is not None and not self.needs_review_df.empty: + filepath = os.path.join( + approved_dir, f"{policy_name}_needs_review_executions.csv" + ) + self.needs_review_df.to_csv(filepath, index=False) + logger.info(f"Saved reviewed executions to {filepath}") + + def _export_review_data(self) -> None: + """Export current review data to CSV.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + if self.current_review_type == "approved" and self.approved_df is not None: + filepath = os.path.join( + self.working_dir, f"{policy_name}_approved_export_{timestamp}.csv" + ) + self.approved_df.to_csv(filepath, index=False) + self.app.notify(f"Exported to: {filepath}", severity="information") + + elif ( + self.current_review_type == "needs_review" + and self.needs_review_df is not None + ): + filepath = os.path.join( + self.working_dir, f"{policy_name}_needs_review_export_{timestamp}.csv" + ) + self.needs_review_df.to_csv(filepath, index=False) + self.app.notify(f"Exported to: {filepath}", severity="information") + + def on_key(self, event) -> None: + """Handle keyboard shortcuts including range selection mode.""" + key = event.key + + # Track arrow key navigation to prevent selection + if key in ["up", "down", "left", "right", "pageup", "pagedown", "home", "end"]: + self._keyboard_navigation = True + return # Let the event propagate for navigation + + # 'r' activates range selection mode + if key == "r": + if self.last_clicked_row and self.last_clicked_table: + self._range_mode = True + self.app.notify( + "Range mode: Click end row (or press ESC to cancel)", + severity="information", + timeout=5, + ) + logger.info( + f"RANGE MODE ACTIVATED: starting from row {self.last_clicked_row} in table {self.last_clicked_table}" + ) + else: + self.app.notify( + "Click a row first, then press 'r' to start range selection", + severity="warning", + timeout=3, + ) + + # ESC cancels range mode + elif key == "escape": + if self._range_mode: + self._range_mode = False + self.app.notify( + "Range mode cancelled", severity="information", timeout=2 + ) + logger.info("RANGE MODE CANCELLED") + + def on_key_up(self, event) -> None: + """Handle key releases (currently unused but kept for future).""" + pass + + def _export_path_review_data(self) -> None: + """Export current path review data to CSV.""" + if not self.source_policies: + return + + policy_name = self.source_policies[0].name + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + if self.current_path_review_type == "paths": + # Export both primary and secondary paths + if self.primary_paths_df is not None: + filepath = os.path.join( + self.working_dir, + f"{policy_name}_primary_paths_export_{timestamp}.csv", + ) + self.primary_paths_df.to_csv(filepath, index=False) + self.app.notify( + f"Exported primary paths to: {filepath}", severity="information" + ) + + if self.secondary_paths_df is not None: + filepath = os.path.join( + self.working_dir, + f"{policy_name}_secondary_paths_export_{timestamp}.csv", + ) + self.secondary_paths_df.to_csv(filepath, index=False) + self.app.notify( + f"Exported secondary paths to: {filepath}", severity="information" + ) + + elif ( + self.current_path_review_type == "publishers" + and self.publishers_df is not None + ): + filepath = os.path.join( + self.working_dir, f"{policy_name}_publishers_export_{timestamp}.csv" + ) + self.publishers_df.to_csv(filepath, index=False) + self.app.notify(f"Exported to: {filepath}", severity="information") + + def _open_folder(self, path: str) -> None: + """Open a folder in the system file explorer.""" + try: + import platform + import subprocess + + os.makedirs(path, exist_ok=True) + + if platform.system() == "Windows": + subprocess.Popen(f'explorer "{path}"') + elif platform.system() == "Darwin": # macOS + subprocess.Popen(["open", path]) + else: # Linux + subprocess.Popen(["xdg-open", path]) + + self.app.notify(f"Opened: {path}", severity="information") + except Exception as e: + logger.error(f"Failed to open folder: {e}") + self.app.notify(f"Failed to open folder: {str(e)}", severity="error") + + # Action handlers + def action_go_back(self) -> None: + """Handle back/escape action.""" + stage_transitions = { + "select_source": lambda: self.app.pop_screen(), + "select_destination": self._show_source_policy_selection, + "select_allowlist": self._show_destination_policy_selection, + "fetch_data": self._show_allowlist_selection, + "first_review": lambda: self._show_fetch_data_after_clearing_paths(), + "second_review": self._show_fetch_results, + "test": self._show_path_results, + "complete": lambda: self.app.pop_screen(), + } + + transition = stage_transitions.get(self.workflow_stage) + if transition: + transition() + else: + self.app.pop_screen() + + def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() + + def action_open_folder(self) -> None: + """Open the working directory.""" + self._open_folder(self.working_dir) + + def action_delete_rows(self) -> None: + """Delete selected rows in the current table.""" + if self.workflow_stage == "first_review": + self._delete_selected_rows() + elif self.workflow_stage == "second_review": + self._delete_selected_path_rows() + + def action_copy_rows(self) -> None: + """Copy selected rows to clipboard.""" + if self.workflow_stage == "first_review": + self._copy_selected_rows() + elif self.workflow_stage == "second_review": + self._copy_selected_path_rows() + + def action_select_all(self) -> None: + """Select all rows in the current table.""" + if self.workflow_stage == "first_review": + self._select_all_rows() + elif self.workflow_stage == "second_review": + self._select_all_path_rows() + + def action_select_none(self) -> None: + """Clear selection in the current table.""" + if self.workflow_stage == "first_review": + self._select_none_rows() + elif self.workflow_stage == "second_review": + self._select_none_path_rows() + + def action_toggle_selection(self) -> None: + """Toggle selection on the current row at cursor position.""" + # Get the focused widget (should be a DataTable) + focused = self.app.focused + + if not isinstance(focused, DataTable): + return + + table = focused + + # Get the current cursor row + try: + cursor_row = table.cursor_row + # Get the row key at the cursor position + row_keys = list(table.rows.keys()) + if cursor_row < len(row_keys): + row_key = str( + row_keys[cursor_row].value + if hasattr(row_keys[cursor_row], "value") + else row_keys[cursor_row] + ) + + logger.info(f"SPACE: Toggling row {row_key} in table {table.id}") + + # Toggle based on table type + if table.id == "source_policy_table": + if row_key in self.selected_source_policy_ids: + self.selected_source_policy_ids.remove(row_key) + else: + self.selected_source_policy_ids.add(row_key) + self._refresh_table_checkboxes( + table.id, self.selected_source_policy_ids + ) + self.app.notify( + f"Selected {len(self.selected_source_policy_ids)} policies", + timeout=1, + ) + + # Remember for range mode + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + elif table.id in ["approved_review_table", "needs_review_table"]: + if row_key in self.selected_rows: + self.selected_rows.remove(row_key) + else: + self.selected_rows.add(row_key) + self._refresh_table_checkboxes(table.id, self.selected_rows) + self.app.notify( + f"Selected {len(self.selected_rows)} rows", timeout=1 + ) + + # Remember for range mode + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + elif table.id in [ + "paths_review_table", + "publishers_review_table", + "remaining_review_table", + ]: + if row_key in self.selected_path_rows: + self.selected_path_rows.remove(row_key) + else: + self.selected_path_rows.add(row_key) + self._refresh_table_checkboxes(table.id, self.selected_path_rows) + self.app.notify( + f"Selected {len(self.selected_path_rows)} items", timeout=1 + ) + + # Remember for range mode + self.last_clicked_row = row_key + self.last_clicked_table = table.id + + except Exception as e: + logger.error(f"Error toggling selection: {e}") diff --git a/TUI/policyselectorscreen.py b/TUI/Screens/policyselectorscreen.py similarity index 82% rename from TUI/policyselectorscreen.py rename to TUI/Screens/policyselectorscreen.py index 07ec2ee..7beb704 100644 --- a/TUI/policyselectorscreen.py +++ b/TUI/Screens/policyselectorscreen.py @@ -23,9 +23,11 @@ the policy selection workflow. import logging from textual.app import ComposeResult +from textual.binding import Binding from textual.screen import Screen +from textual.widgets import Footer, Header -from TUI.policyselector import PolicySelector +from TUI.Widgets.policyselector import PolicySelector logger = logging.getLogger(__name__) @@ -42,6 +44,11 @@ class PolicySelectorScreen(Screen): agent_move_operations: Reference to the parent AgentMoveOperations widget. """ + BINDINGS = [ + Binding("escape", "go_back", "Back"), + Binding("q", "main_menu", "Main Menu"), + ] + CSS = """ Screen { layout: vertical; @@ -68,7 +75,18 @@ class PolicySelectorScreen(Screen): def compose(self) -> ComposeResult: """Create the PolicySelector widget.""" + yield Header(show_clock=True) yield PolicySelector(self.policies) + yield Footer() + + def action_go_back(self) -> None: + """Handle escape key to go back one screen.""" + self.app.pop_screen() + + def action_main_menu(self) -> None: + """Handle q key to go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() def on_policy_selector_policy_selected( self, message: PolicySelector.PolicySelected diff --git a/TUI/quietagentworkflowscreen.py b/TUI/Screens/quietagentworkflowscreen.py similarity index 74% rename from TUI/quietagentworkflowscreen.py rename to TUI/Screens/quietagentworkflowscreen.py index 552f214..bca4b31 100644 --- a/TUI/quietagentworkflowscreen.py +++ b/TUI/Screens/quietagentworkflowscreen.py @@ -34,12 +34,12 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.reactive import reactive from textual.screen import Screen -from textual.widgets import Button, DataTable, Footer, Header, Static +from textual.widgets import Button, DataTable, Footer, Header, Input, Static from models.policy import Policy from services.API import AirlockAPIWrapper from services.policyhandler import getPolicyInfo -from TUI.policyselector import PolicySelector +from TUI.Widgets.policyselector import PolicySelector from utils.configmanager import load_env logger = logging.getLogger(__name__) @@ -51,16 +51,17 @@ class QuietAgentWorkflowScreen(Screen): This screen provides a multi-step workflow: 1. Select initial policy to analyze - 2. View categorized agents (enforce ready vs. non-enforce ready) - 3. Select target policies for each category - 4. Execute agent migrations + 2. Configure analysis parameters (history period and quiet time period) + 3. View categorized agents (enforce ready vs. non-enforce ready) + 4. Select target policies for each category + 5. Execute agent migrations Attributes: api (AirlockAPIWrapper): API wrapper for Airlock operations policies (List[Policy]): List of all available policies selected_policy (Optional[Policy]): The initially selected policy to analyze - history_days (int): Number of days of history to pull (default: 150) - quiet_days (int): Number of days without execution to be considered quiet (default: 45) + history_days (int): Number of days of history to pull (default: 150, range: 1-365) + quiet_days (int): Number of days without execution to be considered quiet (default: 45, range: 1-365) agents_df (Optional[pd.DataFrame]): DataFrame of all agents with analysis results enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents ready for enforcement non_enforce_ready_df (Optional[pd.DataFrame]): DataFrame of agents not ready for enforcement @@ -69,6 +70,7 @@ class QuietAgentWorkflowScreen(Screen): BINDINGS = [ ("escape", "go_back", "Back"), + ("q", "main_menu", "Main Menu"), ] workflow_stage = reactive("select_policy") # Tracks current workflow stage @@ -85,7 +87,7 @@ class QuietAgentWorkflowScreen(Screen): self.api = api self.policies = policies self.selected_policy: Optional[Policy] = None - self.history_days = 150 # Fixed as per requirements + self.history_days = 150 # Default value, user-selectable self.quiet_days = 45 # Default value self.agents_df: Optional[pd.DataFrame] = None self.enforce_ready_df: Optional[pd.DataFrame] = None @@ -96,10 +98,10 @@ class QuietAgentWorkflowScreen(Screen): def compose(self) -> ComposeResult: """Build the UI layout for the workflow screen.""" # Include Header and Footer like other standalone screens - yield Header(show_clock=True, icon="⚙") + yield Header(show_clock=True, icon="⚙️") # Title area - title = Static("🔒 Quiet Agent Workflow", id="workflow_title") + title = Static("Quiet Agent Workflow", id="workflow_title") title.styles.margin = (0, 0, 0, 1) yield title @@ -129,14 +131,14 @@ class QuietAgentWorkflowScreen(Screen): stage_messages = { "select_policy": "Step 1: Select Policy to Analyze", - "select_quiet_days": "Step 2: Select Quiet Time Period", - "analyzing": "📊 Analyzing agent activity...", + "select_history_days": "Step 2: Configure Analysis Parameters", + "analyzing": "Analyzing agent activity...", "view_results": "Step 3: Review Categorized Agents", "select_enforce_target": "Step 4: Select Target Policy for Enforce Ready Agents", "select_non_enforce_target": "Step 5: Select Target Policy for Non-Enforce Ready Agents", "confirm_migration": "Step 6: Confirm and Execute Migration", - "executing": "⏳ Executing agent migrations...", - "complete": "✅ Migration Complete", + "executing": "Executing agent migrations...", + "complete": "Migration Complete", } status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage")) @@ -160,7 +162,7 @@ class QuietAgentWorkflowScreen(Screen): # Initial policy selection for analysis self.selected_policy = message.policy logger.info(f"Selected policy for analysis: {self.selected_policy.name}") - self._show_quiet_days_selection() + self._show_history_days_selection() elif self.workflow_stage == "select_enforce_target": # Target policy selection for enforce ready agents self.enforce_ready_target_policy = message.policy @@ -176,64 +178,170 @@ class QuietAgentWorkflowScreen(Screen): ) self._show_migration_confirmation() - def _show_quiet_days_selection(self) -> None: - """Show the quiet days selection screen.""" - self.workflow_stage = "select_quiet_days" + def _show_history_days_selection(self) -> None: + """Show the history days and quiet days selection screen.""" + self.workflow_stage = "select_history_days" content = self.query_one("#content_area", Vertical) content.remove_children() # Create info text info_widget = Static( f"Policy Selected: {self.selected_policy.name}\n\n" - f"History Period: {self.history_days} days\n\n" - "Select quiet time period (days without untrusted execution):", - id="quiet_days_info", + "Configure Analysis Parameters:", + id="analysis_params_info", ) info_widget.styles.margin = (0, 0, 2, 0) content.mount(info_widget) - # Create button container and mount it first - button_container = Vertical(id="quiet_days_buttons") - button_container.styles.height = "auto" - content.mount(button_container) + # Create input container + input_container = Vertical(id="analysis_params_input_container") + input_container.styles.height = "auto" + content.mount(input_container) - # Now add buttons to the mounted container - for days in [15, 30, 45, 60]: - btn = Button( - f"{days} days {'(Default)' if days == 45 else ''}", - id=f"quiet_days_{days}", - classes="quiet_day_btn", + # History days label + history_label = Static("History Period (days of execution history to pull):") + history_label.styles.margin = (0, 0, 1, 0) + input_container.mount(history_label) + + # Add history days input field + history_input = Input( + placeholder="Enter days (1-365, default: 150)", + value="150", + id="history_days_input", + ) + history_input.styles.width = "50" + history_input.styles.margin = (0, 0, 2, 0) + input_container.mount(history_input) + + # Quiet days label + quiet_label = Static( + "Quiet Time Period (days without execution to be considered quiet):" + ) + quiet_label.styles.margin = (0, 0, 1, 0) + input_container.mount(quiet_label) + + # Add quiet days input field + quiet_input = Input( + placeholder="Enter days (1-365, default: 45)", + value="45", + id="quiet_days_input", + ) + quiet_input.styles.width = "50" + quiet_input.styles.margin = (0, 0, 2, 0) + input_container.mount(quiet_input) + + # Add submit button + submit_btn = Button( + "Continue", + id="analysis_params_submit", + variant="primary", + ) + submit_btn.styles.width = "50" + submit_btn.styles.margin = (1, 0, 0, 0) + input_container.mount(submit_btn) + + # Focus the first input field + history_input.focus() + + def _validate_and_submit_history_days(self) -> None: + """Validate and submit the history days and quiet days inputs.""" + try: + history_input = self.query_one("#history_days_input", Input) + quiet_input = self.query_one("#quiet_days_input", Input) + + history_value = history_input.value.strip() + quiet_value = quiet_input.value.strip() + + # Validate history days + if not history_value: + self.app.notify( + "Please enter a history period value", severity="error", timeout=3 + ) + history_input.focus() + return + + try: + history_days = int(history_value) + except ValueError: + self.app.notify( + "Please enter a valid number for history period", + severity="error", + timeout=3, + ) + history_input.focus() + return + + if history_days < 1 or history_days > 365: + self.app.notify( + "History period must be between 1 and 365 days", + severity="error", + timeout=3, + ) + history_input.focus() + return + + # Validate quiet days + if not quiet_value: + self.app.notify( + "Please enter a quiet time period value", + severity="error", + timeout=3, + ) + quiet_input.focus() + return + + try: + quiet_days = int(quiet_value) + except ValueError: + self.app.notify( + "Please enter a valid number for quiet time period", + severity="error", + timeout=3, + ) + quiet_input.focus() + return + + if quiet_days < 1 or quiet_days > 365: + self.app.notify( + "Quiet time period must be between 1 and 365 days", + severity="error", + timeout=3, + ) + quiet_input.focus() + return + + # Check that quiet days doesn't exceed history days + if quiet_days > history_days: + self.app.notify( + "Quiet time period cannot exceed history period", + severity="error", + timeout=3, + ) + quiet_input.focus() + return + + # All validation passed + self.history_days = history_days + self.quiet_days = quiet_days + logger.info( + f"Selected history days: {history_days}, quiet days: {quiet_days}" ) - btn.styles.width = "100%" - btn.styles.margin = (0, 0, 1, 0) - button_container.mount(btn) + self._start_analysis() - back_btn = Button("← Back", id="back_to_policy_selection") - back_btn.styles.width = "100%" - back_btn.styles.margin = (2, 0, 0, 0) - button_container.mount(back_btn) + except Exception as e: + logger.error(f"Error validating analysis parameters: {e}") + self.app.notify(f"Error: {str(e)}", severity="error", timeout=3) def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button press events.""" button_id = event.button.id - # Quiet days selection buttons - if button_id and button_id.startswith("quiet_days_"): - days = int(button_id.split("_")[-1]) - self.quiet_days = days - logger.info(f"Selected quiet days: {days}") - self._start_analysis() + # Analysis parameters submit button + if button_id == "analysis_params_submit": + self._validate_and_submit_history_days() return # Navigation buttons - if button_id == "back_to_policy_selection": - self._show_policy_selection() - return - - if button_id == "back_to_results": - self._show_results() - return - if button_id == "select_enforce_target_btn": self._show_enforce_target_selection() return @@ -270,46 +378,44 @@ class QuietAgentWorkflowScreen(Screen): self._show_policy_selection() return + def on_input_submitted(self, event: Input.Submitted) -> None: + """Handle input submission (Enter key pressed).""" + if event.input.id in ["history_days_input", "quiet_days_input"]: + self._validate_and_submit_history_days() + def _start_analysis(self) -> None: """Start the agent activity analysis.""" - self.workflow_stage = "analyzing" - content = self.query_one("#content_area", Vertical) - content.remove_children() - - # Show analyzing message with detailed steps - analyzing_msg = Static( - f"📊 Analyzing Agent Activity\n" - f"{'=' * 50}\n\n" - f"Policy: {self.selected_policy.name}\n" - f"History Period: {self.history_days} days\n" - f"Quiet Threshold: {self.quiet_days} days\n\n" - f"Progress:\n" - f"⏳ Step 1/4: Fetching agents from policy...\n" - f"⏱️ Step 2/4: Pulling execution history (this may take a moment)...\n" - f"⏱️ Step 3/4: Analyzing activity patterns...\n" - f"⏱️ Step 4/4: Categorizing agents...\n\n" - f"Please wait - this operation cannot be cancelled.", - id="analyzing_message", - ) - analyzing_msg.styles.margin = (2, 1) - content.mount(analyzing_msg) - - # Show notification + # Show notification that analysis is starting self.app.notify( "Starting analysis - this may take several minutes for large policies", severity="information", timeout=5, ) - # Perform the analysis asynchronously - self.call_later(self._perform_analysis) + # Clear the screen to provide a blank canvas for Rust progress output + # (Rust output displays over the TUI, so we clear everything except header/footer) + try: + # Clear title + title_widget = self.query_one("#workflow_title", Static) + title_widget.update("") - def _perform_analysis(self) -> None: + # Clear status + status_widget = self.query_one("#workflow_status", Static) + status_widget.update("") + + # Clear content area + content = self.query_one("#content_area", Vertical) + content.remove_children() + except Exception as e: + logger.debug(f"Could not clear screen for analysis: {e}") + + # Delay the analysis start to ensure UI refresh completes first + # This prevents Rust output from starting before the screen is cleared + self.set_timer(0.5, self._perform_analysis_worker) + + def _perform_analysis_worker(self) -> None: """Perform the actual agent activity analysis.""" try: - # Update status: Fetching agents - self._update_analysis_status("Step 1/4: Fetching agents from policy...") - # Get agents in the selected policy agents = self.api.agents_find_by_group(self.selected_policy.groupid) @@ -322,32 +428,11 @@ class QuietAgentWorkflowScreen(Screen): self._show_policy_selection() return - agent_count = len(agents) - self.app.notify( - f"Found {agent_count} agents - fetching execution history...", - severity="information", - timeout=3, - ) - - # Update status: Pulling execution history - self._update_analysis_status( - f"Step 2/4: Pulling execution history for {agent_count} agents...\n" - f"(This may take several minutes - progress shown in terminal)" - ) - # Get execution history (this shows progress bars in terminal via airlock_libs) policy_exec_history = getPolicyInfo( self.api, self.selected_policy, [1, 2, 6, 7], self.history_days ) - # Update status: Analyzing patterns - self._update_analysis_status("Step 3/4: Analyzing activity patterns...") - self.app.notify( - "History retrieved - analyzing patterns...", - severity="information", - timeout=2, - ) - if policy_exec_history.empty: logger.info( "No execution history found for the selected policy and time range." @@ -393,9 +478,6 @@ class QuietAgentWorkflowScreen(Screen): lambda x: True if pd.isna(x) or x > self.quiet_days else False ) - # Update status: Categorizing - self._update_analysis_status("Step 4/4: Categorizing agents...") - # Sort agents agents = agents.sort_values( by=["execution_count", "hostname"], ascending=[True, True] @@ -405,8 +487,8 @@ class QuietAgentWorkflowScreen(Screen): self.agents_df = agents # Categorize agents into DataFrames - self.enforce_ready_df = agents[agents["enforce_ready"] == True].copy() - self.non_enforce_ready_df = agents[agents["enforce_ready"] == False].copy() + self.enforce_ready_df = agents[agents["enforce_ready"]].copy() + self.non_enforce_ready_df = agents[~agents["enforce_ready"]].copy() logger.info( f"Analysis complete: {len(self.enforce_ready_df)} enforce ready, " @@ -428,27 +510,6 @@ class QuietAgentWorkflowScreen(Screen): self.app.notify(f"Analysis failed: {str(e)}", severity="error", timeout=5) self._show_policy_selection() - def _update_analysis_status(self, status_text: str) -> None: - """Update the analysis status message.""" - try: - analyzing_msg = self.query_one("#analyzing_message", Static) - - # Build updated message - updated_text = ( - f"📊 Analyzing Agent Activity\n" - f"{'=' * 50}\n\n" - f"Policy: {self.selected_policy.name}\n" - f"History Period: {self.history_days} days\n" - f"Quiet Threshold: {self.quiet_days} days\n\n" - f"Progress:\n" - f"✅ {status_text}\n\n" - f"Please wait - this operation cannot be cancelled." - ) - - analyzing_msg.update(updated_text) - except Exception as e: - logger.debug(f"Could not update analysis status: {e}") - def _show_results(self) -> None: """Show the categorized results.""" self.workflow_stage = "view_results" @@ -469,9 +530,9 @@ class QuietAgentWorkflowScreen(Screen): summary = Static( f"Analysis Results for: {self.selected_policy.name}\n\n" - f"📊 Total Agents: {total_agents}\n" - f"✅ Enforce Ready: {ready_count} ({ready_percentage:.1f}%)\n" - f"❌ Not Ready: {not_ready_count} ({100 - ready_percentage:.1f}%)\n\n" + f"Total Agents: {total_agents}\n" + f"Enforce Ready: {ready_count} ({ready_percentage:.1f}%)\n" + f"Not Ready: {not_ready_count} ({100 - ready_percentage:.1f}%)\n\n" f"Quiet Threshold: {self.quiet_days} days\n" f"History Period: {self.history_days} days", id="results_summary", @@ -500,11 +561,11 @@ class QuietAgentWorkflowScreen(Screen): non_enforce_btn.styles.margin = (0, 1, 1, 0) button_container.mount(non_enforce_btn) - export_btn = Button("💾 Export Results", id="export_results_btn") + export_btn = Button("Export Results", id="export_results_btn") export_btn.styles.margin = (0, 1, 1, 0) button_container.mount(export_btn) - start_over_btn = Button("🔄 Start Over", id="start_over_btn") + start_over_btn = Button("Start Over", id="start_over_btn") start_over_btn.styles.margin = (0, 0, 1, 0) button_container.mount(start_over_btn) @@ -520,7 +581,7 @@ class QuietAgentWorkflowScreen(Screen): enforce_col.styles.margin = (1, 1, 0, 0) tables_container.mount(enforce_col) - enforce_label = Static("✅ Enforce Ready Agents") + enforce_label = Static("Enforce Ready Agents") enforce_label.styles.margin = (0, 0, 1, 0) enforce_col.mount(enforce_label) @@ -548,7 +609,7 @@ class QuietAgentWorkflowScreen(Screen): non_enforce_col.styles.margin = (1, 0, 0, 1) tables_container.mount(non_enforce_col) - non_enforce_label = Static("❌ Non-Enforce Ready Agents") + non_enforce_label = Static("Non-Enforce Ready Agents") non_enforce_label.styles.margin = (0, 0, 1, 0) non_enforce_col.mount(non_enforce_label) @@ -589,7 +650,7 @@ class QuietAgentWorkflowScreen(Screen): content.mount(policy_selector) # Skip button - skip_btn = Button("⭕️ Skip - No Migration", id="skip_enforce_target_btn") + skip_btn = Button("Skip - No Migration", id="skip_enforce_target_btn") skip_btn.styles.width = "50%" skip_btn.styles.margin = (2, 0, 0, 0) content.mount(skip_btn) @@ -614,7 +675,7 @@ class QuietAgentWorkflowScreen(Screen): content.mount(policy_selector) # Skip button - skip_btn = Button("⭕️ Skip - No Migration", id="skip_non_enforce_target_btn") + skip_btn = Button("Skip - No Migration", id="skip_non_enforce_target_btn") skip_btn.styles.width = "50%" skip_btn.styles.margin = (2, 0, 0, 0) content.mount(skip_btn) @@ -627,29 +688,29 @@ class QuietAgentWorkflowScreen(Screen): # Build confirmation message confirmation_lines = [ - "🔐 Migration Summary\n", + "Migration Summary\n", f"Source Policy: {self.selected_policy.name}\n", ] if self.enforce_ready_target_policy: confirmation_lines.append( - f"\n✅ Enforce Ready Migration:\n" - f" • Agents: {len(self.enforce_ready_df)}\n" - f" • Target: {self.enforce_ready_target_policy.name}\n" + f"\nEnforce Ready Migration:\n" + f"Agents: {len(self.enforce_ready_df)}\n" + f"Target: {self.enforce_ready_target_policy.name}\n" ) if self.non_enforce_ready_target_policy: confirmation_lines.append( - f"\n❌ Non-Enforce Ready Migration:\n" - f" • Agents: {len(self.non_enforce_ready_df)}\n" - f" • Target: {self.non_enforce_ready_target_policy.name}\n" + f"\nNon-Enforce Ready Migration:\n" + f"Agents: {len(self.non_enforce_ready_df)}\n" + f"Target: {self.non_enforce_ready_target_policy.name}\n" ) if ( not self.enforce_ready_target_policy and not self.non_enforce_ready_target_policy ): - confirmation_lines.append("\n⚠️ No migrations will be performed.") + confirmation_lines.append("\nNo migrations will be performed.") confirmation = Static("".join(confirmation_lines), id="migration_confirmation") confirmation.styles.margin = (1, 1, 2, 1) @@ -662,11 +723,11 @@ class QuietAgentWorkflowScreen(Screen): content.mount(button_container) if self.enforce_ready_target_policy or self.non_enforce_ready_target_policy: - confirm_btn = Button("✅ Confirm Migration", id="confirm_migration_btn") + confirm_btn = Button("Confirm Migration", id="confirm_migration_btn") confirm_btn.styles.margin = (0, 1, 0, 0) button_container.mount(confirm_btn) - cancel_btn = Button("❌ Cancel", id="cancel_migration_btn") + cancel_btn = Button("Cancel", id="cancel_migration_btn") button_container.mount(cancel_btn) def _execute_migration(self) -> None: @@ -677,7 +738,7 @@ class QuietAgentWorkflowScreen(Screen): # Show executing message executing_msg = Static( - "⏳ Executing agent migrations...\nPlease wait...", + "Executing agent migrations...\nPlease wait...", id="executing_message", ) executing_msg.styles.margin = (2, 1) @@ -696,7 +757,7 @@ class QuietAgentWorkflowScreen(Screen): if self.enforce_ready_target_policy: for idx, row in self.enforce_ready_df.iterrows(): try: - result = self.api.agent_move( + self.api.agent_move( row["agentid"], self.enforce_ready_target_policy.groupid ) successful_migrations.append( @@ -713,7 +774,7 @@ class QuietAgentWorkflowScreen(Screen): if self.non_enforce_ready_target_policy: for idx, row in self.non_enforce_ready_df.iterrows(): try: - result = self.api.agent_move( + self.api.agent_move( row["agentid"], self.non_enforce_ready_target_policy.groupid ) successful_migrations.append( @@ -749,7 +810,7 @@ class QuietAgentWorkflowScreen(Screen): ) results = Static( - f"✅ Migration Complete\n\n" + f"Migration Complete\n\n" f"Total Agents Migrated: {len(successful)}\n" f"Failed Migrations: {len(failed)}\n" f"Success Rate: {success_rate:.1f}%", @@ -764,7 +825,7 @@ class QuietAgentWorkflowScreen(Screen): success_container.styles.margin = (0, 1) content.mount(success_container) - success_label = Static("✅ Successful Migrations") + success_label = Static("Successful Migrations") success_label.styles.margin = (0, 0, 1, 0) success_container.mount(success_label) @@ -785,7 +846,7 @@ class QuietAgentWorkflowScreen(Screen): failed_container.styles.margin = (2, 1, 0, 1) content.mount(failed_container) - failed_label = Static("❌ Failed Migrations") + failed_label = Static("Failed Migrations") failed_label.styles.margin = (0, 0, 1, 0) failed_container.mount(failed_label) @@ -802,7 +863,7 @@ class QuietAgentWorkflowScreen(Screen): failed_container.mount(failed_table) # Action button - done_btn = Button("✔ Done", id="start_over_btn") + done_btn = Button("Done", id="start_over_btn") done_btn.styles.width = "50%" done_btn.styles.margin = (2, 0, 0, 0) content.mount(done_btn) @@ -833,7 +894,7 @@ class QuietAgentWorkflowScreen(Screen): # Depending on stage, go back to previous stage or exit if self.workflow_stage in ["select_policy", "view_results", "complete"]: self.app.pop_screen() - elif self.workflow_stage == "select_quiet_days": + elif self.workflow_stage == "select_history_days": self._show_policy_selection() elif self.workflow_stage == "select_enforce_target": self._show_results() @@ -846,3 +907,8 @@ class QuietAgentWorkflowScreen(Screen): self._show_non_enforce_target_selection() else: self.app.pop_screen() + + def action_main_menu(self) -> None: + """Go back to main menu.""" + while len(self.app.screen_stack) > 2: + self.app.pop_screen() diff --git a/TUI/TUI.py b/TUI/TUI.py deleted file mode 100644 index 2c5d4fc..0000000 --- a/TUI/TUI.py +++ /dev/null @@ -1,511 +0,0 @@ -import logging -import os -import sys -from typing import Optional - -import dotenv -from textual.app import App, ComposeResult -from textual.containers import Vertical -from textual.message import Message -from textual.reactive import reactive -from textual.screen import Screen -from textual.widgets import ( - Button, - DirectoryTree, - Footer, - Header, - Static, - Tab, - Tabs, -) - -from flows.otp import otp_revoke -from flows.prepPolicy import menu_policy_enforce -from models.agent import Agent -from models.policy import Policy -from services.API import AirlockAPIWrapper -from services.policyhandler import confirmUpdateAfromE -from TUI.agentmoveoperations import AgentMoveOperations -from TUI.moveagentworkflowscreen import MoveAgentWorkflowScreen -from TUI.multiagentselector import MultiAgentSelector -from TUI.OTP_generate import OTPGenerator -from TUI.otpactivityscreen import OTPActivitiesScreen -from TUI.otpworkflowscreen import OTPWorkflowScreen -from TUI.policytreewidget import PolicyTreeWidget -from TUI.quietagentworkflowscreen import QuietAgentWorkflowScreen -from TUI.resultsdisplay import ResultsDisplay -from TUI.theme_amber_terminal import get_amber_terminal_theme -from TUI.theme_retro_terminal import get_retro_terminal_theme -from TUI.themeselector import ThemeSelector -from utils.configmanager import get_user_value, load_env, save_user_config -from utils.setup import get_base_directory -from utils.utils import open_directory - -dotenv.load_dotenv() - -# --------------------------------------------------------------------------- -# GLOBAL STASH -# --------------------------------------------------------------------------- - -_PENDING_JOB = None - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# helper to persist TEXTUAL_THEME to *user* config and mirror to .env -# --------------------------------------------------------------------------- -def _persist_user_theme(theme_name: str) -> None: - """ - Store the chosen Textual theme in the user's config using the config manager. - No need to touch .env - config manager handles everything. - """ - base_dir = get_base_directory() - config_dir = base_dir / "config" - - try: - save_user_config(config_dir, {"TEXTUAL_THEME": theme_name}) - logger.debug("Updated user config with TEXTUAL_THEME=%s", theme_name) - except Exception as exc: - logger.error("Failed to save TEXTUAL_THEME: %s", exc) - - -# --------------------------------------------------------------------------- -# 1) SCREEN -# --------------------------------------------------------------------------- -class MainMenuScreen(Screen): - api: AirlockAPIWrapper - current_tab = reactive("") - - BUTTON_DEFS = { - "agent_actions": [ - ( - "🖥️ - Find, Move, or Generate OTP for Agents", - "move_agent_workflow_button", - ), - ("📊 - Review and appove OTP Activities", "otp_activities_button"), - ("📇 - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), - ], - "policy": [ - ("🔒 - Prepare Policy For Enforcement", "policy_prep_button"), - ("🔄 - Update Audit Policies", "policy_audit_update_button"), - ("❌ - Revoke OTPs", "otp_revoke_button"), - ], - } - - def __init__(self) -> None: - super().__init__() - self.extras = get_user_value("EXTRAS", str, "NOTTODAY") - wd = load_env("WORKING_DIR") or os.getcwd() - if not os.path.isdir(wd): - wd = os.getcwd() - self.working_dir = wd - - def _make_buttons_for(self, tab_id: str) -> Vertical: - defs = self.BUTTON_DEFS.get(tab_id, []) - buttons = [] - for label, btn_id in defs: - btn = Button(label, id=btn_id) - btn.styles.width = "100%" - buttons.append(btn) - return Vertical(*buttons) - - def compose(self) -> ComposeResult: - yield Header(show_clock=True, icon="⚙") - - tabs = [ - Tab("Tree View", id="p_tree"), - Tab("Agents", id="agent_actions"), - Tab("Directory", id="dir"), - Tab("Settings", id="settings"), - ] - - if self.extras == "POLICYPREP": - tabs.insert(2, Tab("Policy Prep", id="policy")) - - yield Tabs(*tabs, id="tabs") - yield Vertical(id="content") - yield Footer() - - def on_mount(self) -> None: - api = self.app.api - self.switch_tab("agent_actions") - - # focus helpers - def _get_content_buttons(self) -> list[Button]: - content = self.query_one("#content", Vertical) - return list(content.query(Button)) - - def _focus_first_button(self) -> None: - buttons = self._get_content_buttons() - if buttons: - buttons[0].focus() - - def _focus_tabs(self) -> None: - tabs = self.query_one("#tabs", Tabs) - tabs.focus() - - def _focus_nearby_button(self, direction: int) -> None: - buttons = self._get_content_buttons() - if not buttons: - return - - try: - current = next(i for i, b in enumerate(buttons) if b.has_focus) - except StopIteration: - if direction > 0: - buttons[0].focus() - else: - buttons[-1].focus() - return - - if direction < 0 and current == 0: - self._focus_tabs() - return - - new_index = current + direction - if 0 <= new_index < len(buttons): - buttons[new_index].focus() - - def switch_tab(self, tab_id: str) -> None: - self.current_tab = tab_id - content = self.query_one("#content", Vertical) - content.remove_children() - - if tab_id in self.BUTTON_DEFS: - content.mount(self._make_buttons_for(tab_id)) - self.call_later(self._focus_first_button) - elif tab_id == "dir": - content.mount(DirectoryTree(self.working_dir, id="dir_tree")) - elif tab_id == "p_tree": - content.mount(PolicyTreeWidget(self.app.policies, self.app.devices)) - elif tab_id == "settings": - content.mount(ThemeSelector()) - else: - content.mount(Static(f"Unknown tab: {tab_id}")) - - def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: - self.switch_tab(event.tab.id) - - def on_multi_agent_selector_agents_selected( - self, message: MultiAgentSelector.AgentsSelected - ) -> None: - """Handle selected agents from AgentSelector.""" - global _PENDING_JOB - selected_agents = message.selected_agents - logger.info("Selected agents: %s", selected_agents) - # TODO: Implement actual handling of selected agents - _PENDING_JOB = ("multi_agent_action", selected_agents) - self.app.exit() - - def on_theme_selector_theme_selected( - self, message: ThemeSelector.ThemeSelected - ) -> None: - """Handle theme selection from ThemeSelector.""" - global _PENDING_JOB - _persist_user_theme(message.theme_name) - _PENDING_JOB = ("restart",) - self.app.exit() - - def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: - """Handle OTP generation request from the workflow.""" - global _PENDING_JOB - - logger.info( - "OTP Generation requested: %d devices, requestor=%s, reason=%s, duration=%d", - len(message.devices), - message.requestor, - message.reasoning, - message.duration, - ) - - _PENDING_JOB = ( - "otp_workflow", - message.devices, - message.requestor, - message.reasoning, - message.duration, - ) - - self.app.exit() - - def on_agent_move_operations_operation_complete( - self, message: AgentMoveOperations.OperationComplete - ) -> None: - """Handle completion of agent move operation - show results.""" - logger.info( - "Agent move operation completed: %s, %d successful, %d unsuccessful", - message.operation, - len(message.successful), - len(message.unsuccessful), - ) - - # Format results for display - successful_text = "\n".join( - [f"{agent.hostname}" for agent, _ in message.successful] - ) - unsuccessful_text = "\n".join( - [f"{agent.hostname}: {error}" for agent, error in message.unsuccessful] - ) - - # Remove the operations widget - try: - ops_widget = self.query_one(AgentMoveOperations) - ops_widget.remove() - except Exception: - pass - - # Show results - self.query_one("#content", Vertical).mount( - ResultsDisplay(message.operation, successful_text, unsuccessful_text) - ) - - def on_results_display_go_back(self, message: ResultsDisplay.GoBack) -> None: - """Handle back button from results display.""" - try: - results_widget = self.query_one(ResultsDisplay) - results_widget.remove() - except Exception: - pass - # Return to main menu - self.app.pop_screen() - - def on_directory_tree_file_selected( - self, event: DirectoryTree.FileSelected - ) -> None: - path = event.path - logger.debug("Directory file selected: %s", path) - try: - open_directory(str(path)) - except Exception as exc: - logger.error("Failed to open %s: %s", path, exc) - self.app.bell() - - def on_button_pressed(self, event: Button.Pressed) -> None: - global _PENDING_JOB - button_id = event.button.id - logger.debug("Button pressed: %s", button_id) - - match button_id: - case "move_agent_workflow_button": - self.app.push_screen(MoveAgentWorkflowScreen(self.app.devices)) - event.stop() - - case "otp_generate_button": - self.app.push_screen(OTPWorkflowScreen(self.app.devices)) - event.stop() - - case "find_quiet_button": - self.app.push_screen( - QuietAgentWorkflowScreen(self.app.api, self.app.policies) - ) - event.stop() - return - - case "otp_activities_button": - self.app.push_screen(OTPActivitiesScreen()) - event.stop() - return - - case "otp_revoke_button": - _PENDING_JOB = ("legacy", otp_revoke, (self.app.api,), {}) - - case "policy_prep_button": - _PENDING_JOB = ("legacy", menu_policy_enforce, (self.app.api,), {}) - - case "policy_audit_update_button": - _PENDING_JOB = ("legacy", confirmUpdateAfromE, (self.app.api,), {}) - - case _: - self.app.bell() - logger.warning("Unknown button pressed: %s", button_id) - return - - # Only exit the UI loop when we explicitly queued a legacy job. - # The original flow used `self.app.exit()` after setting _PENDING_JOB so - # the outer loop could run legacy code. Keep that behavior only for legacy jobs. - logger.debug("Set _PENDING_JOB = %r", _PENDING_JOB) - if _PENDING_JOB and _PENDING_JOB[0] == "legacy": - # let the main loop pick up the legacy job - self.app.exit() - - -# --------------------------------------------------------------------------- -# 2) APP -# --------------------------------------------------------------------------- -class Loxide(App[Message]): - api: AirlockAPIWrapper - working_dir: str - policies: Optional[list[Policy]] - devices: Optional[list[Agent]] - - CSS = """ - #logo { - width: 100%; - content-align: center middle; - text-align: center; - } - """ - BINDINGS = [ - ("q", "quit", "Quit"), - ("f", "open_fe", "Launch Explorer"), - ("r", "refresh", "Refresh"), - ] - - def __init__(self, api: AirlockAPIWrapper): - self._textual_theme = get_user_value("TEXTUAL_THEME", str, "nord") - super().__init__() - self.api = api - wd = load_env("WORKING_DIR") or os.getcwd() - if not os.path.isdir(wd): - wd = os.getcwd() - self.working_dir = wd - # Initial data load - self.refresh_data() - - def refresh_data(self) -> None: - """Public method to refresh policies and devices from the API.""" - try: - self.policies = [ - Policy(**row.to_dict()) - for _, row in self.api.policy_find_all().iterrows() - ] - self.devices = [ - Agent(**row.to_dict()) - for _, row in self.api.agent_find_all().iterrows() - ] - if self.policies and self.devices: - for agent in self.devices: - agent.enrich_with_policies(self.policies) - logger.debug( - f"Enriched {len(self.devices)} agents with policy information" - ) - except Exception as exc: - logger.error("Failed to load policies/devices: %s", exc) - self.policies = None - self.devices = None - - def on_mount(self, api: AirlockAPIWrapper) -> None: - self.register_theme(get_retro_terminal_theme()) - self.register_theme(get_amber_terminal_theme()) - self.theme = self._textual_theme - self.push_screen(MainMenuScreen()) - - def action_refresh(self) -> None: - self.refresh_data() - - def action_quit(self) -> None: - global _PENDING_JOB - _PENDING_JOB = None - self.exit() - - def action_open_fe(self) -> None: - """Open the working directory in the OS file manager (footer binding).""" - path_to_open = self.working_dir or os.getcwd() - try: - open_directory(path_to_open) - except Exception as exc: - logger.error("Failed to open directory %s: %s", path_to_open, exc) - self.bell() # optional feedback - - -# --------------------------------------------------------------------------- -# 3) TERMINAL + LEGACY -# --------------------------------------------------------------------------- -def _restore_terminal_for_legacy() -> None: - sys.stdout.write("\033[?1049l") - sys.stdout.write("\033[?25h") - sys.stdout.write("\033[0m") - sys.stdout.write("\033[?1000l\033[?1002l\033[?1003l\033[?1006l") - sys.stdout.write("\033[2J\033[H") - sys.stdout.flush() - if os.name == "nt": - try: - import ctypes - - kernel32 = ctypes.windll.kernel32 - handle = kernel32.GetStdHandle(-11) - mode = ctypes.c_ulong() - if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): - kernel32.SetConsoleMode(handle, mode.value | 0x0004) - except Exception as exc: - logger.debug("VT enable on Windows failed: %s", exc) - - -def _run_legacy_job(func, args, kwargs) -> None: - logger.debug("Running legacy job: %s", getattr(func, "__name__", func)) - _restore_terminal_for_legacy() - try: - func(*args, **kwargs) - finally: - try: - input("\nPress Enter to return to the UI...") - except EOFError: - pass - - -# --------------------------------------------------------------------------- -# 4) PUBLIC ENTRYPOINT -# --------------------------------------------------------------------------- -def run_Loxide(api: AirlockAPIWrapper) -> None: - global _PENDING_JOB - base_dir = get_base_directory() - env_path = base_dir / ".env" - dotenv.load_dotenv(dotenv_path=env_path, override=True) - - max_attempts = 5 - attempts = 0 - - while attempts < max_attempts: - attempts += 1 - logger.debug("Starting job loop iteration (attempt %d)", attempts) - _PENDING_JOB = None - app = Loxide(api) - - try: - app.run() - except SystemExit as exc: - if exc.code != 0: - logger.debug("Caught SystemExit from Textual: %s", exc) - raise - - job = _PENDING_JOB - logger.debug("After app.run(), _PENDING_JOB = %r", job) - - if not job: - logger.debug("No job pending, exiting loop") - break - - if job[0] == "legacy": - _, func, args, kwargs = job - _run_legacy_job(func, args, kwargs) - continue - - if job[0] == "restart": - logger.debug("Restarting job loop") - continue - - if job[0] == "multi_agent_action": - logger.info("Multi-agent action with selected agents: %s", job[1]) - continue - - if job[0] == "otp_workflow": - _, devices, requestor, reasoning, duration = job - - def otp_generate_with_params(): - # Your OTP logic here - pass - - _run_legacy_job(otp_generate_with_params, (), {}) - continue - - logger.error("Unknown job type: %r", job) - break - - -# --------------------------------------------------------------------------- -# 5) DEV -# --------------------------------------------------------------------------- -if __name__ == "__main__": - api = AirlockAPIWrapper() - run_Loxide(api) diff --git a/TUI/theme_amber_terminal.py b/TUI/Themes/theme_amber_terminal.py similarity index 55% rename from TUI/theme_amber_terminal.py rename to TUI/Themes/theme_amber_terminal.py index 3662892..738be38 100644 --- a/TUI/theme_amber_terminal.py +++ b/TUI/Themes/theme_amber_terminal.py @@ -1,3 +1,18 @@ +# 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 . + from textual.color import Color from textual.theme import Theme diff --git a/TUI/theme_retro_terminal.py b/TUI/Themes/theme_retro_terminal.py similarity index 50% rename from TUI/theme_retro_terminal.py rename to TUI/Themes/theme_retro_terminal.py index e0cd9c9..1de2669 100644 --- a/TUI/theme_retro_terminal.py +++ b/TUI/Themes/theme_retro_terminal.py @@ -1,3 +1,18 @@ +# 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 . + from textual.color import Color diff --git a/TUI/themeselector.py b/TUI/Themes/themeselector.py similarity index 68% rename from TUI/themeselector.py rename to TUI/Themes/themeselector.py index bcbbb99..b21bb9d 100644 --- a/TUI/themeselector.py +++ b/TUI/Themes/themeselector.py @@ -1,3 +1,18 @@ +# 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 . + from textual.containers import Vertical from textual.message import Message from textual.widget import Widget diff --git a/TUI/OTP_generate.py b/TUI/Widgets/OTP_generate.py similarity index 92% rename from TUI/OTP_generate.py rename to TUI/Widgets/OTP_generate.py index 4ca7977..4dacdf6 100644 --- a/TUI/OTP_generate.py +++ b/TUI/Widgets/OTP_generate.py @@ -1,3 +1,18 @@ +# 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 . + import logging from typing import List, Optional @@ -23,6 +38,8 @@ 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) @@ -139,14 +156,10 @@ class OTPGenerator(Widget): button_row.styles.height = "auto" button_row.styles.margin = (1, 0, 0, 0) - back_button = Button("← Back", id="back_button") - back_button.styles.width = "1fr" - yield back_button - generate_button = Button( "Generate OTP", id="generate_button", variant="primary" ) - generate_button.styles.width = "2fr" + generate_button.styles.width = "100%" yield generate_button # Right side - Show device list initially, then output after generation @@ -169,7 +182,7 @@ class OTPGenerator(Widget): # Show device list initially device_list_text = "\n".join( - f"• {device.hostname}" for device in self.devices + f"{device.hostname}" for device in self.devices ) device_display = Static(device_list_text, id="device_display") yield device_display @@ -197,14 +210,7 @@ class OTPGenerator(Widget): def on_button_pressed(self, event: Button.Pressed): btn_id = event.button.id - if btn_id == "back_button": - - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - - event.stop() - - elif btn_id == "copy_clipboard_button": + if btn_id == "copy_clipboard_button": try: output_area = self.query_one("#otp_output", TextArea) text_to_copy = output_area.text @@ -213,7 +219,7 @@ class OTPGenerator(Widget): pyperclip.copy(text_to_copy) self.app.notify( - "✅ Copied to clipboard!", severity="information", timeout=2 + "✓ Copied to clipboard!", severity="information", timeout=2 ) except ImportError: self.app.notify( diff --git a/TUI/agentmoveoperations.py b/TUI/Widgets/agentmoveoperations.py similarity index 85% rename from TUI/agentmoveoperations.py rename to TUI/Widgets/agentmoveoperations.py index 26032a0..15cfc96 100644 --- a/TUI/agentmoveoperations.py +++ b/TUI/Widgets/agentmoveoperations.py @@ -1,3 +1,19 @@ +# 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 . + + from dataclasses import asdict from datetime import datetime import logging @@ -10,12 +26,13 @@ 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, Header, Static, TextArea +from textual.widgets import Button, DataTable, Footer, Header, Static, TextArea from models.agent import Agent -from TUI.OTP_generate import OTPGenerator -from TUI.otpworkflowscreen import OTPWorkflowScreen -from TUI.policyselectorscreen import PolicySelectorScreen +from TUI.Screens.executionhistoryscreen import ExecutionHistoryScreen +from TUI.Screens.otpworkflowscreen import OTPWorkflowScreen +from TUI.Screens.policyselectorscreen import PolicySelectorScreen +from TUI.Widgets.OTP_generate import OTPGenerator logger = logging.getLogger(__name__) @@ -124,6 +141,7 @@ class AgentMoveOperations(Widget): 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) + exec_history_btn = self.query_one("#exec_history_btn", Button) # If operation in progress, disable all if self.operation_in_progress: @@ -132,6 +150,7 @@ class AgentMoveOperations(Widget): local_approval_btn.disabled = True toggle_enforcement_btn.disabled = True other_policy_btn.disabled = True + exec_history_btn.disabled = True else: # If an operation was selected, disable if self.selected_operation: @@ -146,6 +165,9 @@ class AgentMoveOperations(Widget): other_policy_btn.disabled = ( self.selected_operation == "other_policy" ) + exec_history_btn.disabled = ( + self.selected_operation == "exec_history" + ) else: # Enable all buttons otp_gen_btn = False @@ -153,6 +175,7 @@ class AgentMoveOperations(Widget): local_approval_btn.disabled = False toggle_enforcement_btn.disabled = False other_policy_btn.disabled = False + exec_history_btn.disabled = False except NoMatches: pass @@ -183,21 +206,21 @@ class AgentMoveOperations(Widget): f"Operation: {operation_name}", f"{'=' * 50}", "", - f"✅ Successful ({len(successful)}):", + f"✅ Successful ({len(successful)}):", ] if successful: for agent, result in successful: - results_lines.append(f" ✅ {agent.hostname}") + results_lines.append(f" ✅ {agent.hostname}") else: results_lines.append(" (none)") results_lines.append("") - results_lines.append(f"❌ Failed ({len(unsuccessful)}):") + results_lines.append(f"❌ Failed ({len(unsuccessful)}):") if unsuccessful: for agent, error in unsuccessful: - results_lines.append(f" ❌ {agent.hostname}: {error}") + results_lines.append(f" ❌ {agent.hostname}: {error}") else: results_lines.append(" (none)") @@ -232,9 +255,9 @@ class AgentMoveOperations(Widget): - Operations panel: 1/3 width - Results area: Initially hidden, shown after operation completion """ - yield Header(show_clock=True, icon="âš™") + yield Header(show_clock=True, icon="⚙️") title_text = Static( - f"🖥️ Agent Operations - {len(self.agents)} device(s) selected", + f"🖥️ Agent Operations - {len(self.agents)} device(s) selected", id="move_ops_title", ) title_text.styles.margin = (0, 0, 1, 0) @@ -269,46 +292,50 @@ class AgentMoveOperations(Widget): yield operations_label # Operation buttons - export_csv_btn = Button("📈 Export CSV", id="export_csv_btn") + 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 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 = 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 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" + "🔀 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 + exec_history_btn = Button( + "📊 View Execution History", id="exec_history_btn" + ) + exec_history_btn.styles.width = "100%" + exec_history_btn.styles.margin = (0, 0, 1, 0) + yield exec_history_btn + # Status label status_label = Static("", id="status_label") status_label.styles.margin = (2, 0, 0, 0) yield status_label - back_button = Button("← Back", id="back_button") - back_button.styles.width = "50%" - back_button.styles.margin = (0, 1, 1, 0) - yield back_button + yield Footer() def on_mount(self) -> None: """ @@ -343,7 +370,7 @@ class AgentMoveOperations(Widget): Handle button press events from the widget. This Textual event handler routes button presses to appropriate actions: - - back_button: Pop this screen (return to parent) + - 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 @@ -357,29 +384,24 @@ class AgentMoveOperations(Widget): btn_id = event.button.id - if btn_id == "back_button": - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - event.stop() - - elif btn_id == "copy_results_btn": + 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!", + "📋✅ Results copied to clipboard!", severity="information", timeout=2, ) except ImportError: self.app.notify( - "❌ pyperclip not installed. Run: pip install pyperclip", + "❌ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: - self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") + self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") event.stop() elif btn_id == "export_csv_btn": self._start_export_csv_operation() @@ -399,6 +421,9 @@ class AgentMoveOperations(Widget): elif btn_id == "otp_gen_btn": self._start_OTP_gen_operation() event.stop() + elif btn_id == "exec_history_btn": + self._start_execution_history_operation() + event.stop() def _start_local_approval_operation(self) -> None: """ @@ -427,7 +452,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("✔️ Moving agents to local approval...") + status_label.update("✔️ Moving agents to local approval...") # Get API from app api = self.app.api @@ -462,12 +487,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during local approval operation: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("✅ Operation complete!") + status_label.update("✅ Operation complete!") # Display results in the widget self._display_results("Local Approval Mode", successful, unsuccessful) @@ -483,7 +508,6 @@ class AgentMoveOperations(Widget): self.selected_operation = "export_csv" self.operation_in_progress = True successful = [] - unsuccessful = [] status_label = self.query_one("#status_label", Static) status_label.update("Exporting CSV...") self.app.refresh_data() @@ -511,9 +535,9 @@ class AgentMoveOperations(Widget): 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}") + status_label.update(f"✅ Exported to {file_path}") except Exception: - status_label.update("❌ Failed") + status_label.update("❌ Failed") self.operation_in_progress = False @@ -557,7 +581,7 @@ class AgentMoveOperations(Widget): self.operation_in_progress = True status_label = self.query_one("#status_label", Static) - status_label.update("⏳ Toggling enforcement mode...") + status_label.update("🔄 Toggling enforcement mode...") # Get API from app api = self.app.api @@ -593,12 +617,12 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error during toggle enforcement operation: {e}") - status_label.update(f"❌ Error: {str(e)}") + status_label.update(f"❌ Error: {str(e)}") self.operation_in_progress = False return self.operation_in_progress = False - status_label.update("✅ Operation complete!") + status_label.update("✅ Operation complete!") # Display results in the widget self._display_results("Toggle Audit/Enforcement", successful, unsuccessful) @@ -663,7 +687,7 @@ class AgentMoveOperations(Widget): except Exception as e: logger.error(f"Error loading policies: {e}") - status_label.update(f"❌ Error: {str(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") @@ -674,6 +698,35 @@ class AgentMoveOperations(Widget): self.app.push_screen(OTPWorkflowScreen(self.agents)) + def _start_execution_history_operation(self) -> None: + """ + Launch the execution history viewer for selected agents. + + This operation opens a new screen that allows the user to: + 1. Select a date range for execution history + 2. Fetch execution logs for all selected agents + 3. View the results in a table + 4. Export the results to CSV + + The screen is pushed onto the screen stack, allowing the user to return + to this screen when done. + """ + status_label = self.query_one("#status_label", Static) + status_label.update("Opening execution history viewer...") + + try: + # Push the execution history screen + self.app.push_screen(ExecutionHistoryScreen(self.agents)) + logger.info( + f"Opened execution history viewer for {len(self.agents)} agents" + ) + except Exception as e: + logger.error(f"Failed to open execution history viewer: {e}") + status_label.update(f"❌ Error: {str(e)}") + self.app.notify( + f"Failed to open execution history: {str(e)}", severity="error" + ) + def _execute_move_to_policy(self, target_policy) -> None: """ Execute the actual move of agents to the selected policy. @@ -695,7 +748,7 @@ class AgentMoveOperations(Widget): for agent in self.agents: try: # Move agent to target policy - result = api.agent_move(agent.agentid, target_policy.groupid) + 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}" diff --git a/TUI/multiagentselector.py b/TUI/Widgets/multiagentselector.py similarity index 55% rename from TUI/multiagentselector.py rename to TUI/Widgets/multiagentselector.py index afe8e14..b3603f3 100644 --- a/TUI/multiagentselector.py +++ b/TUI/Widgets/multiagentselector.py @@ -1,4 +1,20 @@ +# 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 . + import difflib +from pathlib import Path import re from typing import List, Optional @@ -20,6 +36,8 @@ 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__() @@ -40,7 +58,7 @@ class MultiAgentSelector(Widget): def compose(self): yield Header(show_clock=True, icon="⚙") - title_text = Static("🖧 Agent Selector", id="selector_title") + title_text = Static("🖥️ Agent Selector", id="selector_title") title_text.styles.margin = (0, 0, 0, 1) yield title_text @@ -60,7 +78,7 @@ class MultiAgentSelector(Widget): text_area.styles.overflow_y = "auto" yield text_area - with Horizontal(id="switch_search_container") as switch_search: + with Horizontal(id="switch_container"): switch = Switch(value=False, id="match_switch") switch.styles.width = "auto" switch.styles.margin = (1, 0, 0, 0) @@ -72,8 +90,13 @@ class MultiAgentSelector(Widget): switch_label.styles.margin = (2, 1, 0, 0) yield switch_label + with Horizontal(id="action_buttons_container"): + load_file = Button("📂 Load File", id="load_file_button") + load_file.styles.margin = (1, 1, 0, 1) + yield load_file + search = Button("🔍 Search", id="search_button") - search.styles.margin = (1, 0, 0, 0) + search.styles.margin = (1, 0, 0, 1) yield search with Horizontal() as select_buttons: @@ -91,11 +114,6 @@ class MultiAgentSelector(Widget): button_row.styles.height = "auto" button_row.styles.margin = (1, 0, 0, 0) - back_button = Button("← Back", id="back_button") - back_button.styles.width = "1fr" - back_button.styles.margin = (0, 0, 0, 1) - yield back_button - submit_button = Button( "▶ Select & Continue", id="submit_selection", variant="primary" ) @@ -123,10 +141,7 @@ class MultiAgentSelector(Widget): match_list = self.query_one("#match_results", SelectionList) except NoMatches: return - if btn_id == "back_button": - self.app.pop_screen() - event.stop() - elif btn_id == "select_all": + if btn_id == "select_all": match_list.select_all() event.stop() elif btn_id == "select_none": @@ -143,6 +158,9 @@ class MultiAgentSelector(Widget): ] self.post_message(self.AgentsSelected(selected_agents)) event.stop() + elif btn_id == "load_file_button": + self._load_from_file() + event.stop() elif btn_id == "search_button": self.update_matches() event.stop() @@ -157,7 +175,7 @@ class MultiAgentSelector(Widget): 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)}") + unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}") else: unmatched_label.update("") @@ -205,3 +223,118 @@ class MultiAgentSelector(Widget): else: unmatched.append(name) return sorted(matched), unmatched + + def _load_from_file(self): + """Safely load device names from a text file.""" + try: + # Import here to avoid issues if tkinter isn't available + import tkinter as tk + from tkinter import filedialog + + # Create file dialog + root = tk.Tk() + root.withdraw() + + file_path = filedialog.askopenfilename( + title="Select device list file", + filetypes=[ + ("Text files", "*.txt"), + ("CSV files", "*.csv"), + ("All files", "*.*"), + ], + ) + + if not file_path: + # User cancelled + return + + # Validate file path + path_obj = Path(file_path) + if not path_obj.exists(): + self.app.notify("File does not exist", severity="error", timeout=3) + return + + if not path_obj.is_file(): + self.app.notify( + "Selected path is not a file", severity="error", timeout=3 + ) + return + + # Check file size (limit to 1 MB for safety) + file_size = path_obj.stat().st_size + if file_size > 1_000_000: # 1 MB + self.app.notify( + f"File too large ({file_size:,} bytes). Maximum 1 MB.", + severity="error", + timeout=5, + ) + return + + # Read file with proper encoding to preserve emojis + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + except UnicodeDecodeError: + # Try with different encoding if UTF-8 fails + try: + with open(file_path, "r", encoding="latin-1") as f: + content = f.read() + self.app.notify( + "File loaded with Latin-1 encoding (UTF-8 failed)", + severity="warning", + timeout=3, + ) + except Exception as e: + self.app.notify( + f"Error reading file: {str(e)}", severity="error", timeout=5 + ) + return + + # Validate and sanitize content + lines = content.split("\n") + valid_lines = [] + invalid_count = 0 + + # Pattern for valid hostnames/device names + # Allows: letters, numbers, hyphens, underscores, periods, and Unicode chars + hostname_pattern = re.compile(r"^[\w\-\.\u0080-\uFFFF]+$", re.UNICODE) + + for line in lines: + line = line.strip() + if not line: + continue # Skip empty lines + + # Check if line looks like a valid hostname/device name + if hostname_pattern.match(line): + valid_lines.append(line) + else: + invalid_count += 1 + # Log but don't add invalid entries + + if not valid_lines: + self.app.notify( + "No valid device names found in file", severity="warning", timeout=3 + ) + return + + # Update text area with validated content + text_area = self.query_one("#device_input", TextArea) + text_area.text = "\n".join(valid_lines) + + # Show notification + msg = f"✅ Loaded {len(valid_lines)} devices from file" + if invalid_count > 0: + msg += f" ({invalid_count} invalid entries skipped)" + + self.app.notify(msg, severity="information", timeout=5) + + except ImportError: + self.app.notify( + "tkinter not available - cannot open file dialog", + severity="error", + timeout=3, + ) + except Exception as e: + self.app.notify( + f"Error loading file: {str(e)}", severity="error", timeout=5 + ) diff --git a/TUI/policyselector.py b/TUI/Widgets/policyselector.py similarity index 92% rename from TUI/policyselector.py rename to TUI/Widgets/policyselector.py index d6e4bdf..6e0b71d 100644 --- a/TUI/policyselector.py +++ b/TUI/Widgets/policyselector.py @@ -1,10 +1,17 @@ -""" -Policy Selector Widget Module - -Provides a Textual widget for selecting target policies for bulk agent operations. -Allows users to browse available policies and select one as the destination for -moving agents. Automatically excludes parent/logical policies. -""" +# 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 import logging import re @@ -34,7 +41,7 @@ class PolicySelector(Widget): - Wildcard filtering (* and ?) - Interactive table for policy browsing - Explicit confirm button for selection - - Cancel/back button to dismiss + - Use escape key to go back Attributes: policies (list[Policy]): List of available Policy objects to display. @@ -91,10 +98,10 @@ class PolicySelector(Widget): - Clear Filter button - Confirm Selection button - Policy table displaying available policies - - Back buttons for navigation + - Use escape key to go back """ title_text = Static( - "🎯 Select Target Policy", + "Select Target Policy", id="policy_selector_title", ) title_text.styles.margin = (0, 0, 1, 0) @@ -125,12 +132,12 @@ class PolicySelector(Widget): filter_help.styles.margin = (0, 0, 1, 0) yield filter_help - apply_button = Button("✓ Apply Filter", id="filter_button") + 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 = Button("🧹 Clear Filter", id="clear_filter_button") clear_button.styles.width = "100%" clear_button.styles.margin = (0, 0, 1, 0) yield clear_button @@ -144,11 +151,6 @@ class PolicySelector(Widget): selected_label.styles.margin = (2, 0, 1, 0) yield selected_label - cancel_button = Button("← Back", id="back_button") - cancel_button.styles.width = "100%" - cancel_button.styles.margin = (1, 0, 1, 0) - yield cancel_button - # Right side - Policy table with Vertical() as right_side: right_side.styles.width = "2fr" @@ -222,7 +224,6 @@ class PolicySelector(Widget): Handle button press events from the widget. Routes to: - - back_button (Cancel): Pop screen without selecting - 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 @@ -232,12 +233,7 @@ class PolicySelector(Widget): """ btn_id = event.button.id - if btn_id == "back_button": - while len(self.app.screen_stack) > 2: - self.app.pop_screen() - event.stop() - - elif btn_id == "filter_button": + if btn_id == "filter_button": self._apply_filter() event.stop() @@ -283,7 +279,7 @@ class PolicySelector(Widget): if self.selected_policy: # Update selection display label = self.query_one("#selected_policy_label", Static) - label.update(f"✓ Selected: {self.selected_policy.name}") + label.update(f"Selected: {self.selected_policy.name}") # Log for debugging logger.debug( @@ -325,7 +321,7 @@ class PolicySelector(Widget): if highlighted_name: label = self.query_one("#selected_policy_label", Static) - label.update(f"→ Highlighting: {highlighted_name}") + label.update(f"Highlighting: {highlighted_name}") except Exception as e: logger.error(f"Error handling row highlight: {e}") @@ -399,7 +395,9 @@ class PolicySelector(Widget): ) displayed_count = len(self._displayed_policies) - status_text = f"📊 Showing {displayed_count} of {len(self._filtered_policies)} 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 @@ -474,7 +472,7 @@ class PolicySelector(Widget): """ if self.selected_policy is None: self.app.notify( - "⚠️ Please select a policy first by clicking on a row in the table", + "Please select a policy first by clicking on a row in the table", severity="warning", timeout=3, ) @@ -483,6 +481,6 @@ class PolicySelector(Widget): # 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 + f"Confirmed: {self.selected_policy.name}", severity="success", timeout=2 ) self.post_message(self.PolicySelected(self.selected_policy)) diff --git a/TUI/policytreewidget.py b/TUI/Widgets/policytreewidget.py similarity index 67% rename from TUI/policytreewidget.py rename to TUI/Widgets/policytreewidget.py index 5e01fdf..c9811de 100644 --- a/TUI/policytreewidget.py +++ b/TUI/Widgets/policytreewidget.py @@ -1,10 +1,26 @@ +# 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 . + from collections import defaultdict import logging from rich.text import Text from textual.containers import Horizontal, Vertical +from textual.message import Message from textual.widget import Widget -from textual.widgets import Input, OptionList, Static, Switch, Tree +from textual.widgets import Button, Input, OptionList, Static, Switch, Tree from textual.widgets.option_list import Option logger = logging.getLogger(__name__) @@ -13,6 +29,27 @@ logger = logging.getLogger(__name__) class PolicyTreeWidget(Widget): """Widget for displaying and searching a hierarchical policy tree.""" + class ViewExecutionHistory(Message): + """Message sent when user wants to view execution history for a device.""" + + def __init__(self, device): + super().__init__() + self.device = device + + class GenerateOTP(Message): + """Message sent when user wants to generate OTP for a device.""" + + def __init__(self, device): + super().__init__() + self.device = device + + class ToggleEnforcement(Message): + """Message sent when user wants to toggle audit/enforcement for a device.""" + + def __init__(self, device): + super().__init__() + self.device = device + def __init__(self, policies, devices): super().__init__() self.policies = policies @@ -20,6 +57,7 @@ class PolicyTreeWidget(Widget): self.last_highlighted_node = None self.leaf_counts = defaultdict(int) self.match_type = "Count" # Default to sorting by count + self.selected_device = None # Track currently selected device def compose(self): # Create the switch and its label @@ -41,6 +79,18 @@ class PolicyTreeWidget(Widget): search_box = Input( placeholder="Search policies or devices...", id="tree_search" ) + exec_history_button = Button( + "📊 Execution History", id="view_exec_history_button", disabled=True + ) + exec_history_button.styles.margin = (0, 1, 0, 0) # Right margin + + otp_button = Button("🎫 Generate OTP", id="generate_otp_button", disabled=True) + otp_button.styles.margin = (0, 1, 0, 0) # Right margin + + toggle_enforcement_button = Button( + "🔄 Toggle Enforcement/Audit", id="toggle_enforcement_button", disabled=True + ) + # No right margin on last button details_pane = Static("", id="details_pane") # Layout the UI @@ -58,6 +108,12 @@ class PolicyTreeWidget(Widget): # Add the search box and details pane yield label yield search_box + # Action buttons in a horizontal row + with Horizontal() as button_row: + button_row.styles.height = "auto" + yield exec_history_button + yield otp_button + yield toggle_enforcement_button yield details_pane def on_mount(self) -> None: @@ -74,6 +130,32 @@ class PolicyTreeWidget(Widget): # Expand the root node policy_tree.root.expand() + def refresh_data(self, policies, devices): + """Refresh the widget with new data and rebuild the tree.""" + self.policies = policies + self.devices = devices + self.selected_device = None + + # Disable all buttons since selection is lost + try: + self.query_one("#view_exec_history_button", Button).disabled = True + self.query_one("#generate_otp_button", Button).disabled = True + self.query_one("#toggle_enforcement_button", Button).disabled = True + except: + pass + + # Rebuild tree with new data + self._precompute_leaf_counts() + 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() + policy_tree.root.expand() + def _precompute_leaf_counts(self): """Precompute leaf counts for each policy group.""" device_counts = defaultdict(int) @@ -169,6 +251,9 @@ class PolicyTreeWidget(Widget): node = message.node data = node.data details_pane = self.query_one("#details_pane", Static) + exec_history_button = self.query_one("#view_exec_history_button", Button) + otp_button = self.query_one("#generate_otp_button", Button) + toggle_enforcement_button = self.query_one("#toggle_enforcement_button", Button) if self.last_highlighted_node is not None: original_label = str(self.last_highlighted_node.label).strip() @@ -183,6 +268,20 @@ class PolicyTreeWidget(Widget): node.set_label(highlighted_label) self.last_highlighted_node = node + # Check if selected node is a device (has Agent data) + from models.agent import Agent + + if data and isinstance(data, Agent): + self.selected_device = data + exec_history_button.disabled = False + otp_button.disabled = False + toggle_enforcement_button.disabled = False + else: + self.selected_device = None + exec_history_button.disabled = True + otp_button.disabled = True + toggle_enforcement_button.disabled = True + if data: details = "\n".join( f"{key}: {value}" for key, value in data.__dict__.items() @@ -272,3 +371,18 @@ class PolicyTreeWidget(Widget): option_list.remove() except: pass + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "view_exec_history_button": + if self.selected_device: + self.post_message(self.ViewExecutionHistory(self.selected_device)) + event.stop() + elif event.button.id == "generate_otp_button": + if self.selected_device: + self.post_message(self.GenerateOTP(self.selected_device)) + event.stop() + elif event.button.id == "toggle_enforcement_button": + if self.selected_device: + self.post_message(self.ToggleEnforcement(self.selected_device)) + event.stop() diff --git a/TUI/Widgets/prepPolicy.py b/TUI/Widgets/prepPolicy.py new file mode 100644 index 0000000..87e2a41 --- /dev/null +++ b/TUI/Widgets/prepPolicy.py @@ -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 . + +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 (1-365): ", + value_type=int, + valid_range=(1, 365), + ) + + 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")) diff --git a/TUI/resultsdisplay.py b/TUI/Widgets/resultsdisplay.py similarity index 74% rename from TUI/resultsdisplay.py rename to TUI/Widgets/resultsdisplay.py index ebabce7..63b204d 100644 --- a/TUI/resultsdisplay.py +++ b/TUI/Widgets/resultsdisplay.py @@ -1,3 +1,18 @@ +# 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 . + import logging from textual.containers import Horizontal, Vertical @@ -59,15 +74,6 @@ class ResultsDisplay(Widget): margin-top: 1; width: 100%; } - - #button_row { - height: auto; - margin: 1 0 0 0; - } - - #back_button { - width: 1fr; - } """ class CopySuccess(Message): @@ -95,9 +101,9 @@ class ResultsDisplay(Widget): def compose(self): with Vertical(id="results_screen"): - yield Header(show_clock=True, icon="⚙") + yield Header(show_clock=True, icon="⚙️") # Title - title = Static(f"📊 {self.operation} - Results", id="results_title") + title = Static(f"{self.operation} - Results", id="results_title") yield title # Two-column layout @@ -107,7 +113,7 @@ class ResultsDisplay(Widget): yield Static("✅ Successful", id="success_label") yield Static(self.successful_results, id="success_results") yield Button( - "📋✅ Copy Success List", + "Copy Success List", id="copy_success", classes="copy_button", ) @@ -117,15 +123,11 @@ class ResultsDisplay(Widget): yield Static("❌ Failed", id="failure_label") yield Static(self.unsuccessful_results, id="failure_results") yield Button( - "📋❌ Copy Failure List", + "Copy Failure List", id="copy_failure", classes="copy_button", ) - # Back Button - with Horizontal(id="button_row"): - back_button = Button("← Back", id="back_button") - yield back_button yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: @@ -138,18 +140,18 @@ class ResultsDisplay(Widget): pyperclip.copy(str(success_widget.renderable)) self.app.notify( - "✅ Success list copied to clipboard!", + "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", + "❌ pyperclip not installed. Run: pip install pyperclip", severity="warning", ) except Exception as e: - self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") + self.app.notify(f"¢ Failed to copy: {str(e)}", severity="error") event.stop() elif btn_id == "copy_failure": @@ -159,20 +161,16 @@ class ResultsDisplay(Widget): pyperclip.copy(str(failure_widget.renderable)) self.app.notify( - "✅ Failure list copied to clipboard!", + "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", + "❌ 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 == "back_button": - self.app.pop_screen() + self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") event.stop() diff --git a/TUI/Widgets/serverlogwidget.py b/TUI/Widgets/serverlogwidget.py new file mode 100644 index 0000000..0402700 --- /dev/null +++ b/TUI/Widgets/serverlogwidget.py @@ -0,0 +1,261 @@ +# 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 . + +import datetime +import logging + +from bson import ObjectId +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.widgets import Button, DataTable, Input, Static + +from services.API import AirlockAPIWrapper + +logger = logging.getLogger(__name__) + + +def skipback(days): + """ + Generate a MongoDB ObjectId for a given number of days ago from today. + """ + adjusted_days = days + date_days_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta( + days=adjusted_days + ) + timestamp = int(date_days_ago.timestamp()) + hex_timestamp = format(timestamp, "08x") + objectid_hex = hex_timestamp + "0000000000000000" + return ObjectId(objectid_hex) + + +class ServerLogWidget(Vertical): + """Widget for displaying server activity logs in a DataTable.""" + + DEFAULT_CSS = """ + ServerLogWidget { + width: 100%; + height: 100%; + } + + ServerLogWidget #status_bar { + width: 100%; + height: auto; + background: $surface; + padding: 1; + margin-bottom: 1; + } + + ServerLogWidget DataTable { + height: 1fr; + border: solid $primary; + } + + ServerLogWidget #button_container { + width: 100%; + height: auto; + layout: horizontal; + padding: 1; + align: left middle; + } + + ServerLogWidget .filter_label { + width: auto; + height: 3; + content-align: left middle; + padding-right: 1; + } + + ServerLogWidget #filter_input { + width: 40; + margin-right: 1; + } + + ServerLogWidget Button { + margin-right: 1; + } + """ + + def __init__(self, api: AirlockAPIWrapper): + super().__init__() + self.api = api + self.all_logs = [] # Store all logs for filtering + self.columns = [] # Store column names + + def compose(self) -> ComposeResult: + yield Static("Loading server logs (last 72 hours)...", id="status_bar") + yield DataTable(id="server_log_table") + with Container(id="button_container"): + yield Static("Filter:", classes="filter_label") + yield Input(placeholder="Filter (use * and ? wildcards)", id="filter_input") + yield Button("Refresh", id="refresh_button", variant="primary") + + def on_mount(self) -> None: + """Initialize the DataTable and load server logs.""" + self.load_logs() + + def load_logs(self) -> None: + """Load server logs from the API and populate the DataTable.""" + table = self.query_one("#server_log_table", DataTable) + status = self.query_one("#status_bar", Static) + + try: + status.update("⏳ Loading server logs (last 72 hours)...") + + # Create a fake checkpoint for 3 days ago (72 hours) + checkpoint = str(skipback(3)) + + # Get server logs from API + logs = self.api.server_logs(checkpoint=checkpoint) + + if not logs: + status.update("ℹï¸ No server logs found in the last 72 hours.") + table.clear(columns=True) + self.all_logs = [] + self.columns = [] + return + + # Store all logs for filtering + self.all_logs = logs + + # Clear existing data + table.clear(columns=True) + + # Add columns based on the first log entry + if logs: + first_log = logs[0] + self.columns = [col for col in first_log.keys() if col != "checkpoint"] + + for col in self.columns: + table.add_column(col, key=col) + + # Add rows in reverse order so newest entries are at the top + for log_entry in reversed(logs): + row_data = [] + for col in self.columns: + value = log_entry.get(col, "") + # Format datetime column to be more readable + if col == "datetime" and value: + try: + # Parse ISO format and convert to readable format + dt = datetime.datetime.fromisoformat( + str(value).replace("Z", "+00:00") + ) + value = dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + # If parsing fails, just use the original value + pass + row_data.append(str(value)) + table.add_row(*row_data) + + status.update( + f"✅ Loaded {len(logs)} log entries from the last 72 hours" + ) + logger.info(f"Loaded {len(logs)} server log entries") + else: + status.update("ℹï¸ No log entries found.") + self.all_logs = [] + self.columns = [] + + except Exception as exc: + error_msg = f"❌ Error loading server logs: {exc}" + status.update(error_msg) + logger.error(f"Failed to load server logs: {exc}", exc_info=True) + table.clear(columns=True) + self.all_logs = [] + self.columns = [] + + def filter_logs(self, filter_text: str) -> None: + """Filter the logs based on the filter text with wildcard support.""" + import fnmatch + + table = self.query_one("#server_log_table", DataTable) + status = self.query_one("#status_bar", Static) + + if not self.all_logs: + return + + # Clear existing data + table.clear(columns=True) + + # Re-add columns + for col in self.columns: + table.add_column(col, key=col) + + # Filter logs + filtered_logs = [] + if filter_text.strip(): + filter_pattern = filter_text.strip().lower() + for log_entry in self.all_logs: + # Check if any field matches the filter pattern + match = False + for col in self.columns: + value = str(log_entry.get(col, "")).lower() + if fnmatch.fnmatch(value, filter_pattern): + match = True + break + if match: + filtered_logs.append(log_entry) + else: + # No filter, show all logs + filtered_logs = self.all_logs + + # Add filtered rows in reverse order + for log_entry in reversed(filtered_logs): + row_data = [] + for col in self.columns: + value = log_entry.get(col, "") + # Format datetime column to be more readable + if col == "datetime" and value: + try: + dt = datetime.datetime.fromisoformat( + str(value).replace("Z", "+00:00") + ) + value = dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + pass + row_data.append(str(value)) + table.add_row(*row_data) + + if filter_text.strip(): + status.update( + f"✅ Showing {len(filtered_logs)} of {len(self.all_logs)} log entries (filtered)" + ) + else: + status.update( + f"✅ Loaded {len(self.all_logs)} log entries from the last 72 hours" + ) + + logger.info( + f"Filtered to {len(filtered_logs)} entries with pattern: {filter_text}" + ) + + def on_input_changed(self, event: Input.Changed) -> None: + """Handle filter input changes.""" + if event.input.id == "filter_input": + self.filter_logs(event.value) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + button_id = event.button.id + + if button_id == "refresh_button": + self.load_logs() + # Clear the filter input when refreshing + try: + filter_input = self.query_one("#filter_input", Input) + filter_input.value = "" + except Exception: + pass + event.stop() diff --git a/TUI/moveagentworkflowscreen.py b/TUI/moveagentworkflowscreen.py deleted file mode 100644 index 8398ddf..0000000 --- a/TUI/moveagentworkflowscreen.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import List, Optional - -from textual.app import ComposeResult -from textual.screen import Screen - -from models.agent import Agent -from TUI.agentmoveoperations import AgentMoveOperations -from TUI.multiagentselector import MultiAgentSelector -from TUI.resultsdisplay import ResultsDisplay - - -class MoveAgentWorkflowScreen(Screen): - """Screen that handles the agent movement workflow.""" - - def __init__(self, all_agents: Optional[List[Agent]]): - super().__init__() - self.all_agents = all_agents - self.selected_agents = None - - def compose(self) -> ComposeResult: - """Start with the multi-agent selector.""" - yield MultiAgentSelector(self.all_agents) - - def on_multi_agent_selector_agents_selected( - self, message: MultiAgentSelector.AgentsSelected - ) -> None: - """Handle selected agents - switch to operations screen.""" - self.selected_agents = message.selected_agents - - # Remove the MultiAgentSelector - selector = self.query_one(MultiAgentSelector) - selector.remove() - - # Mount the AgentMoveOperations with the selected Agent objects - self.mount(AgentMoveOperations(self.selected_agents)) - - def on_agent_move_operations_operation_complete( - self, message: AgentMoveOperations.OperationComplete - ) -> None: - """Handle completion of move operation - transition to results screen.""" - # Format successful results - success_lines = [] - for agent, result in message.successful: - success_lines.append(f"✓ {agent.hostname}") - - # Format unsuccessful results - failure_lines = [] - for agent, error in message.unsuccessful: - failure_lines.append(f"✗ {agent.hostname}: {error}") - - successful_text = "\n".join(success_lines) if success_lines else "(none)" - unsuccessful_text = "\n".join(failure_lines) if failure_lines else "(none)" - - # Remove the operations widget - ops_widget = self.query_one(AgentMoveOperations) - ops_widget.remove() - - # Mount the results display - self.mount( - ResultsDisplay(message.operation, successful_text, unsuccessful_text) - ) diff --git a/TUI/otpworkflowscreen.py b/TUI/otpworkflowscreen.py deleted file mode 100644 index d7d7322..0000000 --- a/TUI/otpworkflowscreen.py +++ /dev/null @@ -1,24 +0,0 @@ -# otp_workflow_screen.py - -from typing import List, Optional - -from textual.app import ComposeResult -from textual.screen import Screen - -from models.agent import Agent -from TUI.OTP_generate import OTPGenerator - - -class OTPWorkflowScreen(Screen): - """Screen that handles the OTP generation workflow without agent selection.""" - - def __init__(self, selected_agents: Optional[List[Agent]]): - super().__init__() - self.selected_agents = selected_agents - - def compose(self) -> ComposeResult: - """Directly show the OTP generator for the selected agents.""" - yield OTPGenerator(self.selected_agents) - - def on_otp_generator_otp_info(self, message: OTPGenerator.OTPInfo) -> None: - """Handle OTP generation request - pass it up to the app level if needed.""" diff --git a/airlock_libs/.gitignore b/airlock_libs/.gitignore index 84ed743..5420cda 100644 --- a/airlock_libs/.gitignore +++ b/airlock_libs/.gitignore @@ -1,3 +1,4 @@ /target build.sh -pythontest.py \ No newline at end of file +pythontest.py +changelog.md \ No newline at end of file diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock index fb2d657..e2386f3 100644 --- a/airlock_libs/Cargo.lock +++ b/airlock_libs/Cargo.lock @@ -26,15 +26,20 @@ dependencies = [ [[package]] name = "airlock_libs" -version = "3.1.2" +version = "6.1.1" dependencies = [ "chrono", + "crossbeam", + "flexi_logger", "indicatif", + "log", "mongodb", - "opentelemetry 0.18.0", + "opentelemetry 0.27.1", + "opentelemetry-appender-log", "opentelemetry-otlp", "opentelemetry-proto", "opentelemetry-semantic-conventions", + "opentelemetry_sdk 0.27.1", "pyo3", "reqwest", "serde", @@ -62,6 +67,150 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -81,9 +230,15 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -92,7 +247,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -109,18 +264,17 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.6.20" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "bitflags 1.3.2", "bytes", "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", + "http", + "http-body", + "http-body-util", "itoa", "matchit", "memchr", @@ -129,53 +283,38 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "sync_wrapper 0.1.2", - "tower 0.4.13", + "sync_wrapper", + "tower 0.5.2", "tower-layer", "tower-service", ] [[package]] name = "axum-core" -version = "0.3.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", "futures-util", - "http 0.2.12", - "http-body 0.4.6", + "http", + "http-body", + "http-body-util", "mime", + "pin-project-lite", "rustversion", + "sync_wrapper", "tower-layer", "tower-service", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" @@ -203,6 +342,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bson" version = "2.15.0" @@ -210,12 +362,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" dependencies = [ "ahash", - "base64 0.22.1", + "base64", "bitvec", "getrandom 0.2.16", "getrandom 0.3.4", "hex", - "indexmap 2.12.0", + "indexmap 2.12.1", "js-sys", "once_cell", "rand 0.9.2", @@ -234,15 +386,15 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.45" +version = "1.2.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" dependencies = [ "find-msvc-tools", "shlex", @@ -254,6 +406,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -263,9 +421,17 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", - "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -303,9 +469,12 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.4.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "core-foundation" @@ -317,6 +486,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -332,6 +511,25 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -341,6 +539,34 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -384,7 +610,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.110", + "syn", ] [[package]] @@ -395,20 +621,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.110", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "syn", ] [[package]] @@ -424,7 +637,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -435,7 +647,7 @@ checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -446,20 +658,30 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] name = "derive_more" -version = "0.99.20" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", - "syn 2.0.110", + "syn", + "unicode-xid", ] [[package]] @@ -481,15 +703,9 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "either" version = "1.15.0" @@ -517,10 +733,10 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -539,6 +755,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -547,15 +790,22 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] -name = "fixedbitset" -version = "0.4.2" +name = "flexi_logger" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +checksum = "31e5335674a3a259527f97e9176a3767dcc9b220b8e29d643daeb2d6c72caf8b" +dependencies = [ + "chrono", + "log", + "nu-ansi-term", + "regex", + "thiserror 2.0.17", +] [[package]] name = "fnv" @@ -593,20 +843,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -640,6 +876,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -648,7 +897,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -669,7 +918,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -719,22 +967,21 @@ dependencies = [ ] [[package]] -name = "h2" -version = "0.3.27" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ - "bytes", - "fnv", + "futures-channel", "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.12.0", - "slab", - "tokio", - "tokio-util", - "tracing", + "js-sys", + "wasm-bindgen", ] [[package]] @@ -748,8 +995,8 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.12.0", + "http", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -764,21 +1011,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -786,6 +1021,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -794,9 +1035,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-proto" -version = "0.24.4" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ "async-trait", "cfg-if", @@ -808,8 +1049,9 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.8.5", - "thiserror 1.0.69", + "rand 0.9.2", + "ring", + "thiserror 2.0.17", "tinyvec", "tokio", "tracing", @@ -818,21 +1060,21 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.24.4" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", "hickory-proto", "ipconfig", - "lru-cache", + "moka", "once_cell", "parking_lot", - "rand 0.8.5", + "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -846,48 +1088,16 @@ dependencies = [ "digest", ] -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "http" -version = "0.2.12" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -895,7 +1105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http", ] [[package]] @@ -906,8 +1116,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -925,42 +1135,19 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "0.14.32" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.12", - "http 1.3.1", - "http-body 1.0.1", + "h2", + "http", + "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -975,26 +1162,29 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.3.1", - "hyper 1.8.0", + "http", + "hyper", "hyper-util", - "rustls 0.23.35", + "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] name = "hyper-timeout" -version = "0.4.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 0.14.32", + "hyper", + "hyper-util", "pin-project-lite", "tokio", - "tokio-io-timeout", + "tower-service", ] [[package]] @@ -1005,7 +1195,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.0", + "hyper", "hyper-util", "native-tls", "tokio", @@ -1015,18 +1205,18 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "hyper 1.8.0", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", @@ -1111,9 +1301,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1125,9 +1315,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1179,19 +1369,16 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", - "serde", ] [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", - "serde", - "serde_core", + "hashbrown 0.16.1", ] [[package]] @@ -1246,9 +1433,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.10.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -1261,14 +1448,23 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1277,21 +1473,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" @@ -1316,18 +1500,18 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] -name = "lru-cache" +name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" -dependencies = [ - "linked-hash-map", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "macro_magic" @@ -1338,7 +1522,7 @@ dependencies = [ "macro_magic_core", "macro_magic_macros", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -1352,7 +1536,7 @@ dependencies = [ "macro_magic_core_macros", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -1363,7 +1547,7 @@ checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -1374,7 +1558,7 @@ checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" dependencies = [ "macro_magic_core", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -1416,9 +1600,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1426,10 +1610,28 @@ dependencies = [ ] [[package]] -name = "mongocrypt" -version = "0.3.1" +name = "moka" +version = "0.12.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22426d6318d19c5c0773f783f85375265d6a8f0fa76a733da8dc4355516ec63d" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mongocrypt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da0cd419a51a5fb44819e290fbdb0665a54f21dead8923446a799c7f4d26ad9" dependencies = [ "bson", "mongocrypt-sys", @@ -1439,25 +1641,22 @@ dependencies = [ [[package]] name = "mongocrypt-sys" -version = "0.1.4+1.12.0" +version = "0.1.5+1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dda42df21d035f88030aad8e877492fac814680e1d7336a57b2a091b989ae388" +checksum = "224484c5d09285a7b8cb0a0c117e847ebd14cb6e4470ecf68cdb89c503b0edb9" [[package]] name = "mongodb" -version = "3.3.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f272c59e54a3c85f5902c6b8e7b1653a6b6681f45e4c42d6581301119a4b8" +checksum = "12f5c20217413bed97c613714e6d6dfe39ef59dd79a68999f1043b0566192975" dependencies = [ - "async-trait", - "base64 0.13.1", - "bitflags 1.3.2", + "base64", + "bitflags", "bson", - "chrono", "derive-where", "derive_more", "futures-core", - "futures-executor", "futures-io", "futures-util", "hex", @@ -1468,49 +1667,42 @@ dependencies = [ "md-5", "mongocrypt", "mongodb-internal-macros", - "once_cell", "pbkdf2", "percent-encoding", - "rand 0.8.5", + "rand 0.9.2", "rustc_version_runtime", - "rustls 0.23.35", + "rustls", "rustversion", "serde", "serde_bytes", "serde_with", "sha1", "sha2", - "socket2 0.5.10", + "socket2 0.6.1", "stringprep", "strsim", "take_mut", - "thiserror 1.0.69", + "thiserror 2.0.17", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "typed-builder", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "mongodb-internal-macros" -version = "3.3.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63981427a0f26b89632fd2574280e069d09fb2912a3138da15de0174d11dd077" +checksum = "20033442aa13664e70bc9f8be1bacabebf6a31b6d4bb5608ceb99c4ec96e9951" dependencies = [ "macro_magic", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] -[[package]] -name = "multimap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ce46fe64a9d73be07dcbe690a38ce1b293be448fd8ce1e6c1b8062c9f72c6a" - [[package]] name = "native-tls" version = "0.2.14" @@ -1523,7 +1715,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -1557,6 +1749,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "openssl" @@ -1564,7 +1760,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -1581,7 +1777,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -1604,12 +1800,16 @@ dependencies = [ [[package]] name = "opentelemetry" -version = "0.18.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d6c3d7288a106c0a363e4b0e8d308058d56902adefb16f4936f417ffef086e" +checksum = "ab70038c28ed37b97d8ed414b6429d343a8bbf44c9f79ec854f3a643029ba6d7" dependencies = [ - "opentelemetry_api", - "opentelemetry_sdk 0.18.0", + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 1.0.69", + "tracing", ] [[package]] @@ -1626,82 +1826,89 @@ dependencies = [ ] [[package]] -name = "opentelemetry-otlp" -version = "0.11.0" +name = "opentelemetry-appender-log" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c928609d087790fc936a1067bdc310ae702bdf3b090c3f281b713622c8bbde" +checksum = "892c3a3fe0cf009cc180366347bfa134c662f028039bce8aa35994830c68c84f" +dependencies = [ + "log", + "opentelemetry 0.27.1", +] + +[[package]] +name = "opentelemetry-http" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a8a7f5f6ba7c1b286c2fbca0454eaba116f63bbe69ed250b642d36fbb04d80" dependencies = [ "async-trait", - "futures", - "futures-util", - "http 0.2.12", - "opentelemetry 0.18.0", + "bytes", + "http", + "opentelemetry 0.27.1", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cf61a1868dacc576bf2b2a1c3e9ab150af7272909e80085c3173384fe11f76" +dependencies = [ + "async-trait", + "futures-core", + "http", + "opentelemetry 0.27.1", + "opentelemetry-http", "opentelemetry-proto", + "opentelemetry_sdk 0.27.1", "prost", + "reqwest", "thiserror 1.0.69", "tokio", "tonic", + "tracing", ] [[package]] name = "opentelemetry-proto" -version = "0.1.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61a2f56df5574508dd86aaca016c917489e589ece4141df1b5e349af8d66c28" +checksum = "a6e05acbfada5ec79023c85368af14abd0b307c015e9064d249b2a950ef459a6" dependencies = [ - "futures", - "futures-util", - "opentelemetry 0.18.0", + "hex", + "opentelemetry 0.27.1", + "opentelemetry_sdk 0.27.1", "prost", + "serde", "tonic", - "tonic-build", ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.10.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b02e0230abb0ab6636d18e2ba8fa02903ea63772281340ccac18e0af3ec9eeb" -dependencies = [ - "opentelemetry 0.18.0", -] - -[[package]] -name = "opentelemetry_api" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c24f96e21e7acc813c7a8394ee94978929db2bcc46cf6b5014fc612bf7760c22" -dependencies = [ - "fnv", - "futures-channel", - "futures-util", - "indexmap 1.9.3", - "js-sys", - "once_cell", - "pin-project-lite", - "thiserror 1.0.69", -] +checksum = "bc1b6902ff63b32ef6c489e8048c5e253e2e4a803ea3ea7e783914536eb15c52" [[package]] name = "opentelemetry_sdk" -version = "0.18.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca41c4933371b61c2a2f214bf16931499af4ec90543604ec828f7a625c09113" +checksum = "231e9d6ceef9b0b2546ddf52335785ce41252bc7474ee8ba05bfad277be13ab8" dependencies = [ + "async-std", "async-trait", - "crossbeam-channel", - "dashmap", - "fnv", "futures-channel", "futures-executor", "futures-util", - "once_cell", - "opentelemetry_api", + "glob", + "opentelemetry 0.27.1", "percent-encoding", "rand 0.8.5", + "serde_json", "thiserror 1.0.69", "tokio", "tokio-stream", + "tracing", ] [[package]] @@ -1719,6 +1926,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1739,14 +1952,14 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "pbkdf2" -version = "0.11.0" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest", ] @@ -1757,16 +1970,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset", - "indexmap 2.12.0", -] - [[package]] name = "pin-project" version = "1.1.10" @@ -1784,7 +1987,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -1799,12 +2002,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1835,16 +2063,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8646e95016a7a6c4adea95bafa8a16baab64b583356217f2c85db4a39d9a86" -dependencies = [ - "proc-macro2", - "syn 1.0.109", -] - [[package]] name = "proc-macro2" version = "1.0.103" @@ -1856,63 +2074,32 @@ dependencies = [ [[package]] name = "prost" -version = "0.11.9" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", "prost-derive", ] -[[package]] -name = "prost-build" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" -dependencies = [ - "bytes", - "heck 0.4.1", - "itertools", - "lazy_static", - "log", - "multimap", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "regex", - "syn 1.0.109", - "tempfile", - "which", -] - [[package]] name = "prost-derive" -version = "0.11.9" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", "itertools", "proc-macro2", "quote", - "syn 1.0.109", -] - -[[package]] -name = "prost-types" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213622a1460818959ac1181aaeb2dc9c7f63df720db7d788b3e24eacd1983e13" -dependencies = [ - "prost", + "syn", ] [[package]] name = "pyo3" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" +checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d" dependencies = [ "indoc", "libc", @@ -1927,9 +2114,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" +checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6" dependencies = [ "python3-dll-a", "target-lexicon", @@ -1937,9 +2124,9 @@ dependencies = [ [[package]] name = "pyo3-ffi" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" +checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089" dependencies = [ "libc", "pyo3-build-config", @@ -1947,27 +2134,27 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" +checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.110", + "syn", ] [[package]] name = "pyo3-macros-backend" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" +checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -1979,6 +2166,61 @@ dependencies = [ "cc", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.1", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -2065,27 +2307,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.110", + "bitflags", ] [[package]] @@ -2119,19 +2341,21 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", - "h2 0.4.12", - "http 1.3.1", - "http-body 1.0.1", + "futures-util", + "h2", + "http", + "http-body", "http-body-util", - "hyper 1.8.0", + "hyper", "hyper-rustls", "hyper-tls", "hyper-util", @@ -2141,13 +2365,17 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower 0.5.2", "tower-http", "tower-service", @@ -2155,28 +2383,14 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] name = "resolv-conf" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin", - "untrusted 0.7.1", - "web-sys", - "winapi", -] +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "ring" @@ -2188,10 +2402,16 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2211,44 +2431,19 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", -] - [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log", - "ring 0.16.20", - "sct", - "webpki", -] - [[package]] name = "rustls" version = "0.23.35" @@ -2257,7 +2452,7 @@ checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", "once_cell", - "ring 0.17.14", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2266,31 +2461,32 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.6.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] name = "rustls-pemfile" -version = "1.0.4" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ - "base64 0.21.7", + "rustls-pki-types", ] [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ + "web-time", "zeroize", ] @@ -2300,9 +2496,9 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "ring 0.17.14", + "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -2326,54 +2522,33 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2443,7 +2618,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2452,7 +2627,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "itoa", "memchr", "ryu", @@ -2474,33 +2649,24 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.1" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.12.0", - "schemars 0.9.0", - "schemars 1.1.0", "serde_core", - "serde_json", "serde_with_macros", - "time", ] [[package]] name = "serde_with_macros" -version = "3.15.1" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2542,9 +2708,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] @@ -2581,12 +2747,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2618,32 +2778,15 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "syn" -version = "2.0.110" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2661,7 +2804,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2670,8 +2813,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2685,6 +2828,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take_mut" version = "0.2.2" @@ -2712,7 +2861,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", + "rustix", "windows-sys 0.61.2", ] @@ -2742,7 +2891,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2753,7 +2902,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2847,16 +2996,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tokio-io-timeout" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd86198d9ee903fedd2f9a2e72014287c0d9167e4ae43b5853007205dda1b76" -dependencies = [ - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-macros" version = "2.6.0" @@ -2865,7 +3004,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -2878,24 +3017,13 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" -dependencies = [ - "rustls 0.20.9", - "tokio", - "webpki", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.35", + "rustls", "tokio", ] @@ -2926,50 +3054,35 @@ dependencies = [ [[package]] name = "tonic" -version = "0.8.3" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f219fad3b929bef19b1f86fbc0358d35daed8f2cac972037ac0dc10bbb8d5fb" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ "async-stream", "async-trait", "axum", - "base64 0.13.1", + "base64", "bytes", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", "hyper-timeout", + "hyper-util", "percent-encoding", "pin-project", "prost", - "prost-derive", "rustls-native-certs", "rustls-pemfile", + "socket2 0.5.10", "tokio", - "tokio-rustls 0.23.4", + "tokio-rustls", "tokio-stream", - "tokio-util", "tower 0.4.13", "tower-layer", "tower-service", "tracing", - "tracing-futures", -] - -[[package]] -name = "tonic-build" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf5e9b9c0f7e0a7c027dcfaba7b2c60816c7049171f679d99ee2ff65d0de8c4" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "quote", - "syn 1.0.109", ] [[package]] @@ -3001,7 +3114,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -3009,15 +3122,15 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "futures-util", - "http 1.3.1", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", "tower 0.5.2", @@ -3039,9 +3152,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -3050,35 +3163,25 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", ] -[[package]] -name = "tracing-futures" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" -dependencies = [ - "pin-project", - "tracing", -] - [[package]] name = "tracing-log" version = "0.2.0" @@ -3111,9 +3214,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -3131,22 +3234,22 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typed-builder" -version = "0.20.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" dependencies = [ "typed-builder-macro", ] [[package]] name = "typed-builder-macro" -version = "0.20.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3182,12 +3285,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unindent" version = "0.2.4" @@ -3196,15 +3311,9 @@ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" [[package]] name = "unit-prefix" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" [[package]] name = "untrusted" @@ -3232,13 +3341,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -3248,6 +3357,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -3286,9 +3401,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -3299,9 +3414,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -3312,9 +3427,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3322,31 +3437,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -3362,25 +3477,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" -dependencies = [ - "ring 0.17.14", - "untrusted 0.9.0", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.4", -] - [[package]] name = "webpki-roots" version = "1.0.4" @@ -3390,46 +3486,12 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "widestring" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -3438,9 +3500,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -3451,7 +3513,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3462,15 +3524,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -3479,22 +3535,13 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -3503,16 +3550,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -3521,7 +3559,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3557,7 +3595,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3597,7 +3635,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3796,28 +3834,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] [[package]] @@ -3837,7 +3875,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", "synstructure", ] @@ -3877,5 +3915,5 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn", ] diff --git a/airlock_libs/Cargo.toml b/airlock_libs/Cargo.toml index 1fb0612..cf49ac1 100644 --- a/airlock_libs/Cargo.toml +++ b/airlock_libs/Cargo.toml @@ -1,29 +1,31 @@ [package] name = "airlock_libs" -version = "3.1.2" +version = "6.1.1" edition = "2024" -[lib] -crate-type = ["cdylib"] - [dependencies] chrono = "0.4.42" indicatif = "0.18.2" mongodb = "3.3.0" -opentelemetry = { version = "0.18.0", features = ["rt-tokio", "metrics", "trace"] } -opentelemetry-otlp = { version = "0.11.0", features = ["trace", "metrics"] } -opentelemetry-semantic-conventions = { version = "0.10.0" } -opentelemetry-proto = { version = "0.1.0"} +opentelemetry = { version = "0.27.0", features = ["logs", "metrics", "trace"] } +opentelemetry-otlp = { version = "0.27.0", features = ["trace", "metrics", "grpc-tonic", "http-proto", "tls", "reqwest-client", "reqwest-rustls"] } +opentelemetry-semantic-conventions = { version = "0.27.0" } +opentelemetry-proto = { version = "0.27.0"} pyo3 = { version = "0.27.0", features = ["extension-module", "generate-import-lib"] } -reqwest = { version = "0.12.24", features = ["json", "native-tls"] } +reqwest = { version = "0.12.24", features = ["json", "native-tls", "rustls-tls"] } serde = "1.0.228" serde-pyobject = "0.8.0" serde_json = "1.0.145" tokio = { version = "1.48.0", features = ["full"] } -tonic = { version = "0.8.2", features = ["tls-roots"] } +tonic = { version = "0.12.3", features = ["tls-roots"] } tracing = "0.1.41" tracing-subscriber = "0.3.20" tracing-opentelemetry = "0.32.0" +crossbeam = "0.8.4" +log = "0.4.29" +flexi_logger = "0.31.7" +opentelemetry-appender-log = "0.27.0" +opentelemetry_sdk = { version = "0.27.0", features = ["rt-tokio", "testing", "trace"] } [package.metadata.maturin] generate-abi-stubs = true @@ -36,4 +38,4 @@ codegen-units = 1 panic = 'abort' strip = true debug-assertions = false -overflow-checks = false +overflow-checks = true diff --git a/airlock_libs/pyproject.toml b/airlock_libs/pyproject.toml index b646277..5ed6be4 100644 --- a/airlock_libs/pyproject.toml +++ b/airlock_libs/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "airlock_libs" -version = "3.1.2" +version = "6.1.1" description = "Airlock Digital API Wrapper" readme = "README.md" license = { text = "AGPL-3.0-only" } diff --git a/airlock_libs/src/lib.rs b/airlock_libs/src/lib.rs index d305d0c..a3b6e3d 100644 --- a/airlock_libs/src/lib.rs +++ b/airlock_libs/src/lib.rs @@ -1,5 +1,7 @@ use pyo3::prelude::*; -mod services; +pub mod modules; +pub mod prelude; +pub mod services; #[pymodule] fn airlock_libs(py: Python<'_>, m: &Bound) -> PyResult<()> { m.add_function(wrap_pyfunction!(services::pull_policy_exec_histories, py)?)?; diff --git a/airlock_libs/src/modules/datatypes.rs b/airlock_libs/src/modules/datatypes.rs new file mode 100644 index 0000000..72f50d8 --- /dev/null +++ b/airlock_libs/src/modules/datatypes.rs @@ -0,0 +1,131 @@ +use crate::prelude::*; +use crate::services::get_base_directory; +#[allow(non_snake_case)] +#[derive(Deserialize, Debug)] +pub struct TelemetryConfig { + pub TELEMETRY: bool, + pub TELEM_URL: Option, +} + +impl TelemetryConfig { + pub fn init_tracer() -> opentelemetry_sdk::trace::TracerProvider { + let cfg: TelemetryConfig = TelemetryConfig::load(); + if !cfg.TELEMETRY { + return TracerProvider::builder().build(); + } + let endpoint = cfg.TELEM_URL.unwrap_or_default(); + let channel = Channel::from_shared(endpoint.clone()) + .unwrap() + .tls_config(ClientTlsConfig::new().with_native_roots()) + .unwrap() + .connect_lazy(); + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(endpoint.clone()) + .with_channel(channel) + .build() + .expect("Failed to build exporter"); + opentelemetry_sdk::trace::TracerProvider::builder() + .with_simple_exporter(exporter) + .with_resource(Resource::new(vec![KeyValue::new( + "service.name", + "LoxideLibs", + )])) + .build() + } + fn load() -> Self { + let cfg_path = get_base_directory().join("config\\user_config.json"); + if !cfg_path.exists() { + return Self { + TELEMETRY: false, + TELEM_URL: None, + }; + } + match fs::read_to_string(&cfg_path) { + Ok(contents) => serde_json::from_str::(&contents).unwrap_or(Self { + TELEMETRY: false, + TELEM_URL: None, + }), + Err(_) => Self { + TELEMETRY: false, + TELEM_URL: None, + }, + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ApiResponse { + pub(crate) error: String, + pub(crate) response: ExecHistories, +} +#[derive(Debug, Deserialize, Serialize)] +pub struct ExecHistories { + pub(crate) exechistories: Vec, +} +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Group { + pub(crate) checkpoint: String, + #[serde(rename = "type")] + pub(crate) exectype: u8, + pub(crate) username: String, + pub(crate) hostname: String, + pub(crate) netdomain: String, + pub(crate) filename: String, + pub(crate) ppolicy: String, + pub(crate) policyname: String, + pub(crate) policyver: String, + pub(crate) commandline: String, + pub(crate) publisher: String, + pub(crate) pprocess: String, + pub(crate) gprocess: String, + pub(crate) sha256: String, + pub(crate) datetime: String, + pub(crate) md5: String, + pub(crate) sha128: String, + pub(crate) sha384: String, + pub(crate) sha512: String, + pub(crate) ip: String, + pub(crate) localip: String, +} + +pub struct PyData { + pub headers: reqwest::header::HeaderMap, + pub base_url: String, +} + +impl PyData { + pub fn extract_data(py: Python<'_>, obj: &Py) -> Self { + let headers_raw = obj.getattr(py, "headers").unwrap().to_string(); + let headers_json = headers_raw.replace('\'', "\""); + let parsed: Value = serde_json::from_str(&headers_json).unwrap(); + let mut header_map = HeaderMap::new(); + if let Some(obj) = parsed.as_object() { + for (key, val) in obj { + if let Some(v) = val.as_str() { + let header_name = HeaderName::from_str(key).unwrap(); + let header_value: HeaderValue = HeaderValue::from_str(v).unwrap(); + header_map.insert(header_name, header_value); + } + } + } + let base_url = obj.getattr(py, "base_url").unwrap().to_string(); + Self { + headers: header_map, + base_url, + } + } +} + +pub struct SkipBack; + +impl SkipBack { + pub fn find_checkpoint(days: i64) -> ObjectId { + let date_days_ago = Local::now() - Duration::days(days); + let timestamp = date_days_ago.timestamp() as u32; + let mut hex_timestamp = String::new(); + write!(&mut hex_timestamp, "{:08x}", timestamp).unwrap(); + let objectid_hex = format!("{}0000000000000000", hex_timestamp); + ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") + } +} diff --git a/airlock_libs/src/modules/mod.rs b/airlock_libs/src/modules/mod.rs new file mode 100644 index 0000000..0d8681d --- /dev/null +++ b/airlock_libs/src/modules/mod.rs @@ -0,0 +1 @@ +pub mod datatypes; diff --git a/airlock_libs/src/modules/updater.rs b/airlock_libs/src/modules/updater.rs new file mode 100644 index 0000000..e69de29 diff --git a/airlock_libs/src/prelude.rs b/airlock_libs/src/prelude.rs new file mode 100644 index 0000000..da633a9 --- /dev/null +++ b/airlock_libs/src/prelude.rs @@ -0,0 +1,32 @@ +pub use chrono::{Duration, Local, NaiveDate}; +pub use crossbeam::channel::unbounded; +pub use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; +pub use mongodb::bson::oid::ObjectId; +pub use opentelemetry::global::GlobalTracerProvider; +pub use opentelemetry::trace::noop::NoopTracerProvider; +pub use opentelemetry::trace::{Status, TraceContextExt, Tracer}; +pub use opentelemetry::*; +pub use opentelemetry_otlp::ExportConfig; +pub use opentelemetry_otlp::WithExportConfig; +pub use opentelemetry_otlp::WithTonicConfig; +pub use opentelemetry_sdk::Resource; +pub use opentelemetry_sdk::trace::{Config, TracerProvider}; +pub use pyo3::{prelude::*, types::PyString}; +pub use reqwest::{ + Client, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +pub use serde::{Deserialize, Serialize}; +pub use serde_json::Value; +pub use std::sync::{Arc, Mutex}; +pub use std::thread; +pub use std::{ + collections::HashMap, + env, + fmt::Write, + fs::{self, File}, + io::{Read, Seek, SeekFrom}, + path::PathBuf, + str::FromStr, +}; +pub use tonic::transport::{Channel, ClientTlsConfig}; diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs index c38adeb..c567f41 100644 --- a/airlock_libs/src/services.rs +++ b/airlock_libs/src/services.rs @@ -1,70 +1,5 @@ -use chrono::{Duration, Local, NaiveDate}; -use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; -use mongodb::bson::oid::ObjectId; -use opentelemetry::global::shutdown_tracer_provider; -use opentelemetry::sdk::Resource; -use opentelemetry::trace::noop::NoopTracerProvider; -use opentelemetry::trace::{Status, TraceContextExt, TraceError}; -use opentelemetry::{Context, KeyValue, sdk::trace as sdktrace, trace::Tracer}; -use opentelemetry::{Key, global}; -use opentelemetry_otlp::WithExportConfig; -use pyo3::{prelude::*, types::PyString}; -use reqwest::{ - Client, - header::{HeaderMap, HeaderName, HeaderValue}, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::{ - collections::HashMap, - env, - fmt::Write, - fs::{self, File}, - io::{Read, Seek, SeekFrom}, - path::PathBuf, - str::FromStr, -}; - -#[derive(Deserialize, Debug)] -struct TelemetryConfig { - TELEMETRY: bool, - TELEM_URL: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -struct ApiResponse { - error: String, - response: ExecHistories, -} -#[derive(Debug, Deserialize, Serialize)] -struct ExecHistories { - exechistories: Vec, -} -#[derive(Debug, Deserialize, Serialize, Clone)] -struct Group { - checkpoint: String, - #[serde(rename = "type")] - exectype: u8, - username: String, - hostname: String, - netdomain: String, - filename: String, - ppolicy: String, - policyname: String, - policyver: String, - commandline: String, - publisher: String, - pprocess: String, - gprocess: String, - sha256: String, - datetime: String, - md5: String, - sha128: String, - sha384: String, - sha512: String, - ip: String, - localip: String, -} +use crate::modules::datatypes::*; +use crate::prelude::*; #[pyfunction] pub fn pull_policy_exec_histories( @@ -74,114 +9,121 @@ pub fn pull_policy_exec_histories( exec_types: String, days: i64, ) -> Py { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - let _ = init_tracer(); - }); - let tracer = global::tracer("global_tracer"); - let _cx = Context::new(); - let file_path: PathBuf = format!( - "{}\\cache\\chunkinator.json", - get_base_directory().display() - ) - .into(); - let writeable_filepath = file_path.clone(); - if !file_path.exists() { - if let Some(parent_dir) = file_path.parent() - && !parent_dir.exists() - { - fs::create_dir_all(parent_dir).unwrap(); - } - fs::File::create(file_path).unwrap(); - } - let data = ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: vec![], - }, - }; - let data_write = serde_json::to_string_pretty(&data).expect("Failed to serialize"); - fs::write(writeable_filepath.clone(), data_write).unwrap(); - let mut checkpoint_number: String = skipback(days).to_string(); - let multi_progress = MultiProgress::new(); - multi_progress.set_draw_target(ProgressDrawTarget::stdout()); - let progress_bar = multi_progress.add(ProgressBar::new(100)); - progress_bar.set_style( - ProgressStyle::default_bar() - .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len}") - .unwrap(), - ); - progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); - let client = tracer.in_span("Building HTTP Client", |cx| { - let client_result = build_client(py, &py_self); - match client_result { - Ok(client_result) => { - cx.span().add_event( - "info", - vec![KeyValue::new( - "Client Built Successfully", - format!("{:?}", client_result), - )], - ); - client_result + println!(); + let data: PyData = PyData::extract_data(py, &py_self); + let headers: HeaderMap = data.headers; + let base_url: String = data.base_url; + let handle: thread::JoinHandle = std::thread::spawn(move || { + let rt: tokio::runtime::Runtime = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + println!("Failed to build Tokio Runtime: {:?}", e); + std::process::abort(); } - Err(client_result) => { - cx.span().add_event( - "warn", - vec![KeyValue::new( - "Client Failed to Build", - format!("{:?}", &client_result), - )], - ); - cx.span() - .set_status(Status::error("Client Failed to Build")); - panic!("Failed to Build Client: {:?}", client_result); + }; + let tracer_provider = rt.block_on(async { TelemetryConfig::init_tracer() }); + global::set_tracer_provider(tracer_provider.clone()); + let tracer: global::BoxedTracer = global::tracer("tracer"); + let _cx: Context = Context::new(); + let file_path: PathBuf = format!( + "{}\\cache\\chunkinator.json", + get_base_directory().display() + ) + .into(); + if !&file_path.exists() { + if let Some(parent_dir) = &file_path.parent() + && !parent_dir.exists() + { + match fs::create_dir_all(parent_dir) { + Ok(_) => {} + Err(e) => { + println!("Failed to Create Directory {:?}: {}", parent_dir, e); + std::process::abort(); + } + } + } + match fs::File::create(&file_path) { + Ok(_) => {} + Err(e) => { + println!("Failed to Create Directory {:?}: {}", &file_path, e); + std::process::abort(); + } } } - }); - let api: Py = py_self; - let cutoff = Local::now().naive_local() - Duration::days(days); - let mut f = File::open(&writeable_filepath).unwrap(); - tracer.in_span("Airlock Data Retreival", |cx| { - let span = cx.span(); - span.set_attribute(Key::new("Days").string(days.to_string().to_string())); - loop { - f.seek(SeekFrom::Start(0)).unwrap(); - let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { - let results: ApiResponse = history_logging( - py, - &api, - &exec_types, - &checkpoint_number, - &policy_names, - &client, - ); - cx.span().set_attribute(KeyValue::new( - "items_in_response", - results.response.exechistories.len().to_string(), - )); - results - }); - let parsed_responses = execution_histories.response.exechistories; - if parsed_responses.is_empty() { - break; + let data: ApiResponse = ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }; + let writeable_filepath: PathBuf = file_path.clone(); + let data_write: String = serde_json::to_string_pretty(&data).expect("Failed to serialize"); + match fs::write(writeable_filepath.clone(), data_write) { + Ok(_) => {} + Err(e) => { + println!("Failed to write to: {:?}: {}", &writeable_filepath, e); + std::process::abort(); } + } + let mut checkpoint_number: String = SkipBack::find_checkpoint(days).to_string(); + let progress_bar = Arc::new(Mutex::new(ProgressBar::new(100))); + progress_bar + .lock() + .unwrap() + .set_draw_target(ProgressDrawTarget::stderr()); + progress_bar.lock().unwrap().set_style( + ProgressStyle::default_bar() + .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len} {message}") + .unwrap(), + ); + let client: Client = tracer.in_span("Building HTTP Client", |cx| { + let client_result: Result = build_client(headers); + match client_result { + Ok(client_result) => { + cx.span().add_event( + "info", + vec![KeyValue::new( + "Client Built Successfully", + format!("{:?}", client_result), + )], + ); + client_result + } + Err(client_result) => { + cx.span().add_event( + "warn", + vec![KeyValue::new( + "Client Failed to Build", + format!("{:?}", &client_result), + )], + ); + cx.span() + .set_status(Status::error("Client Failed to Build")); + println!("Failed to Build Client: {:?}", client_result); + std::process::abort(); + } + } + }); + let cutoff: chrono::NaiveDateTime = + Local::now().naive_local() - chrono::Duration::days(days); + let (tx, rx) = unbounded::>(); + let pb_clone = progress_bar.clone(); + thread::spawn(move || { let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() { - let mut contents = String::new(); - f.read_to_string(&mut contents).unwrap(); - let existing_data: ApiResponse = + let contents: String = fs::read_to_string(&writeable_filepath).unwrap_or_default(); + let existing: ApiResponse = serde_json::from_str(&contents).unwrap_or(ApiResponse { error: "Success".to_string(), response: ExecHistories { exechistories: vec![], }, }); - existing_data + existing .response .exechistories .into_iter() - .map(|entry| { + .map(|entry: Group| { ( ( entry.sha256.clone(), @@ -195,88 +137,131 @@ pub fn pull_policy_exec_histories( } else { HashMap::new() }; - for (index, executions) in parsed_responses.iter().enumerate() { - if executions.checkpoint.is_empty() || executions.datetime.is_empty() { - continue; + while let Ok(parsed_responses) = rx.recv() { + for executions in parsed_responses { + if executions.checkpoint.is_empty() || executions.datetime.is_empty() { + continue; + } + let history_date: NaiveDate = match NaiveDate::parse_from_str( + &executions.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) { + Ok(date) => date, + Err(_) => continue, + }; + if history_date >= cutoff.into() { + let key: (String, String, String) = ( + executions.sha256.clone(), + executions.filename.clone(), + executions.hostname.clone(), + ); + seen.entry(key).or_insert(executions.clone()); + } } - if index == parsed_responses.len() - 1 { - checkpoint_number = executions.checkpoint.clone(); + let final_response: ApiResponse = ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: seen.values().cloned().collect(), + }, + }; + let data_write: String = serde_json::to_string_pretty(&final_response).unwrap(); + match fs::write(&writeable_filepath, data_write) { + Ok(_) => {} + Err(e) => { + println!("Failed to write to: {:?}: {}", &writeable_filepath, e); + } + } + } + }); + let mut first_date: Option = None; + tracer.in_span("Airlock Data Retreival", |cx| { + pb_clone + .lock() + .unwrap() + .enable_steady_tick(std::time::Duration::from_millis(100)); + let span: opentelemetry::trace::SpanRef<'_> = cx.span(); + span.set_attribute(KeyValue::new("Days", days.to_string())); + span.set_attribute(KeyValue::new("Policy Name", policy_names.clone())); + loop { + let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| { + let results: ApiResponse = history_logging( + &base_url, + &exec_types, + &checkpoint_number, + &policy_names, + &client, + ); + cx.span().set_attribute(KeyValue::new( + "items_in_response", + results.response.exechistories.len().to_string(), + )); + results + }); + let parsed_responses: Vec = execution_histories.response.exechistories; + if parsed_responses.is_empty() { break; } - let history_date = match NaiveDate::parse_from_str( - &executions.datetime.replace(" +0000 UTC", ""), - "%Y-%m-%dT%H:%M:%SZ", - ) { - Ok(date) => date, - Err(_) => continue, - }; - if history_date >= cutoff.into() { - let key = ( - executions.sha256.clone(), - executions.filename.clone(), - executions.hostname.clone(), - ); - seen.entry(key).or_insert(executions.clone()); + tx.send(parsed_responses.clone()).unwrap(); + checkpoint_number = parsed_responses.last().unwrap().checkpoint.clone(); + if let Some(last_item) = parsed_responses.last() + && let Ok(last_date) = NaiveDate::parse_from_str( + &last_item.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) + { + if first_date.is_none() { + first_date = Some(last_date); + } + if let Some(base_date) = first_date { + let date_diff: chrono::TimeDelta = last_date - base_date; + let total_span: i64 = + (Local::now().naive_local().date() - base_date).num_days(); + let percentage: u64 = ((date_diff.num_days() as f64 / total_span as f64) + * 100.0) + .clamp(0.0, 100.0) + .round() as u64; + pb_clone.lock().unwrap().set_position(percentage); + } } } - let final_response = ApiResponse { - error: "Success".to_string(), - response: ExecHistories { - exechistories: seen.values().cloned().collect(), - }, - }; - let data_write = serde_json::to_string_pretty(&final_response).unwrap(); - fs::write(&writeable_filepath, data_write).unwrap(); - if let Some(last_item) = &final_response.response.exechistories.last() - && let Ok(last_date) = NaiveDate::parse_from_str( - &last_item.datetime.replace(" +0000 UTC", ""), - "%Y-%m-%dT%H:%M:%SZ", - ) - { - let date_diff = Local::now().naive_local().date() - last_date; - let percentage_diff = (days - date_diff.num_days()) as f64 / days as f64 * 100.0; - progress_bar.set_position(percentage_diff.round() as u64); - progress_bar.set_message("Total Percent Complete"); + }); + progress_bar + .lock() + .unwrap() + .finish_with_message("All Checkpoints Complete"); + let return_data: String = match fs::read_to_string(file_path.clone()) { + Ok(return_data) => return_data, + Err(e) => { + println!("Failed to read data from: {:?}: {}", &file_path, e); + std::process::abort(); } - } + }; + tracer_provider + .shutdown() + .expect("Failed to Shutdown Tracer Provdier"); + drop(tx); + return_data.to_string() }); - progress_bar.finish_with_message("All Checkpoints Complete"); - let return_data = fs::read_to_string(&writeable_filepath).unwrap(); - shutdown_tracer_provider(); - PyString::new(py, &return_data).into() + let gil_value: String = handle.join().unwrap(); + Python::attach(|py: Python<'_>| PyString::new(py, &gil_value).into()) } -fn build_client(py: Python<'_>, py_self: &Py) -> Result { - let headers = py_self.getattr(py, "headers").unwrap().to_string(); - let headers_replace = headers.replace('\'', "\""); - let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); - let mut header_map = HeaderMap::new(); - if let Some(obj) = parsed.as_object() { - for (_key, value) in obj { - if let Some(v) = value.as_str() { - let val = HeaderValue::from_str(v).unwrap(); - header_map.insert(HeaderName::from_str("X-APIKey").unwrap(), val); - } - } - } - +fn build_client(headers: HeaderMap) -> Result { Client::builder() .danger_accept_invalid_certs(true) - .default_headers(header_map) + .default_headers(headers) .timeout(std::time::Duration::from_secs(300)) .build() } #[tokio::main] async fn history_logging( - py: Python<'_>, - py_self: &Py, + base_url: &String, exec_types: &String, checkpoint_number: &String, policy_names: &String, client: &Client, ) -> ApiResponse { - let base_url = py_self.getattr(py, "base_url").unwrap().to_string(); let payload = format!( r#"{{ "type": {}, @@ -285,7 +270,7 @@ async fn history_logging( }}"#, exec_types, checkpoint_number, policy_names ); - let res = client + let res: Result = client .post(format!("{}/v1/logging/exechistories", base_url)) .body(payload) .send() @@ -308,7 +293,7 @@ async fn history_logging( } } -fn get_base_directory() -> PathBuf { +pub fn get_base_directory() -> PathBuf { let home = env::var_os("HOME") .map(PathBuf::from) .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from)) @@ -321,60 +306,10 @@ fn get_base_directory() -> PathBuf { .unwrap_or_else(|| home.join("AppData").join("Roaming")); appdata.join("Loxide") } - _ => home.join(".local").join("share").join("Loxide"), - } -} - -fn skipback(days: i64) -> ObjectId { - let date_days_ago = Local::now() - Duration::days(days); - let timestamp = date_days_ago.timestamp() as u32; - let mut hex_timestamp = String::new(); - write!(&mut hex_timestamp, "{:08x}", timestamp).unwrap(); - let objectid_hex = format!("{}0000000000000000", hex_timestamp); - ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") -} - -fn load_telemetry_config() -> TelemetryConfig { - let cfg_path = get_base_directory().join("config\\user_config.json"); - if !cfg_path.exists() { - return TelemetryConfig { - TELEMETRY: false, - TELEM_URL: None, - }; - } - match fs::read_to_string(&cfg_path) { - Ok(contents) => { - serde_json::from_str::(&contents).unwrap_or(TelemetryConfig { - TELEMETRY: false, - TELEM_URL: None, - }) + "linux" => home.join(".local").join("share").join("Loxide"), + _ => { + println!("{} is currently not compatible with LoxideLibs", os); + std::process::abort(); } - Err(_) => TelemetryConfig { - TELEMETRY: false, - TELEM_URL: None, - }, } } - -fn init_tracer() -> Result, TraceError> { - let cfg = load_telemetry_config(); - if !cfg.TELEMETRY { - global::set_tracer_provider(NoopTracerProvider::new()); - return Ok(None); - } - let endpoint = cfg.TELEM_URL.unwrap_or_default(); - let tracer = - opentelemetry_otlp::new_pipeline() - .tracing() - .with_exporter( - opentelemetry_otlp::new_exporter() - .tonic() - .with_endpoint(endpoint), - ) - .with_trace_config(sdktrace::config().with_resource(Resource::new(vec![ - KeyValue::new("service.name", "LoxideLibs"), - ]))) - .install_simple() - .unwrap(); - Ok(Some(tracer)) -} diff --git a/flows/localApproval.py b/flows/localApproval.py index f1054f1..d6bda28 100644 --- a/flows/localApproval.py +++ b/flows/localApproval.py @@ -1,6 +1,17 @@ -""" -This module handles the creation of local approval requests. -""" +# 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 . import logging import os @@ -51,7 +62,7 @@ class LocalApprovalRequestor: batch_id = int(time.time()) purpose = ( - f"🎫 Local Approval 🎫 - {duration_minutes} mins - " + f" Local Approval - {duration_minutes} mins - " f"batch:{batch_id} Client:{agent_id} User:{self.username}" ) @@ -103,11 +114,9 @@ class LocalApprovalRequestor: success_count = 0 failure_count = 0 - print(colorText(f"\n📦 Processing batch {batch_id}...", "cyan")) - print(colorText(f"👤 Requested by: {self.username}", "cyan")) - print( - colorText(f"📊 Moving {len(agents)} agent(s) to local approval\n", "cyan") - ) + print(colorText(f"\n Processing batch {batch_id}...", "cyan")) + print(colorText(f" Requested by: {self.username}", "cyan")) + print(colorText(f" Moving {len(agents)} agent(s) to local approval\n", "cyan")) for agent in agents: try: @@ -125,11 +134,11 @@ class LocalApprovalRequestor: if not move_success: raise Exception("Failed to move to audit policy") - print(colorText(f"✓ {agent.hostname}", "green")) + print(colorText(f" {agent.hostname}", "green")) success_count += 1 except Exception as e: - print(colorText(f"✗ {agent.hostname}: {e}", "red")) + print(colorText(f" {agent.hostname}: {e}", "red")) logger.error(f"Error processing agent {agent.hostname}: {e}") failure_count += 1 @@ -152,7 +161,7 @@ class LocalApprovalRequestor: ] # Display duration options - print(colorText("\n⏱️ Select Local Approval Duration:", "white")) + print(colorText("\n Select Local Approval Duration:", "white")) print(colorText("=" * 50, "white")) for i, (minutes, label) in enumerate(duration_options, start=1): @@ -166,36 +175,36 @@ class LocalApprovalRequestor: if 1 <= choice <= len(duration_options): duration_minutes, duration_label = duration_options[choice - 1] - print(colorText(f"✓ Selected: {duration_label}", "green")) + print(colorText(f" Selected: {duration_label}", "green")) logger.info(f"User selected duration: {duration_minutes} minutes") else: - print(colorText("❌ Invalid choice.", "red")) + print(colorText("❌ Invalid choice.", "red")) logger.warning("Invalid duration choice") return except ValueError: - print(colorText("❌ Invalid input. Please enter a number.", "red")) + print(colorText("❌ Invalid input. Please enter a number.", "red")) logger.warning("Invalid input for duration selection") return # Select agents - print(colorText("\n🎯 Select Agents for Local Approval:", "white")) + print(colorText("\nSelect Agents for Local Approval:", "white")) agents = selectAgents(self.api) if not agents: - print(colorText("❌ No agents found or error retrieving agents.", "red")) + print(colorText("❌ No agents found or error retrieving agents.", "red")) logger.warning("No agents selected or error retrieving agents") return # Confirm with user - print(colorText("\n📋 Summary:", "cyan")) + print(colorText("\nSummary:", "cyan")) print(colorText(f" Duration: {duration_label}", "white")) print(colorText(f" Agents: {len(agents)}", "white")) confirm = get_sanitized_input("\nProceed? (y/n): ").lower() if confirm != "y": - print(colorText("❌ Operation cancelled.", "yellow")) + print(colorText("❌ Operation cancelled.", "yellow")) return # Process the batch @@ -219,24 +228,24 @@ class LocalApprovalRequestor: failure_count: Number of failed operations """ print(colorText(f"\n{'=' * 60}", "white")) - print(colorText("📊 Local Approval Summary", "cyan")) + print(colorText(" Local Approval Summary", "cyan")) print(colorText("=" * 60, "white")) - print(colorText(f"✓ Successfully processed: {success_count}", "green")) + print(colorText(f" Successfully processed: {success_count}", "green")) if failure_count > 0: - print(colorText(f"✗ Failed: {failure_count}", "red")) + print(colorText(f" Failed: {failure_count}", "red")) - print(colorText(f"\n📦 Batch ID: {batch_id}", "cyan")) - print(colorText(f"⏱️ Duration: {duration_label}", "cyan")) + print(colorText(f"\n Batch ID: {batch_id}", "cyan")) + print(colorText(f" Duration: {duration_label}", "cyan")) print(colorText("=" * 60, "white")) - print(colorText("\n💡 Next Steps:", "yellow")) - print(colorText(" • Agents have been moved to audit policies", "white")) - print(colorText(" • Local approvals are active", "white")) + print(colorText("\n Next Steps:", "yellow")) + print(colorText(" ✅ Agents have been moved to audit policies", "white")) + print(colorText(" ✅ Local approvals are active", "white")) print( colorText( - f" • Agents will return to enforcement after {duration_label}", + f" ✅ Agents will return to enforcement after {duration_label}", "white", ) ) diff --git a/flows/prepPolicy.py b/flows/prepPolicy.py index 53f7ede..e6e6298 100644 --- a/flows/prepPolicy.py +++ b/flows/prepPolicy.py @@ -88,7 +88,7 @@ def sortHashes( ): working_dir = load_env("WORKING_DIR") history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", + prompt="Enter how many days of history to pull (1-150): ", value_type=int, valid_range=(1, 150), ) @@ -655,7 +655,7 @@ def section_header(title): def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist): working_dir = load_env("WORKING_DIR") - section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒") + section_header("Prepare to Enforce Policy") print( colorText( "\nSequentially follow these steps to prepare a policy for enforcement:", @@ -670,11 +670,11 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all ) ) if not selected_policies: - print(colorText(" [✗] No policies have been chosen", "red")) + 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")) + print(colorText(f" [✅] {policy.name}", "green")) # Step 2: Destination Policy and Allowlist print( @@ -683,22 +683,22 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all if destination_policy: print( colorText( - f" [✓] {destination_policy[0].name} has been selected as the destination policy", + f" [✅] {destination_policy[0].name} has been selected as the destination policy", "green", ) ) else: - print(colorText(" [✗] No destination policy has been chosen", "red")) + print(colorText(" [❌] No destination policy has been chosen", "red")) if destination_allowlist: print( colorText( - f" [✓] {destination_allowlist[0].name} has been selected as allowlist", + f" [✅] {destination_allowlist[0].name} has been selected as allowlist", "green", ) ) else: - print(colorText(" [✗] No allowlist has been chosen", "red")) + print(colorText(" [❌] No allowlist has been chosen", "red")) # Step 3: Data Preparation print( @@ -713,9 +713,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Data has been fetched" + " [✅] Data has been fetched" if os.path.exists(review_path) - else " [✗] Data has not been fetched" + else " [❌] Data has not been fetched" ), "green" if os.path.exists(review_path) else "red", ) @@ -723,7 +723,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [✗] No policies selected, cannot check data fetch status", "red" + " [❌] No policies selected, cannot check data fetch status", "red" ) ) @@ -756,9 +756,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Reviewed hashes have been loaded" + " [✅] Reviewed hashes have been loaded" if os.path.exists(approved_path) - else " [✗] Reviewed hashes have not been loaded" + else " [❌] Reviewed hashes have not been loaded" ), "green" if os.path.exists(approved_path) else "red", ) @@ -766,9 +766,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Path review list created" + " [✅] Path review list created" if os.path.exists(second_review_path) - else " [✗] Path review list has not been created" + else " [❌] Path review list has not been created" ), "green" if os.path.exists(second_review_path) else "red", ) @@ -776,7 +776,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [✗] No policies selected, cannot check reviewed hashes or path list", + " [❌] No policies selected, cannot check reviewed hashes or path list", "red", ) ) @@ -812,9 +812,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Reviewed path list detected" + " [✅] Reviewed path list detected" if os.path.exists(reviewed_path) - else " [✗] Path review list has not been detected" + else " [❌] Path review list has not been detected" ), "green" if os.path.exists(reviewed_path) else "red", ) @@ -825,9 +825,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print( colorText( ( - " [✓] Preflight Path Exclusion List has been generated" + " [✅] Preflight Path Exclusion List has been generated" if preflight_ready - else " [✗] Preflight Path Exclusion List has not been generated" + else " [❌] Preflight Path Exclusion List has not been generated" ), "green" if preflight_ready else "red", ) @@ -835,7 +835,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all else: print( colorText( - " [✗] No policies selected, cannot check preflight status", "red" + " [❌] No policies selected, cannot check preflight status", "red" ) ) @@ -866,5 +866,5 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all print(colorText(" Apply approved hashes to allowlist", "cyan")) # Utility Options - print(colorText("F. 📂 - Open Working Directory", "cyan")) - print(colorText("B. 🔚 - Back", "cyan")) + print(colorText("F. Open Working Directory", "cyan")) + print(colorText("B. Back", "cyan")) diff --git a/models/execution.py b/models/execution.py index 462193d..439515f 100644 --- a/models/execution.py +++ b/models/execution.py @@ -145,13 +145,13 @@ class Hash: approved_count += 1 except (ValueError, TypeError): logger.debug( - "Needs Review: Scannermatch score is missing or invalid. — {e}" + "Needs Review: Scannermatch score is missing or invalid. — {e}" ) hash_obj.at_decision = "needs_review" needs_review_count += 1 logger.debug( - f"Final counts — Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" + f"Final counts — Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" ) return hashes @@ -443,13 +443,13 @@ class ExecutionHistoryRecord: approved_count += 1 except (ValueError, TypeError) as e: logger.debug( - f"Needs Review: Scannermatch score is missing or invalid. — {e}" + f"Needs Review: Scannermatch score is missing or invalid. — {e}" ) hash_obj.at_decision = "needs_review" needs_review_count += 1 logger.debug( - f"Final counts — Needs Review: {needs_review_count}, " + f"Final counts — Needs Review: {needs_review_count}, " f"Approved: {approved_count}, Unapproved: {unapproved_count}" ) diff --git a/requirements.txt b/requirements.txt index a89fe9e..30d5f35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,26 @@ +# Core TUI dependencies +textual==6.5.0 + +# API and data handling +Requests==2.32.5 +pandas==2.3.3 +numpy==2.3.4 + +# Database +pymongo==4.15.3 + +# Security and encryption cryptography==46.0.3 keyring==25.6.0 -numpy==2.3.4 -pandas==2.3.3 -pymongo==4.15.3 + +# Environment management python-dotenv==1.2.1 -Requests==2.32.5 -textual==6.5.0 + +# Utilities tqdm==4.67.1 urllib3==2.5.0 pyperclip==1.11.0 +# Custom/Private packages --extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/ -airlock_libs==3.1.2 \ No newline at end of file +airlock_libs==6.1.1 \ No newline at end of file diff --git a/services/API.py b/services/API.py index 22cfa7d..d9e2d36 100644 --- a/services/API.py +++ b/services/API.py @@ -351,6 +351,14 @@ class AirlockAPIWrapper: result = self._post("/v1/getexechistory", payload) return result["response"]["exechistory"] + def server_logs(self, checkpoint: str | None = None) -> str: + """Retrieves Server Activity History Logs.""" + payload = {} + if checkpoint is not None: + payload["checkpoint"] = checkpoint + result = self._post("/v1/logging/svractivities?checkpoint", payload) + return result["response"]["svractivities"] + """ from services.API import AirlockAPIWrapper diff --git a/services/agenthandler.py b/services/agenthandler.py index 7a788e6..2d50f4d 100644 --- a/services/agenthandler.py +++ b/services/agenthandler.py @@ -38,9 +38,9 @@ logger = logging.getLogger(__name__) def devicehistory(api: AirlockAPIWrapper, outputjson: bool): agents = selectAgents(api) history_days = Selector.select_value( - prompt="Enter how many days of history to pull (1–150): ", + prompt="Enter how many days of history to pull (1–365): ", value_type=int, - valid_range=(1, 150), + valid_range=(1, 365), ) if not agents or not history_days: @@ -139,7 +139,7 @@ def findAgents(api, return_dataframe): print( colorText( - f"\n✅ Matched devices exported to: {working_dir}\\{filename}", + f"\n✓ Matched devices exported to: {working_dir}\\{filename}", "green", ) ) @@ -148,7 +148,7 @@ def findAgents(api, return_dataframe): def collect_device_names() -> List[str]: - print(colorText("🔍 Device Search", "cyan")) + print(colorText("🖥��Â Device Search", "cyan")) print( colorText( "Enter the device hostnames you'd like to search for, one per line.", "cyan" @@ -185,7 +185,7 @@ def collect_device_names() -> List[str]: else: print( colorText( - f"⚠️ Invalid input: '{stripped_line}' — only letters, numbers, underscores, spaces, and hyphens are allowed.", + f"⚠️ Invalid input: '{stripped_line}' — only letters, numbers, underscores, spaces, and hyphens are allowed.", "yellow", ) ) @@ -265,7 +265,7 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: print(colorText("❌ No matching devices found.", "red")) return [] - print(colorText(f"✅ Found {len(matched_agents)} matching device(s).", "green")) + print(colorText(f"✓ Found {len(matched_agents)} matching device(s).", "green")) logger.info("Matched agent hostnames:") rows = (len(matched_agents) + 2) // 3 # 3 columns for row in range(rows): @@ -302,7 +302,7 @@ def moveAgentToRelatedPolicy( Args: api: AirlockAPIWrapper instance. agent: Agent object. - policy_relationship_map: Dict mapping enforcement → audit. + policy_relationship_map: Dict mapping enforcement â–€ –€™ audit. mode: 'audit' to move to audit, 'enforcement' to move to enforcement. """ policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") diff --git a/services/policyhandler.py b/services/policyhandler.py index 0fa6a11..e36fe23 100644 --- a/services/policyhandler.py +++ b/services/policyhandler.py @@ -27,9 +27,8 @@ import tqdm from models.policy import Policy from services.API import AirlockAPIWrapper -from utils.configmanager import get_system_json from utils.setup import get_base_directory -from utils.utils import areYouSure, colorText, get_sanitized_input +from utils.utils import colorText logger = logging.getLogger(__name__) @@ -233,17 +232,3 @@ def skipback(days): hex_timestamp = format(timestamp, "08x") objectid_hex = hex_timestamp + "0000000000000000" return ObjectId(objectid_hex) - - -def updateAuditPoliciesFromEnforcementPolices(api: AirlockAPIWrapper): - policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}") - for enforcement_policy, audit_policy in policy_relationship_map.items(): - api.policy_clone(enforcement_policy, audit_policy) - api.policy_set_auditmode(audit_policy, "1") - - -def confirmUpdateAfromE(api: AirlockAPIWrapper): - areYouSure() - confirmation = get_sanitized_input("Type 'I AGREE' to continue: ") - if confirmation.strip() == "I AGREE": - updateAuditPoliciesFromEnforcementPolices(api) diff --git a/utils/selector.py b/utils/selector.py index 2cac574..d43f6d8 100644 --- a/utils/selector.py +++ b/utils/selector.py @@ -184,7 +184,7 @@ class Selector: print(colorText(f"✅ Included {len(selected)} item(s).", "green")) return selected elif mode == "e": - print(colorText(f"🚫 Excluded {len(selected)} item(s).", "yellow")) + print(colorText(f"👫 Excluded {len(selected)} item(s).", "yellow")) return [item for item in items if item not in selected] else: print(colorText("⚠️ Invalid mode. Returning all items.", "yellow")) @@ -279,7 +279,9 @@ class Selector: df = df[columns] items = [row for _, row in df.iterrows()] - label_func = lambda row: str(row.to_dict()) + + def label_func(row): + return str(row.to_dict()) result = Selector._select_from_list( items, @@ -311,7 +313,9 @@ class Selector: df = df[columns] items = df.to_dict("records") - label_func = lambda row: " | ".join(str(row[col]) for col in df.columns) + + def label_func(row): + return " | ".join(str(row[col]) for col in df.columns) # Show rows first print(colorText(header, "cyan")) @@ -346,7 +350,7 @@ class Selector: print(colorText(f"✅ Included {len(selected)} row(s).", "green")) return [pd.Series(row) for row in selected] elif mode == "e": - print(colorText(f"🚫 Excluded {len(selected)} row(s).", "yellow")) + print(colorText(f"👫 Excluded {len(selected)} row(s).", "yellow")) return [pd.Series(row) for row in items if row not in selected] else: print(colorText("⚠️ Invalid mode. Returning no rows.", "yellow")) diff --git a/utils/setup.py b/utils/setup.py index ee4a384..36cf927 100644 --- a/utils/setup.py +++ b/utils/setup.py @@ -29,6 +29,49 @@ from utils.configmanager import ( ) +class TextualNotificationHandler(logging.Handler): + """ + Custom logging handler that sends ERROR, WARNING, and CRITICAL logs + to Textual toast notifications. + """ + + def __init__(self, app): + super().__init__() + self.app = app + + def emit(self, record): + try: + # Only handle ERROR, WARNING, and CRITICAL + if record.levelno >= logging.WARNING: + # Format the message + msg = self.format(record) + + # Map log levels to Textual severity + severity_map = { + logging.WARNING: "warning", + logging.ERROR: "error", + logging.CRITICAL: "error", + } + severity = severity_map.get(record.levelno, "information") + + # Send to Textual notification + # Use call_from_thread if logging from non-main thread + try: + self.app.notify(msg, severity=severity, timeout=5) + except Exception: + # If we're not on the main thread, schedule it + try: + self.app.call_from_thread( + self.app.notify, msg, severity=severity, timeout=5 + ) + except Exception: + # Silently fail to avoid breaking the logging system + pass + except Exception: + # Silently fail to avoid breaking the logging system + pass + + def get_base_directory() -> Path: system = platform.system() home = Path.home() @@ -44,38 +87,31 @@ def configure_logging(log_dir: Path, log_level: str = "INFO"): log_file = log_dir / "Loxide.log" config = { - "version": 1, # Required key for dictConfig format version - "disable_existing_loggers": False, # Keeps existing loggers active + "version": 1, + "disable_existing_loggers": False, "formatters": { "detailed": { "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - # Includes timestamp, logger name, level, and message - }, - "simple": { - "format": "%(levelname)s - %(message)s" - # Minimal format for console output }, + "simple": {"format": "%(levelname)s - %(message)s"}, + "toast": {"format": "%(name)s: %(message)s"}, # Simpler format for toasts }, "handlers": { "file": { "class": "logging.handlers.TimedRotatingFileHandler", "filename": str(log_file), - "when": "midnight", # Rotate logs at midnight - "interval": 1, # Every 1 day - "backupCount": 7, # Keep 7 days of logs - "encoding": "utf-8", # Ensure UTF-8 encoding - "level": "DEBUG", # Always log DEBUG and above to file - "formatter": "detailed", # Use detailed format - }, - "console": { - "class": "logging.StreamHandler", - "level": log_level.upper(), # System-configured level for console - "formatter": "simple", # Use simple format + "when": "midnight", + "interval": 1, + "backupCount": 7, + "encoding": "utf-8", + "level": "DEBUG", + "formatter": "detailed", }, + # REMOVED console handler - it interferes with Textual TUI }, "root": { - "level": "DEBUG", # Root logger level - "handlers": ["file", "console"], # Attach both handlers + "level": "DEBUG", + "handlers": ["file"], # Only use file handler, not console }, } @@ -96,6 +132,18 @@ def configure_logging(log_dir: Path, log_level: str = "INFO"): logging.config.dictConfig(config) logging.getLogger().debug("✅ Logging configured.") + # Return a function to attach the notification handler once the app is created + def attach_notification_handler(app): + """Attach the Textual notification handler to the root logger.""" + handler = TextualNotificationHandler(app) + handler.setLevel(logging.WARNING) # Only WARNING and above + formatter = logging.Formatter("%(name)s: %(message)s") + handler.setFormatter(formatter) + logging.getLogger().addHandler(handler) + logging.getLogger().debug("✅ Textual notification handler attached.") + + return attach_notification_handler + def setup(): """ @@ -105,6 +153,9 @@ def setup(): 3. Load user config (mutable, from user_config.json) 4. Configure logging 5. Set up .env with WORKING_DIR only + + Returns: + attach_notification_handler: Function to attach notification handler to TUI app """ base_dir = get_base_directory() dirs = { @@ -118,14 +169,14 @@ def setup(): logging.debug(f"{name.capitalize()} directory ensured at: {path}") # Load system config (immutable) - system_config = load_system_config() + load_system_config() # Configure logging with system-defined log level log_level = get_system_value("LOG_LEVEL", str, "INFO") - configure_logging(dirs["logs"], log_level) + attach_handler = configure_logging(dirs["logs"], log_level) # Load user config (mutable) - user_config = load_user_config(dirs["config"]) + load_user_config(dirs["config"]) # Set up .env file - ONLY for WORKING_DIR (runtime-configurable value) env_path = base_dir / ".env" @@ -145,7 +196,6 @@ def setup(): "Approved": [], "Needs_Review": ["Review_First", "Review_Second", "HTML"], "Preflight": ["HTML"], - "Archived": [], } for folder_name, subfolders in folders_structure.items(): @@ -155,6 +205,9 @@ def setup(): for subfolder in subfolders: subfolder_path = folder_path / subfolder subfolder_path.mkdir(parents=True, exist_ok=True) - logging.debug(f" └─ '{subfolder}' subfolder created at: {subfolder_path}") + logging.debug(f"'{subfolder}' subfolder created at: {subfolder_path}") logging.info("✅ Setup complete") + + # Return the attach handler function + return attach_handler