From 797d0f44627cd4668410db94a5cd6ae530bfd351 Mon Sep 17 00:00:00 2001 From: Zarithas Date: Thu, 11 Dec 2025 16:55:12 -0500 Subject: [PATCH] fix(policy-prep): implement table editors and workflow improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add table editors for Policy Prep workflow - 'Add to policy' remains a placeholder - Apply planned tweaks: - Replace ballot checkbox with ✓ for selection - Relocate loading screen text to bottom: 'Building Path exclusions and publisher lists... This may take a moment for large datasets.' - Ensure interaction with all tables before allowing review steps - Move excessive logging to debug level - Add Step 0 to explain process before user begins Notes: Further discussion needed on enforcing table interaction before review. --- TUI/Screens/policyprepworkflowscreen.py | 1537 +++++++++++++++++++---- TUI/Widgets/prepPolicy.py | 48 +- services/agenthandler.py | 30 +- 3 files changed, 1305 insertions(+), 310 deletions(-) diff --git a/TUI/Screens/policyprepworkflowscreen.py b/TUI/Screens/policyprepworkflowscreen.py index 7035dc2..ceec0d6 100644 --- a/TUI/Screens/policyprepworkflowscreen.py +++ b/TUI/Screens/policyprepworkflowscreen.py @@ -17,6 +17,7 @@ import datetime import logging import os +import re from typing import Dict, List, Optional import pandas as pd @@ -80,12 +81,24 @@ class PolicyPrepWorkflowScreen(Screen): } #checklist_area { - max-height: 30%; - margin: 0 1 0 1; + 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 { @@ -103,11 +116,11 @@ class PolicyPrepWorkflowScreen(Screen): Binding("escape", "go_back", "Back"), Binding("q", "main_menu", "Main Menu"), Binding("f", "open_folder", "Open Folder"), - Binding("r", "refresh", "Refresh"), Binding("d", "delete_rows", "Delete 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 @@ -128,7 +141,7 @@ class PolicyPrepWorkflowScreen(Screen): 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 = "\\\\" # Default path split for Windows + self.path_split: str = "\\" # Path separator for Windows (single backslash) # Data storage self.approved_df: Optional[pd.DataFrame] = None @@ -142,6 +155,16 @@ class PolicyPrepWorkflowScreen(Screen): # 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 + def compose(self) -> ComposeResult: """Build the UI layout for the workflow screen.""" yield Header(show_clock=True, icon="⚙️") @@ -204,116 +227,126 @@ class PolicyPrepWorkflowScreen(Screen): # Checklist container with border checklist_container = Vertical() checklist_container.styles.border = ("round", "blue") - checklist_container.styles.margin = (1, 2) - checklist_container.styles.padding = 1 + checklist_container.styles.margin = (0, 1) + checklist_container.styles.padding = (0, 1) # Mount the container to the checklist area FIRST checklist.mount(checklist_container) - # NOW mount children to the container - checklist_title = Static("Preparation Checklist") + # 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.destination_policy else "✖️" - step1_text = f"{step1_status} Source Policies: " - if self.source_policies: - step1_text += ", ".join([p.name for p in self.source_policies[:3]]) - if len(self.source_policies) > 3: - step1_text += f" (+{len(self.source_policies)-3} more)" - else: - step1_text += "Not selected" - step1 = Static(step1_text) + 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" - checklist_container.mount(step1) + col1.mount(step1) # Step 2: Destination Policy - step2_status = "✔️" if self.destination_policy else "✖️" - step2_text = f"{step2_status} Destination Policy: " - step2_text += ( - self.destination_policy.name if self.destination_policy else "Not selected" - ) - step2 = Static(step2_text) + 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" - checklist_container.mount(step2) + col1.mount(step2) - # Step 3: Destination Allowlist - step3_status = "✔️" if self.destination_policy else "✖️" - step3_text = f"{step3_status} Allowlist: " - step3_text += ( - self.destination_allowlist.name - if self.destination_allowlist - else "Not selected" - ) - step3 = Static(step3_text) + # 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" - checklist_container.mount(step3) + 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 self.destination_policy else "✖️" - step4_text = f"{step4_status} Data Fetched: " + 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_text += f"{total} executions" + step4 = Static(f"{step4_status} Data ({total})") else: - step4_text += "Not fetched" - step4 = Static(step4_text) + step4 = Static(f"{step4_status} Data") if data_fetched: step4.styles.color = "green" else: step4.styles.text_style = "dim" - checklist_container.mount(step4) + col1.mount(step4) - # Step 5: First Review Complete + # 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) - else: - first_review_done = False - step5_status = "✔️" if self.destination_policy else "✖️" - step5_text = f"{step5_status} First Review: " - step5_text += "Complete" if first_review_done else "Pending" - step5 = Static(step5_text) + 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" - checklist_container.mount(step5) + col2.mount(step5) # Step 6: Paths Generated paths_generated = self.primary_paths_df is not None - step6_status = "✔️" if self.destination_policy else "✖️" - step6_text = f"{step6_status} Paths Generated: " + step6_status = "✓" if paths_generated else "✖" if paths_generated: - step6_text += f"{len(self.primary_paths_df)} primary paths" + step6 = Static(f"{step6_status} Paths ({len(self.primary_paths_df)})") else: - step6_text += "Not generated" - step6 = Static(step6_text) + step6 = Static(f"{step6_status} Paths") if paths_generated: step6.styles.color = "green" else: step6.styles.text_style = "dim" - checklist_container.mount(step6) + 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_source_policy_selection(self) -> None: """Show the source policy selection screen.""" @@ -335,8 +368,11 @@ class PolicyPrepWorkflowScreen(Screen): # 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 self.policies: + for policy in sorted_policies: # Skip parent policies if policy.parent == "global-policy-settings": continue @@ -393,8 +429,9 @@ class PolicyPrepWorkflowScreen(Screen): instruction.styles.margin = (0, 1, 1, 1) content.mount(instruction) - # Create policy selector with single-select - policy_selector = PolicySelector(self.policies) + # 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: @@ -425,6 +462,9 @@ class PolicyPrepWorkflowScreen(Screen): ) 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) @@ -455,9 +495,19 @@ class PolicyPrepWorkflowScreen(Screen): 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:" + "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) @@ -465,11 +515,17 @@ class PolicyPrepWorkflowScreen(Screen): # 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-150): ") - days_input = Input(value="150", id="history_days_input", type="integer") - days_input.styles.width = 20 + 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) @@ -500,22 +556,82 @@ class PolicyPrepWorkflowScreen(Screen): 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" - content = self.query_one("#content_area", Vertical) - content.remove_children() - status = Static("Fetching execution history...\nThis may take a few moments...") - status.styles.margin = (2, 1) - content.mount(status) + # Show notification that fetch is starting + self.app.notify( + "Starting data fetch - this may take several minutes for large policies", + severity="information", + timeout=5, + ) - # Perform fetch in background - self.call_later(lambda: self._perform_fetch(history_days)) + # 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, @@ -524,12 +640,25 @@ class PolicyPrepWorkflowScreen(Screen): 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 @@ -537,10 +666,16 @@ class PolicyPrepWorkflowScreen(Screen): ) # 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 self.approved_df = ( pd.DataFrame([r.__dict__ for r in approved]) @@ -559,9 +694,11 @@ class PolicyPrepWorkflowScreen(Screen): ) # 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: @@ -602,10 +739,21 @@ class PolicyPrepWorkflowScreen(Screen): def _show_fetch_results(self) -> None: """Show the results of data fetching.""" + logger.info("=== _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 = ( @@ -624,6 +772,8 @@ class PolicyPrepWorkflowScreen(Screen): 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) @@ -639,22 +789,19 @@ class PolicyPrepWorkflowScreen(Screen): tab_container.mount(approved_tab_btn) tab_container.mount(review_tab_btn) + logger.info("Mounted tab buttons") + # Show approved table by default + logger.info("About to call _show_review_table('approved')") self._show_review_table("approved") + logger.info("=== _show_fetch_results complete ===") def _show_review_table(self, table_type: str) -> None: """Show an editable DataTable for reviewing executions.""" + logger.info(f"=== _show_review_table called with type: {table_type} ===") content = self.query_one("#content_area", Vertical) - # Remove existing table if any - existing_table = content.query("DataTable") - for table in existing_table: - table.remove() - existing_controls = content.query("#review_controls") - for control in existing_controls: - control.remove() - - # Determine which dataframe to show + # Determine which dataframe and table ID to show if table_type == "approved": df = self.approved_df table_id = "approved_review_table" @@ -664,10 +811,54 @@ class PolicyPrepWorkflowScreen(Screen): 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 + 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 ALL existing DataTables to be safe + try: + existing_tables = content.query("DataTable") + logger.debug(f"Found {len(existing_tables)} existing tables to remove") + for table in existing_tables: + logger.debug(f"Removing table: {table.id}") + table.remove() + except Exception as e: + logger.debug(f"Error removing existing tables: {e}") + + # Force a refresh to ensure removals are processed + try: + content.refresh() + 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.info( + 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 @@ -678,8 +869,8 @@ class PolicyPrepWorkflowScreen(Screen): # Help text help_text = Static( - "Use arrows to navigate, SPACE to select/deselect rows, 'd' to delete selected rows\n" - "Selected rows will be highlighted and removed from the approved list" + "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" + "Space to toggle cursor row, 'd' to delete, 'a' select all, arrows navigate" ) help_text.styles.margin = (0, 1, 1, 1) help_text.styles.text_style = "dim" @@ -687,18 +878,22 @@ class PolicyPrepWorkflowScreen(Screen): # Create the review table review_table = DataTable(id=table_id) - review_table.styles.height = "50vh" + review_table.styles.height = "40vh" # Increased since we removed button rows review_table.cursor_type = "row" review_table.zebra_stripes = True - # Add columns - checkbox first, then important fields + # Specified columns in order important_cols = [ - "filename", - "publisher", - "sha256", - "filepath", + "policyname", + "policyver", "hostname", - "datetime", + "username", + "publisher", + "filename", + "pprocess", + "gprocess", + "sha256", + "commandline", ] available_cols = [col for col in important_cols if col in df.columns] @@ -712,37 +907,49 @@ class PolicyPrepWorkflowScreen(Screen): row_data = [str(row.get(col, "")) for col in available_cols] review_table.add_row(checkbox, *row_data, key=str(idx)) + logger.info(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 after removal attempts! Forcing removal..." + ) + final_check.remove() + content.refresh() + except Exception: + # Good - table doesn't exist + pass + content.mount(review_table) + logger.info(f"Successfully mounted {table_id} with {len(df)} rows") - # Control buttons - control_container = Horizontal(id="review_controls") - control_container.styles.margin = (1, 1) - content.mount(control_container) - - select_all_btn = Button("Select All", id="select_all_rows") - select_all_btn.styles.margin = (0, 1, 0, 0) - - select_none_btn = Button("Clear Selection", id="select_none_rows") - select_none_btn.styles.margin = (0, 1, 0, 0) - - delete_btn = Button( - "Delete Selected", id="delete_selected_rows", variant="error" - ) - delete_btn.styles.margin = (0, 1, 0, 0) + # 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, 2) + row_count.styles.margin = (0, 1, 0, 1) - control_container.mount(select_all_btn) - control_container.mount(select_none_btn) - control_container.mount(delete_btn) 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, 0, 1) - continue_container.styles.dock = "bottom" + 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) @@ -778,14 +985,26 @@ class PolicyPrepWorkflowScreen(Screen): # 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 indices to keep (not in selected rows) + # 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 @@ -794,26 +1013,56 @@ class PolicyPrepWorkflowScreen(Screen): 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() - # Refresh the table - self._show_review_table(self.current_review_type) + # 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") - self.app.notify( - f"Deleted {len(indices_to_delete)} rows", severity="information" - ) - - def _build_paths_and_publishers(self) -> None: - """Build path exclusions and publisher lists.""" - self.workflow_stage = "build_paths" + 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() - status = Static("Building path exclusions and publisher lists...") + # Clear message + status = Static( + "Building path exclusions and publisher lists...\n\n" + "This may take a moment for large datasets." + ) status.styles.margin = (2, 1) + status.styles.text_align = "center" 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: @@ -868,17 +1117,37 @@ class PolicyPrepWorkflowScreen(Screen): ) # Secondary paths - remaining = all_approved[ - ~all_approved["sha256"].isin(self.primary_paths_df["sha256"]) - ] - self.secondary_paths_df = self._calculate_paths( - remaining, path_exclusion_const - 1 - ) + 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 - self.remaining_hashes_df = remaining[ - ~remaining["sha256"].isin(self.secondary_paths_df["sha256"]) - ] + 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: @@ -909,28 +1178,236 @@ class PolicyPrepWorkflowScreen(Screen): self.app.notify(f"Failed to build paths: {str(e)}", severity="error") self._show_fetch_results() - def _calculate_paths(self, df: pd.DataFrame, depth: int) -> pd.DataFrame: - """Calculate path exclusions at specified depth.""" - # Simplified path calculation - in production this would be more complex - if df.empty or "filepath" not in df.columns: + 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.info(f"Input DataFrame: {len(df)} rows") + logger.info(f"Columns: {list(df.columns) if not df.empty else 'empty'}") + + if df.empty: + logger.warning("Input DataFrame is empty") return pd.DataFrame() - paths = [] - for filepath in df["filepath"].unique(): - if pd.isna(filepath): - continue - parts = filepath.split(self.path_split) - if len(parts) > depth: - truncated = self.path_split.join(parts[:depth]) - paths.append(truncated) + # 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() - # Create dataframe with unique paths - if paths: - path_df = pd.DataFrame({"longestcfp": list(set(paths))}) - # Add mock sha256 for compatibility - path_df["sha256"] = path_df.index.astype(str) - return path_df - return pd.DataFrame() + # Split filepaths and extract metadata + haslcp = self._split_filepaths_grouped(df, path_exclusion_constant, "filename") + haslcp = haslcp.drop_duplicates() + + logger.info(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.info( + 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.info(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.info( + 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.info( + 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.""" @@ -964,6 +1441,7 @@ class PolicyPrepWorkflowScreen(Screen): def _show_path_results(self) -> None: """Show the results of path building.""" + logger.info("=== _show_path_results called ===") self.workflow_stage = "second_review" content = self.query_one("#content_area", Vertical) content.remove_children() @@ -979,6 +1457,10 @@ class PolicyPrepWorkflowScreen(Screen): 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" @@ -987,6 +1469,7 @@ class PolicyPrepWorkflowScreen(Screen): ) summary.styles.margin = (1, 1) content.mount(summary) + logger.info("Mounted summary widget") # Tab selection for different review types tab_container = Horizontal() @@ -1004,22 +1487,18 @@ class PolicyPrepWorkflowScreen(Screen): 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.info("About to call _show_path_review_table('paths')") self._show_path_review_table("paths") + logger.info("=== _show_path_results complete ===") def _show_path_review_table(self, table_type: str) -> None: """Show an editable DataTable for reviewing paths/publishers.""" + logger.info(f"=== _show_path_review_table called with type: {table_type} ===") content = self.query_one("#content_area", Vertical) - # Remove existing table if any - existing_table = content.query("DataTable") - for table in existing_table: - table.remove() - existing_controls = content.query("#path_review_controls") - for control in existing_controls: - control.remove() - # Determine which dataframe to show if table_type == "paths": # Combine primary and secondary paths for review @@ -1038,12 +1517,51 @@ class PolicyPrepWorkflowScreen(Screen): 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:" - columns = ["longestcfp", "type"] if not df.empty else [] + # 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 @@ -1061,6 +1579,41 @@ class PolicyPrepWorkflowScreen(Screen): 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}") + + # 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) @@ -1076,8 +1629,8 @@ class PolicyPrepWorkflowScreen(Screen): # Help text (different for remaining hashes) if table_type != "remaining": help_text = Static( - "Use arrows to navigate, SPACE to select/deselect rows, 'd' to delete selected rows\n" - "Selected items will be removed from the final approval list" + "Click to toggle, 'r' for range select (click start, press 'r', click end)\n" + "Space to toggle cursor row, 'd' to delete, 'a' select all, arrows navigate" ) else: help_text = Static( @@ -1090,7 +1643,7 @@ class PolicyPrepWorkflowScreen(Screen): # Create the review table review_table = DataTable(id=table_id) - review_table.styles.height = "40vh" + review_table.styles.height = "35vh" # Increased since we removed button rows review_table.cursor_type = "row" review_table.zebra_stripes = True @@ -1105,42 +1658,91 @@ class PolicyPrepWorkflowScreen(Screen): # 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] + 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.info(f"About to mount {table_id}") + + # Final safety check before mounting table + try: + final_check = content.query_one(f"#{table_id}", DataTable) + if final_check: + logger.warning(f"Table {table_id} still exists! Forcing removal...") + final_check.remove() + content.refresh() + except Exception: + pass + content.mount(review_table) + logger.info(f"Successfully mounted {table_id}") - # Control buttons (not for remaining hashes view) + # Row count display only (removed Select All, Clear, Delete buttons) if table_type != "remaining": - control_container = Horizontal(id="path_review_controls") - control_container.styles.margin = (1, 1) - content.mount(control_container) - - select_all_btn = Button("Select All", id="select_all_path_rows") - select_all_btn.styles.margin = (0, 1, 0, 0) - - select_none_btn = Button("Clear Selection", id="select_none_path_rows") - select_none_btn.styles.margin = (0, 1, 0, 0) - - delete_btn = Button( - "Delete Selected", id="delete_selected_path_rows", variant="error" - ) - delete_btn.styles.margin = (0, 1, 0, 0) + # 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, 2) + row_count.styles.margin = (0, 1, 0, 1) - control_container.mount(select_all_btn) - control_container.mount(select_none_btn) - control_container.mount(delete_btn) control_container.mount(row_count) # Continue button (always at bottom) - if not content.query("#path_continue_container"): - continue_container = Horizontal(id="path_continue_container") - continue_container.styles.margin = (2, 1, 0, 1) - continue_container.styles.dock = "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) @@ -1150,7 +1752,6 @@ class PolicyPrepWorkflowScreen(Screen): continue_container.mount(export_btn) continue_container.mount(continue_btn) - content.mount(continue_container) # Track selected rows if not hasattr(self, "selected_path_rows"): @@ -1169,6 +1770,22 @@ class PolicyPrepWorkflowScreen(Screen): 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 @@ -1198,12 +1815,16 @@ class PolicyPrepWorkflowScreen(Screen): 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() - # Refresh the table - self._show_path_review_table(self.current_path_review_type) - self.app.notify( f"Deleted {len(indices_to_delete)} items", severity="information" ) @@ -1219,48 +1840,81 @@ class PolicyPrepWorkflowScreen(Screen): self.app.notify(f"Failed to build preflight: {str(e)}", severity="error") def _show_test_screen(self) -> None: - """Show the test/preview screen.""" + """Show the test/preview screen with detailed path listings.""" self.workflow_stage = "test" content = self.query_one("#content_area", Vertical) content.remove_children() summary = Static( - "Test Mode - Preview Changes\n\n" "The following changes will be applied:\n" + "Test Mode - Preview Changes\n\n" + "Review the paths that will be added to your policy:" ) summary.styles.margin = (1, 1) + summary.styles.text_style = "bold" content.mount(summary) - # Show what would be changed - changes_text = "" - + # Policy and Allowlist info + info_text = "" if self.destination_policy: - changes_text += f"Policy: {self.destination_policy.name}\n" - - if self.primary_paths_df is not None and not self.primary_paths_df.empty: - changes_text += f"Add {len(self.primary_paths_df)} path exclusions\n" - - if self.publishers_df is not None and not self.publishers_df.empty: - changes_text += f"Add {len(self.publishers_df)} approved publishers\n" - + info_text += f"📋 Policy: {self.destination_policy.name}\n" if self.destination_allowlist: - changes_text += f"\nAllowlist: {self.destination_allowlist.name}\n" + 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: - changes_text += f"Add {len(self.approved_df)} approved hashes\n" - - changes = Static(changes_text) - changes.styles.margin = (0, 2) - changes.styles.border = ("round", "cyan") - changes.styles.padding = 1 - content.mount(changes) + hash_info = Static( + f"\n🔐 Individual Hash Approvals: {len(self.approved_df):,} hashes\n" + f" (Files not covered by paths or publishers)" + ) + hash_info.styles.margin = (1, 1) + content.mount(hash_info) # Warning warning = Static( - " ⚠️Warning: These changes cannot be easily undone.⚠️\n" - "Please review carefully before proceeding." + "\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 @@ -1268,10 +1922,67 @@ class PolicyPrepWorkflowScreen(Screen): button_container.styles.margin = (2, 1) content.mount(button_container) - liftoff_btn = Button("Liftoff - Apply Changes", id="liftoff", variant="success") + 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" @@ -1361,37 +2072,134 @@ class PolicyPrepWorkflowScreen(Screen): # Update checkboxes in place without rebuilding the table 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 - checkbox = "☑️" if str(row_key) in selected_keys else "☐" + 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.debug(f"Could not update cell at row {row_index}: {e}") + logger.error(f"Could not update cell at row {row_index}: {e}") row_index += 1 except Exception as e: - logger.debug(f"Error refreshing table {table_id}: {e}") + logger.error(f"Error refreshing table {table_id}: {e}", exc_info=True) - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - """Handle row selection in data tables.""" + 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": - # Use cursor_row for reliable row index - row_index = table.cursor_row - # Get the row key from the table - row_keys = list(table.rows.keys()) - if row_index < len(row_keys): - row_key = str(row_keys[row_index]) + 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) - # Refresh checkbox display self._refresh_table_checkboxes( table.id, self.selected_source_policy_ids ) @@ -1400,8 +2208,86 @@ class PolicyPrepWorkflowScreen(Screen): timeout=1, ) - # Handle allowlist selection - elif table.id == "allowlist_table": + # 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): @@ -1409,39 +2295,21 @@ class PolicyPrepWorkflowScreen(Screen): logger.info(f"Selected allowlist: {self.destination_allowlist.name}") self._show_fetch_data() - # Handle review table selections (toggle selection) - elif table.id in ["approved_review_table", "needs_review_table"]: - # Use cursor_row for reliable row index - row_index = table.cursor_row - # Get the row key from the table - row_keys = list(table.rows.keys()) - if row_index < len(row_keys): - row_key = str(row_keys[row_index]) - if row_key in self.selected_rows: - self.selected_rows.remove(row_key) + 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.selected_rows.add(row_key) - # Refresh checkbox display - self._refresh_table_checkboxes(table.id, self.selected_rows) - self.app.notify(f"Selected {len(self.selected_rows)} rows", timeout=1) - - # Handle path/publisher review selections - elif table.id in ["paths_review_table", "publishers_review_table"]: - # Use cursor_row for reliable row index - row_index = table.cursor_row - # Get the row key from the table - row_keys = list(table.rows.keys()) - if row_index < len(row_keys): - row_key = str(row_keys[row_index]) - if row_key in self.selected_path_rows: - self.selected_path_rows.remove(row_key) - else: - self.selected_path_rows.add(row_key) - # Refresh checkbox display - self._refresh_table_checkboxes(table.id, self.selected_path_rows) - self.app.notify( - f"Selected {len(self.selected_path_rows)} items", timeout=1 - ) + 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.""" @@ -1471,7 +2339,18 @@ class PolicyPrepWorkflowScreen(Screen): logger.info( f"Selected source policies: {[p.name for p in self.source_policies]}" ) - self._show_destination_policy_selection() + 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 elif button_id == "show_approved_tab": @@ -1521,12 +2400,12 @@ class PolicyPrepWorkflowScreen(Screen): try: days_input = self.query_one("#history_days_input", Input) history_days = int(days_input.value) - if 1 <= history_days <= 150: + 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 150", severity="warning" + "Please enter a value between 1 and 365", severity="warning" ) except (ValueError, TypeError): self.app.notify("Please enter a valid number", severity="warning") @@ -1566,7 +2445,8 @@ class PolicyPrepWorkflowScreen(Screen): else: # Save the reviewed data before continuing self._save_reviewed_data() - self._build_paths_and_publishers() + # Show loading screen then build paths + self._show_path_building_screen() elif button_id == "build_preflight": # Validate that path review is complete @@ -1579,6 +2459,10 @@ class PolicyPrepWorkflowScreen(Screen): else: 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") @@ -1599,7 +2483,8 @@ class PolicyPrepWorkflowScreen(Screen): df = self.needs_review_df if table_id and df is not None: - self.selected_rows = set(str(i) for i in range(len(df))) + # 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) @@ -1621,6 +2506,7 @@ class PolicyPrepWorkflowScreen(Screen): 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) @@ -1632,9 +2518,8 @@ class PolicyPrepWorkflowScreen(Screen): and self.publishers_df is not None ): table_id = "publishers_review_table" - self.selected_path_rows = set( - str(i) for i in range(len(self.publishers_df)) - ) + # 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: @@ -1703,6 +2588,47 @@ class PolicyPrepWorkflowScreen(Screen): 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: @@ -1792,10 +2718,6 @@ class PolicyPrepWorkflowScreen(Screen): """Open the working directory.""" self._open_folder(self.working_dir) - def action_refresh(self) -> None: - """Refresh the current view.""" - self._update_checklist() - def action_delete_rows(self) -> None: """Delete selected rows in the current table.""" if self.workflow_stage == "first_review": @@ -1818,5 +2740,78 @@ class PolicyPrepWorkflowScreen(Screen): self._select_none_path_rows() def action_toggle_selection(self) -> None: - """Toggle selection on the current row (handled by on_data_table_row_selected).""" - pass # This is handled directly in the DataTable event + """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/Widgets/prepPolicy.py b/TUI/Widgets/prepPolicy.py index f3448a0..87e2a41 100644 --- a/TUI/Widgets/prepPolicy.py +++ b/TUI/Widgets/prepPolicy.py @@ -88,9 +88,9 @@ 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-365): ", value_type=int, - valid_range=(1, 150), + valid_range=(1, 365), ) logger.debug(f"{history_days} day selected for history") @@ -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/services/agenthandler.py b/services/agenthandler.py index 6d6d250..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: @@ -60,7 +60,7 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool): except Exception as e: print( colorText( - f"❌ Error retrieving history for {agent.hostname}: {e}", "red" + f"❌ Error retrieving history for {agent.hostname}: {e}", "red" ) ) continue @@ -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", ) ) @@ -235,8 +235,8 @@ def show_unmatched( ] if unmatched: - logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") - print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) + logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") + print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) def enrich_agents(agents: List["Agent"], policies: List["Policy"]): @@ -248,7 +248,7 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: device_names = collect_device_names() if not device_names: logger.debug("No device names entered") - print(colorText("⚠️ No device names entered.", "red")) + print(colorText("⚠️ No device names entered.", "red")) return [] use_exact = choose_match_type() @@ -261,11 +261,11 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: show_unmatched(device_names, matched_agents, use_exact) if not matched_agents: - logger.debug("❌ No matching devices found.") - print(colorText("❌ No matching devices found.", "red")) + logger.debug("❌ No matching devices found.") + 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): @@ -283,8 +283,8 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]: ) if not matched_agents: - logger.debug("❌ No matching devices remain after refinement.") - print(colorText("❌ No matching devices remain after refinement.", "red")) + logger.debug("❌ No matching devices remain after refinement.") + print(colorText("❌ No matching devices remain after refinement.", "red")) return [] enrich_agents(matched_agents, policies) @@ -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", "{}")