Files
AirlockTools/TUI/Screens/policyprepworkflowscreen.py
T

1823 lines
68 KiB
Python

# Copyright (C) 2025 James Brotosky, Brandon Wickline
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import logging
import os
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
from utils.utils import formatHTML
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: $primary 30%;
}
DataTable:focus > .datatable--cursor {
background: $secondary 20%;
}
#workflow_title {
text-style: bold;
color: $text;
}
#workflow_status {
color: $accent;
}
#checklist_area {
max-height: 30%;
margin: 0 1 0 1;
}
#content_area {
height: 1fr;
}
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("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),
]
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 = "\\\\" # Default path split for Windows
# 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
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_source_policy_selection()
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 = {
"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": "Fetching and sorting execution data...",
"first_review": "Step 5: First Manual Review",
"build_paths": "Step 6: Building Path Exclusions",
"second_review": "Step 7: Second Manual Review",
"test": "Step 8: Test - Preview Changes",
"liftoff": "Step 9: Liftoff - Apply Changes",
"complete": "Workflow Complete",
}
status_widget.update(stage_messages.get(self.workflow_stage, "Unknown Stage"))
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 = (1, 2)
checklist_container.styles.padding = 1
# Mount the container to the checklist area FIRST
checklist.mount(checklist_container)
# NOW mount children to the container
checklist_title = Static("Preparation Checklist")
checklist_title.styles.text_style = "bold"
checklist_container.mount(checklist_title)
# 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)
if self.source_policies:
step1.styles.color = "green"
else:
step1.styles.text_style = "dim"
checklist_container.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)
if self.destination_policy:
step2.styles.color = "green"
else:
step2.styles.text_style = "dim"
checklist_container.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)
if self.destination_allowlist:
step3.styles.color = "green"
else:
step3.styles.text_style = "dim"
checklist_container.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: "
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"
else:
step4_text += "Not fetched"
step4 = Static(step4_text)
if data_fetched:
step4.styles.color = "green"
else:
step4.styles.text_style = "dim"
checklist_container.mount(step4)
# Step 5: First Review Complete
first_review_path = os.path.join(self.working_dir, "Approved")
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)
if first_review_done:
step5.styles.color = "green"
else:
step5.styles.text_style = "dim"
checklist_container.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: "
if paths_generated:
step6_text += f"{len(self.primary_paths_df)} primary paths"
else:
step6_text += "Not generated"
step6 = Static(step6_text)
if paths_generated:
step6.styles.color = "green"
else:
step6.styles.text_style = "dim"
checklist_container.mount(step6)
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")
# Add rows
for policy in self.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)
# 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 single-select
policy_selector = PolicySelector(self.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
# 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()
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:"
)
instruction.styles.margin = (0, 1, 1, 1)
content.mount(instruction)
# Days input
days_container = Horizontal()
days_container.styles.margin = (1, 1)
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_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)
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)
# Perform fetch in background
self.call_later(lambda: self._perform_fetch(history_days))
def _perform_fetch(self, history_days: int) -> None:
"""Perform the actual data fetching."""
try:
# Fetch execution history
policy_executions = ExecutionHistoryRecord.from_policies(
self.api,
self.source_policies,
type_=[1, 2, 6, 7],
history_days=history_days,
)
# Enrich with hash data
enriched_executions = ExecutionHistoryRecord.enrich_with_hashes(
self.api, policy_executions
)
# Categorize by hash decision
categorized_executions = (
ExecutionHistoryRecord.categorize_executions_by_hash_decision(
enriched_executions
)
)
# Sort by decision
approved, unapproved, needs_review, unknown = (
ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions)
)
# Store the data
self.approved_df = (
pd.DataFrame([r.__dict__ for r in approved])
if approved
else pd.DataFrame()
)
self.unapproved_df = (
pd.DataFrame([r.__dict__ for r in unapproved])
if unapproved
else pd.DataFrame()
)
self.needs_review_df = (
pd.DataFrame([r.__dict__ for r in needs_review])
if needs_review
else pd.DataFrame()
)
# Save to files
self._save_fetched_data()
# Show 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 _save_fetched_data(self) -> None:
"""Save fetched data to CSV and HTML 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")
html_dir = os.path.join(self.working_dir, "Needs_Review", "HTML")
os.makedirs(review_dir, exist_ok=True)
os.makedirs(html_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"
)
html_path = os.path.join(html_dir, f"{policy_name}_{label}.html")
df.to_csv(csv_path, index=False)
formatHTML(df, html_path)
logger.info(f"Saved {label} executions to {csv_path}")
def _show_fetch_results(self) -> None:
"""Show the results of data fetching."""
self.workflow_stage = "first_review"
content = self.query_one("#content_area", Vertical)
content.remove_children()
# 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)
# 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)
# Show approved table by default
self._show_review_table("approved")
def _show_review_table(self, table_type: str) -> None:
"""Show an editable DataTable for reviewing executions."""
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
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:"
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)
return
# Instructions
instruction = Static(title)
instruction.styles.margin = (1, 1)
instruction.styles.text_style = "bold"
content.mount(instruction)
# 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"
)
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 = "50vh"
review_table.cursor_type = "row"
review_table.zebra_stripes = True
# Add columns - checkbox first, then important fields
important_cols = [
"filename",
"publisher",
"sha256",
"filepath",
"hostname",
"datetime",
]
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))
content.mount(review_table)
# 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 = Static(f"Total rows: {len(df)}")
row_count.styles.margin = (0, 1, 0, 2)
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"
# 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
else:
df = self.needs_review_df
if df is None:
return
# Get indices to keep (not in selected rows)
indices_to_delete = [int(idx) for idx in self.selected_rows]
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
# Clear selection
self.selected_rows.clear()
# Refresh the table
self._show_review_table(self.current_review_type)
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"
content = self.query_one("#content_area", Vertical)
content.remove_children()
status = Static("Building path exclusions and publisher lists...")
status.styles.margin = (2, 1)
content.mount(status)
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")
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",
)
# Load approved files
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
remaining = all_approved[
~all_approved["sha256"].isin(self.primary_paths_df["sha256"])
]
self.secondary_paths_df = self._calculate_paths(
remaining, path_exclusion_const - 1
)
# Remaining hashes
self.remaining_hashes_df = remaining[
~remaining["sha256"].isin(self.secondary_paths_df["sha256"])
]
# 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
# 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 _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:
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)
# 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()
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")
html_dir = os.path.join(self.working_dir, "Needs_Review", "HTML")
os.makedirs(review_dir, exist_ok=True)
os.makedirs(html_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")
html_path = os.path.join(html_dir, f"{policy_name}_{name}.html")
df.to_csv(csv_path, index=False)
formatHTML(df, html_path)
logger.info(f"Saved {name} to {csv_path}")
def _show_path_results(self) -> None:
"""Show the results of path building."""
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
)
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)
# 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)
# Show paths table by default
self._show_path_review_table("paths")
def _show_path_review_table(self, table_type: str) -> None:
"""Show an editable DataTable for reviewing paths/publishers."""
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
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)
else:
df = pd.DataFrame()
table_id = "paths_review_table"
title = "Path Exclusions - Select paths to REMOVE:"
columns = ["longestcfp", "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 []
)
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
instruction = Static(title)
instruction.styles.margin = (1, 1)
instruction.styles.text_style = "bold"
content.mount(instruction)
# 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"
)
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 = "40vh"
review_table.cursor_type = "row"
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 = [str(row.get(col, "")) for col in available_cols]
review_table.add_row(checkbox, *row_data, key=str(idx))
content.mount(review_table)
# Control buttons (not for remaining hashes view)
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)
row_count = Static(f"Total items: {len(df)}")
row_count.styles.margin = (0, 1, 0, 2)
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"
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)
content.mount(continue_container)
# 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]
# 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"
)
# 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"
)
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."""
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"
)
summary.styles.margin = (1, 1)
content.mount(summary)
# Show what would be changed
changes_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"
if self.destination_allowlist:
changes_text += f"\nAllowlist: {self.destination_allowlist.name}\n"
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)
# Warning
warning = Static(
" ⚠️Warning: These changes cannot be easily undone.⚠️\n"
"Please review carefully before proceeding."
)
warning.styles.margin = (1, 1)
warning.styles.color = "yellow"
content.mount(warning)
# Buttons
button_container = Horizontal()
button_container.styles.margin = (2, 1)
content.mount(button_container)
liftoff_btn = Button("Liftoff - Apply Changes", id="liftoff", variant="success")
button_container.mount(liftoff_btn)
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 = []
# Apply path exclusions to policy
if self.destination_policy and self.primary_paths_df is not None:
# This would call the actual API methods
results.append("Applied path exclusions to policy")
# Apply publishers to policy
if self.destination_policy and self.publishers_df is not None:
# This would call the actual API methods
results.append("Applied approved publishers to policy")
# Apply hashes to allowlist
if self.destination_allowlist and self.approved_df is not None:
# This would call the actual API methods
results.append("Applied approved hashes to allowlist")
self._show_completion(results)
except Exception as e:
logger.error(f"Failed to apply changes: {e}", exc_info=True)
self.app.notify(f"Failed to apply changes: {str(e)}", severity="error")
self._show_test_screen()
def _show_completion(self, results: List[str]) -> None:
"""Show completion screen."""
self.workflow_stage = "complete"
content = self.query_one("#content_area", Vertical)
content.remove_children()
summary = Static(
"Policy Preparation Complete!\n\n"
"The following changes have been applied:"
)
summary.styles.margin = (1, 1)
content.mount(summary)
for result in results:
result_widget = Static(f" {result}")
result_widget.styles.margin = (0, 2)
content.mount(result_widget)
# Final message
final = Static(
f"\nPolicy '{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 in place without rebuilding the table
row_index = 0
for row_key in table.rows.keys():
# Determine if this row should be checked
checkbox = "☑️" if str(row_key) in selected_keys 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}")
row_index += 1
except Exception as e:
logger.debug(f"Error refreshing table {table_id}: {e}")
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection in data tables."""
table = event.data_table
# 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 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
)
self.app.notify(
f"Selected {len(self.selected_source_policy_ids)} policies",
timeout=1,
)
# Handle allowlist selection
elif 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()
# 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)
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
)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
button_id = event.button.id
# Source policy selection buttons
if 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]}"
)
self._show_destination_policy_selection()
# Tab switching buttons
elif button_id == "show_approved_tab":
self._show_review_table("approved")
elif button_id == "show_needs_review_tab":
self._show_review_table("needs_review")
elif button_id == "show_paths_tab":
self._show_path_review_table("paths")
elif button_id == "show_publishers_tab":
self._show_path_review_table("publishers")
elif button_id == "show_remaining_tab":
self._show_path_review_table("remaining")
# 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 <= 150:
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"
)
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":
# 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
self._save_reviewed_data()
self._build_paths_and_publishers()
elif button_id == "build_preflight":
# Validate that path review is complete
if (self.primary_paths_df is None or self.primary_paths_df.empty) and (
self.publishers_df is None or self.publishers_df.empty
):
self.app.notify(
"No paths or publishers to build preflight with!", severity="error"
)
else:
self._build_preflight()
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:
self.selected_rows = set(str(i) for i in range(len(df)))
# 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"
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"
self.selected_path_rows = set(
str(i) for i in range(len(self.publishers_df))
)
# 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 _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": self._show_fetch_data,
"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_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":
self._delete_selected_rows()
elif self.workflow_stage == "second_review":
self._delete_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 (handled by on_data_table_row_selected)."""
pass # This is handled directly in the DataTable event