Config Refactor: unify config handling
Build Library / Build Library (push) Failing after 5m55s

- Consolidated all system/user config logic into configmanager.py
- Removed duplicate loaders from setup.py and TUI.py
- Eliminated .env redundancy; now only stores WORKING_DIR
- Clarified boundaries: system config immutable, user config mutable
- Updated TUI to use save_user_config()
- Removed all deprecated/legacy config functions and aliases
This commit is contained in:
2025-11-17 12:00:32 -05:00
parent b198362ac8
commit 89654d3a8c
13 changed files with 432 additions and 417 deletions
+15 -42
View File
@@ -4,7 +4,6 @@ import sys
from typing import Optional from typing import Optional
import dotenv import dotenv
from dotenv import set_key
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Vertical from textual.containers import Vertical
from textual.message import Message from textual.message import Message
@@ -38,8 +37,8 @@ from TUI.resultsdisplay import ResultsDisplay
from TUI.theme_amber_terminal import get_amber_terminal_theme from TUI.theme_amber_terminal import get_amber_terminal_theme
from TUI.theme_retro_terminal import get_retro_terminal_theme from TUI.theme_retro_terminal import get_retro_terminal_theme
from TUI.themeselector import ThemeSelector from TUI.themeselector import ThemeSelector
from utils.configmanager import load_env from utils.configmanager import get_user_value, load_env, save_user_config
from utils.setup import get_base_directory, load_user_config from utils.setup import get_base_directory
from utils.utils import open_directory from utils.utils import open_directory
dotenv.load_dotenv() dotenv.load_dotenv()
@@ -58,47 +57,17 @@ logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _persist_user_theme(theme_name: str) -> None: def _persist_user_theme(theme_name: str) -> None:
""" """
Store the chosen Textual theme in the user's config: Store the chosen Textual theme in the user's config using the config manager.
<base>/config/user_config.json No need to touch .env - config manager handles everything.
and also mirror to <base>/.env so load_env(...) sees it.
""" """
base_dir = get_base_directory() base_dir = get_base_directory()
config_dir = base_dir / "config" config_dir = base_dir / "config"
user_config_path = config_dir / "user_config.json"
env_path = base_dir / ".env"
# ensure dirs / files exist similarly to setup()
config_dir.mkdir(parents=True, exist_ok=True)
if not user_config_path.exists():
# minimal default like your load_user_config does
user_config_path.write_text(
'{"URL": "", "LOG_LEVEL": "INFO"}\n', encoding="utf-8"
)
# load existing user config
user_conf = load_user_config(config_dir)
user_conf["TEXTUAL_THEME"] = theme_name
# write it back
user_config_path.write_text(
# pretty print so it stays human-readable
__import__("json").dumps(user_conf, indent=4),
encoding="utf-8",
)
logger.debug("Updated user_config.json with TEXTUAL_THEME=%s", theme_name)
# mirror to .env (like setup.write_config_to_env does)
env_path.parent.mkdir(parents=True, exist_ok=True)
if not env_path.exists():
env_path.touch()
try: try:
set_key(str(env_path), "TEXTUAL_THEME", theme_name) save_user_config(config_dir, {"TEXTUAL_THEME": theme_name})
except Exception as exc: # keep going even if .env write fails logger.debug("Updated user config with TEXTUAL_THEME=%s", theme_name)
logger.warning("Failed to mirror TEXTUAL_THEME to .env: %s", exc) except Exception as exc:
logger.error("Failed to save TEXTUAL_THEME: %s", exc)
# reload so load_env(...) sees the new value right now
dotenv.load_dotenv(dotenv_path=env_path, override=True)
logger.debug("Reloaded .env from %s", env_path)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -115,7 +84,7 @@ class MainMenuScreen(Screen):
"move_agent_workflow_button", "move_agent_workflow_button",
), ),
("📊 - Review and appove OTP Activities", "otp_activities_button"), ("📊 - Review and appove OTP Activities", "otp_activities_button"),
("🔇 - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"), ("📇 - Find and Move Quiet Hosts to Enforcement", "find_quiet_button"),
], ],
"policy": [ "policy": [
("🔒 - Prepare Policy For Enforcement", "policy_prep_button"), ("🔒 - Prepare Policy For Enforcement", "policy_prep_button"),
@@ -126,7 +95,7 @@ class MainMenuScreen(Screen):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.extras = load_env("EXTRAS") self.extras = get_user_value("EXTRAS", str, "NOTTODAY")
wd = load_env("WORKING_DIR") or os.getcwd() wd = load_env("WORKING_DIR") or os.getcwd()
if not os.path.isdir(wd): if not os.path.isdir(wd):
wd = os.getcwd() wd = os.getcwd()
@@ -380,10 +349,11 @@ class Loxide(App[Message]):
BINDINGS = [ BINDINGS = [
("q", "quit", "Quit"), ("q", "quit", "Quit"),
("f", "open_fe", "Launch Explorer"), ("f", "open_fe", "Launch Explorer"),
("r", "refresh", "Refresh"),
] ]
def __init__(self, api: AirlockAPIWrapper): def __init__(self, api: AirlockAPIWrapper):
self._textual_theme = load_env("TEXTUAL_THEME") or "nord" self._textual_theme = get_user_value("TEXTUAL_THEME", str, "nord")
super().__init__() super().__init__()
self.api = api self.api = api
wd = load_env("WORKING_DIR") or os.getcwd() wd = load_env("WORKING_DIR") or os.getcwd()
@@ -421,6 +391,9 @@ class Loxide(App[Message]):
self.theme = self._textual_theme self.theme = self._textual_theme
self.push_screen(MainMenuScreen()) self.push_screen(MainMenuScreen())
def action_refresh(self) -> None:
self.refresh_data()
def action_quit(self) -> None: def action_quit(self) -> None:
global _PENDING_JOB global _PENDING_JOB
_PENDING_JOB = None _PENDING_JOB = None
+26 -26
View File
@@ -183,21 +183,21 @@ class AgentMoveOperations(Widget):
f"Operation: {operation_name}", f"Operation: {operation_name}",
f"{'=' * 50}", f"{'=' * 50}",
"", "",
f" Successful ({len(successful)}):", f"✅ Successful ({len(successful)}):",
] ]
if successful: if successful:
for agent, result in successful: for agent, result in successful:
results_lines.append(f" {agent.hostname}") results_lines.append(f" ✅ {agent.hostname}")
else: else:
results_lines.append(" (none)") results_lines.append(" (none)")
results_lines.append("") results_lines.append("")
results_lines.append(f" Failed ({len(unsuccessful)}):") results_lines.append(f"❌ Failed ({len(unsuccessful)}):")
if unsuccessful: if unsuccessful:
for agent, error in unsuccessful: for agent, error in unsuccessful:
results_lines.append(f" {agent.hostname}: {error}") results_lines.append(f" ❌ {agent.hostname}: {error}")
else: else:
results_lines.append(" (none)") results_lines.append(" (none)")
@@ -232,9 +232,9 @@ class AgentMoveOperations(Widget):
- Operations panel: 1/3 width - Operations panel: 1/3 width
- Results area: Initially hidden, shown after operation completion - Results area: Initially hidden, shown after operation completion
""" """
yield Header(show_clock=True, icon="") yield Header(show_clock=True, icon="âš™")
title_text = Static( title_text = Static(
f"🖥️ Agent Operations - {len(self.agents)} device(s) selected", f"🖥️ Agent Operations - {len(self.agents)} device(s) selected",
id="move_ops_title", id="move_ops_title",
) )
title_text.styles.margin = (0, 0, 1, 0) title_text.styles.margin = (0, 0, 1, 0)
@@ -269,32 +269,32 @@ class AgentMoveOperations(Widget):
yield operations_label yield operations_label
# Operation buttons # Operation buttons
export_csv_btn = Button("📈 Export CSV", id="export_csv_btn") export_csv_btn = Button("📈 Export CSV", id="export_csv_btn")
export_csv_btn.styles.width = "100%" export_csv_btn.styles.width = "100%"
export_csv_btn.styles.margin = (0, 0, 1, 0) export_csv_btn.styles.margin = (0, 0, 1, 0)
yield export_csv_btn yield export_csv_btn
local_approval_btn = Button( local_approval_btn = Button(
"✔️ Local Approval Mode", id="local_approval_btn" "✔️ Local Approval Mode", id="local_approval_btn"
) )
local_approval_btn.styles.width = "100%" local_approval_btn.styles.width = "100%"
local_approval_btn.styles.margin = (0, 0, 1, 0) local_approval_btn.styles.margin = (0, 0, 1, 0)
yield local_approval_btn yield local_approval_btn
otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn") otp_gen_btn = Button("🎫 Generate One Time Passes", id="otp_gen_btn")
otp_gen_btn.styles.width = "100%" otp_gen_btn.styles.width = "100%"
otp_gen_btn.styles.margin = (0, 0, 1, 0) otp_gen_btn.styles.margin = (0, 0, 1, 0)
yield otp_gen_btn yield otp_gen_btn
toggle_enforcement_btn = Button( toggle_enforcement_btn = Button(
"🔄 Toggle Audit/Enforcement", id="toggle_enforcement_btn" "🔄 Toggle Audit/Enforcement", id="toggle_enforcement_btn"
) )
toggle_enforcement_btn.styles.width = "100%" toggle_enforcement_btn.styles.width = "100%"
toggle_enforcement_btn.styles.margin = (0, 0, 1, 0) toggle_enforcement_btn.styles.margin = (0, 0, 1, 0)
yield toggle_enforcement_btn yield toggle_enforcement_btn
other_policy_btn = Button( other_policy_btn = Button(
"🔀 Move to Other Policy", id="other_policy_btn" "🔀 Move to Other Policy", id="other_policy_btn"
) )
other_policy_btn.styles.width = "100%" other_policy_btn.styles.width = "100%"
other_policy_btn.styles.margin = (0, 0, 1, 0) other_policy_btn.styles.margin = (0, 0, 1, 0)
@@ -305,7 +305,7 @@ class AgentMoveOperations(Widget):
status_label.styles.margin = (2, 0, 0, 0) status_label.styles.margin = (2, 0, 0, 0)
yield status_label yield status_label
back_button = Button(" Back", id="back_button") back_button = Button("← Back", id="back_button")
back_button.styles.width = "50%" back_button.styles.width = "50%"
back_button.styles.margin = (0, 1, 1, 0) back_button.styles.margin = (0, 1, 1, 0)
yield back_button yield back_button
@@ -369,17 +369,17 @@ class AgentMoveOperations(Widget):
pyperclip.copy(results_text.text) pyperclip.copy(results_text.text)
self.app.notify( self.app.notify(
"📋✅ Results copied to clipboard!", "📋✅ Results copied to clipboard!",
severity="information", severity="information",
timeout=2, timeout=2,
) )
except ImportError: except ImportError:
self.app.notify( self.app.notify(
" pyperclip not installed. Run: pip install pyperclip", "❌ pyperclip not installed. Run: pip install pyperclip",
severity="warning", severity="warning",
) )
except Exception as e: except Exception as e:
self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error") self.app.notify(f"❌ Failed to copy: {str(e)}", severity="error")
event.stop() event.stop()
elif btn_id == "export_csv_btn": elif btn_id == "export_csv_btn":
self._start_export_csv_operation() self._start_export_csv_operation()
@@ -427,7 +427,7 @@ class AgentMoveOperations(Widget):
self.operation_in_progress = True self.operation_in_progress = True
status_label = self.query_one("#status_label", Static) status_label = self.query_one("#status_label", Static)
status_label.update("✔️ Moving agents to local approval...") status_label.update("✔️ Moving agents to local approval...")
# Get API from app # Get API from app
api = self.app.api api = self.app.api
@@ -462,12 +462,12 @@ class AgentMoveOperations(Widget):
except Exception as e: except Exception as e:
logger.error(f"Error during local approval operation: {e}") logger.error(f"Error during local approval operation: {e}")
status_label.update(f" Error: {str(e)}") status_label.update(f"❌ Error: {str(e)}")
self.operation_in_progress = False self.operation_in_progress = False
return return
self.operation_in_progress = False self.operation_in_progress = False
status_label.update(" Operation complete!") status_label.update("✅ Operation complete!")
# Display results in the widget # Display results in the widget
self._display_results("Local Approval Mode", successful, unsuccessful) self._display_results("Local Approval Mode", successful, unsuccessful)
@@ -511,9 +511,9 @@ class AgentMoveOperations(Widget):
file_path = os.path.join(str(path), filename) file_path = os.path.join(str(path), filename)
df.to_csv(file_path, index=False) df.to_csv(file_path, index=False)
successful.append(file_path) successful.append(file_path)
status_label.update(f" Exported to {file_path}") status_label.update(f"✅ Exported to {file_path}")
except Exception: except Exception:
status_label.update(" Failed") status_label.update("❌ Failed")
self.operation_in_progress = False self.operation_in_progress = False
@@ -557,7 +557,7 @@ class AgentMoveOperations(Widget):
self.operation_in_progress = True self.operation_in_progress = True
status_label = self.query_one("#status_label", Static) status_label = self.query_one("#status_label", Static)
status_label.update("⏳ Toggling enforcement mode...") status_label.update("⏳ Toggling enforcement mode...")
# Get API from app # Get API from app
api = self.app.api api = self.app.api
@@ -567,9 +567,9 @@ class AgentMoveOperations(Widget):
try: try:
from services.agenthandler import moveAgentToRelatedPolicy from services.agenthandler import moveAgentToRelatedPolicy
from utils.configmanager import get_protected_json from utils.configmanager import get_system_json
policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}")
for agent in self.agents: for agent in self.agents:
try: try:
@@ -593,12 +593,12 @@ class AgentMoveOperations(Widget):
except Exception as e: except Exception as e:
logger.error(f"Error during toggle enforcement operation: {e}") logger.error(f"Error during toggle enforcement operation: {e}")
status_label.update(f" Error: {str(e)}") status_label.update(f"❌ Error: {str(e)}")
self.operation_in_progress = False self.operation_in_progress = False
return return
self.operation_in_progress = False self.operation_in_progress = False
status_label.update("✅ Operation complete!") status_label.update("✅ Operation complete!")
# Display results in the widget # Display results in the widget
self._display_results("Toggle Audit/Enforcement", successful, unsuccessful) self._display_results("Toggle Audit/Enforcement", successful, unsuccessful)
@@ -663,7 +663,7 @@ class AgentMoveOperations(Widget):
except Exception as e: except Exception as e:
logger.error(f"Error loading policies: {e}") logger.error(f"Error loading policies: {e}")
status_label.update(f" Error: {str(e)}") status_label.update(f"❌ Error: {str(e)}")
self.operation_in_progress = False self.operation_in_progress = False
self.selected_operation = "" self.selected_operation = ""
self.app.notify(f"Failed to load policies: {str(e)}", severity="error") self.app.notify(f"Failed to load policies: {str(e)}", severity="error")
+1 -1
View File
@@ -30,7 +30,7 @@ def history_logging(
checkpoint_number: str, checkpoint_number: str,
policy_names: str, policy_names: str,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Query execution history logs from the Airlock API. Query execution history logs from the Airlock API.
Parameters Parameters
+31 -8
View File
@@ -2,15 +2,38 @@
"APPNAME": "Loxide", "APPNAME": "Loxide",
"URL": "https://server:3129", "URL": "https://server:3129",
"LOG_LEVEL": "INFO", "LOG_LEVEL": "INFO",
"BAD_PATH_PARTS": ["users","wwwroot","windows\\temp","windows\\task","windows\\system32","startup", "windows\\fonts","Recycle.Bin","AppData","programdata", "Solarwinds","kaseya"], "BAD_PATH_PARTS": [
"BAD_PUBLISHERS": ["Brave", "Zoom", "GlavSoft", "VNC"], "users",
"PUPS":["logmein","invalid","nmap","LTSvc","VNC","Kaseya","Solarwinds","mRemoteNG"], "wwwroot",
"windows\\temp",
"windows\\task",
"windows\\system32",
"startup",
"windows\\fonts",
"Recycle.Bin",
"AppData",
"programdata",
"Solarwinds",
"kaseya"
],
"BAD_PUBLISHERS": [
"Brave",
"Zoom",
"GlavSoft",
"VNC"
],
"PUPS": [
"logmein",
"invalid",
"nmap",
"LTSvc",
"VNC",
"Kaseya",
"Solarwinds",
"mRemoteNG"
],
"PATH_EXCLUSION_CONST": 4, "PATH_EXCLUSION_CONST": 4,
"MIN_FILES_FOR_PATH": 4, "MIN_FILES_FOR_PATH": 4,
"VT_THREAT_TOLERANCE": 4, "VT_THREAT_TOLERANCE": 4,
"TELEMETRY": "FALSE", "POLICY_MAP_ENF_AUD": {}
"TELEM_URL": "",
"POLICY_MAP_ENF_AUD": {
}
} }
+26 -25
View File
@@ -10,7 +10,7 @@ from typing import List, Optional
from models.agent import Agent from models.agent import Agent
from services.agenthandler import moveAgentToRelatedPolicy, selectAgents from services.agenthandler import moveAgentToRelatedPolicy, selectAgents
from services.API import AirlockAPIWrapper from services.API import AirlockAPIWrapper
from utils.configmanager import get_protected_json from utils.configmanager import get_system_json
from utils.utils import colorText, get_sanitized_input from utils.utils import colorText, get_sanitized_input
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ class LocalApprovalRequestor:
username: Username creating the approvals (for tracking) username: Username creating the approvals (for tracking)
""" """
self.api = api self.api = api
self.policy_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") self.policy_map = get_system_json("POLICY_MAP_ENF_AUD", "{}")
self.username = ( self.username = (
username or os.getenv("USERNAME") or os.getenv("USER") or "unknown" username or os.getenv("USERNAME") or os.getenv("USER") or "unknown"
) )
@@ -51,7 +51,7 @@ class LocalApprovalRequestor:
batch_id = int(time.time()) batch_id = int(time.time())
purpose = ( purpose = (
f"🎫 Local Approval 🎫 - {duration_minutes} mins - " f"🎫 Local Approval 🎫 - {duration_minutes} mins - "
f"batch:{batch_id} Client:{agent_id} User:{self.username}" f"batch:{batch_id} Client:{agent_id} User:{self.username}"
) )
@@ -103,10 +103,10 @@ class LocalApprovalRequestor:
success_count = 0 success_count = 0
failure_count = 0 failure_count = 0
print(colorText(f"\n📦 Processing batch {batch_id}...", "cyan")) print(colorText(f"\n📦 Processing batch {batch_id}...", "cyan"))
print(colorText(f"👤 Requested by: {self.username}", "cyan")) print(colorText(f"👤 Requested by: {self.username}", "cyan"))
print( print(
colorText(f"📊 Moving {len(agents)} agent(s) to local approval\n", "cyan") colorText(f"📊 Moving {len(agents)} agent(s) to local approval\n", "cyan")
) )
for agent in agents: for agent in agents:
@@ -125,11 +125,11 @@ class LocalApprovalRequestor:
if not move_success: if not move_success:
raise Exception("Failed to move to audit policy") raise Exception("Failed to move to audit policy")
print(colorText(f" {agent.hostname}", "green")) print(colorText(f"✓ {agent.hostname}", "green"))
success_count += 1 success_count += 1
except Exception as e: except Exception as e:
print(colorText(f" {agent.hostname}: {e}", "red")) print(colorText(f"✗ {agent.hostname}: {e}", "red"))
logger.error(f"Error processing agent {agent.hostname}: {e}") logger.error(f"Error processing agent {agent.hostname}: {e}")
failure_count += 1 failure_count += 1
@@ -152,7 +152,7 @@ class LocalApprovalRequestor:
] ]
# Display duration options # Display duration options
print(colorText("\n⏱️ Select Local Approval Duration:", "white")) print(colorText("\n⏱️ Select Local Approval Duration:", "white"))
print(colorText("=" * 50, "white")) print(colorText("=" * 50, "white"))
for i, (minutes, label) in enumerate(duration_options, start=1): for i, (minutes, label) in enumerate(duration_options, start=1):
@@ -166,36 +166,36 @@ class LocalApprovalRequestor:
if 1 <= choice <= len(duration_options): if 1 <= choice <= len(duration_options):
duration_minutes, duration_label = duration_options[choice - 1] duration_minutes, duration_label = duration_options[choice - 1]
print(colorText(f" Selected: {duration_label}", "green")) print(colorText(f"✓ Selected: {duration_label}", "green"))
logger.info(f"User selected duration: {duration_minutes} minutes") logger.info(f"User selected duration: {duration_minutes} minutes")
else: else:
print(colorText(" Invalid choice.", "red")) print(colorText("❌ Invalid choice.", "red"))
logger.warning("Invalid duration choice") logger.warning("Invalid duration choice")
return return
except ValueError: except ValueError:
print(colorText(" Invalid input. Please enter a number.", "red")) print(colorText("❌ Invalid input. Please enter a number.", "red"))
logger.warning("Invalid input for duration selection") logger.warning("Invalid input for duration selection")
return return
# Select agents # Select agents
print(colorText("\n🎯 Select Agents for Local Approval:", "white")) print(colorText("\n🎯 Select Agents for Local Approval:", "white"))
agents = selectAgents(self.api) agents = selectAgents(self.api)
if not agents: if not agents:
print(colorText(" No agents found or error retrieving agents.", "red")) print(colorText("❌ No agents found or error retrieving agents.", "red"))
logger.warning("No agents selected or error retrieving agents") logger.warning("No agents selected or error retrieving agents")
return return
# Confirm with user # Confirm with user
print(colorText("\n📋 Summary:", "cyan")) print(colorText("\n📋 Summary:", "cyan"))
print(colorText(f" Duration: {duration_label}", "white")) print(colorText(f" Duration: {duration_label}", "white"))
print(colorText(f" Agents: {len(agents)}", "white")) print(colorText(f" Agents: {len(agents)}", "white"))
confirm = get_sanitized_input("\nProceed? (y/n): ").lower() confirm = get_sanitized_input("\nProceed? (y/n): ").lower()
if confirm != "y": if confirm != "y":
print(colorText(" Operation cancelled.", "yellow")) print(colorText("❌ Operation cancelled.", "yellow"))
return return
# Process the batch # Process the batch
@@ -219,24 +219,25 @@ class LocalApprovalRequestor:
failure_count: Number of failed operations failure_count: Number of failed operations
""" """
print(colorText(f"\n{'=' * 60}", "white")) print(colorText(f"\n{'=' * 60}", "white"))
print(colorText("📊 Local Approval Summary", "cyan")) print(colorText("📊 Local Approval Summary", "cyan"))
print(colorText("=" * 60, "white")) print(colorText("=" * 60, "white"))
print(colorText(f" Successfully processed: {success_count}", "green")) print(colorText(f"✓ Successfully processed: {success_count}", "green"))
if failure_count > 0: if failure_count > 0:
print(colorText(f" Failed: {failure_count}", "red")) print(colorText(f"✗ Failed: {failure_count}", "red"))
print(colorText(f"\n📦 Batch ID: {batch_id}", "cyan")) print(colorText(f"\n📦 Batch ID: {batch_id}", "cyan"))
print(colorText(f"⏱️ Duration: {duration_label}", "cyan")) print(colorText(f"⏱️ Duration: {duration_label}", "cyan"))
print(colorText("=" * 60, "white")) print(colorText("=" * 60, "white"))
print(colorText("\n💡 Next Steps:", "yellow")) print(colorText("\n💡 Next Steps:", "yellow"))
print(colorText(" Agents have been moved to audit policies", "white")) print(colorText(" • Agents have been moved to audit policies", "white"))
print(colorText(" Local approvals are active", "white")) print(colorText(" • Local approvals are active", "white"))
print( print(
colorText( colorText(
f" Agents will return to enforcement after {duration_label}", "white" f" • Agents will return to enforcement after {duration_label}",
"white",
) )
) )
print(colorText("=" * 60 + "\n", "white")) print(colorText("=" * 60 + "\n", "white"))
+29 -29
View File
@@ -25,7 +25,7 @@ import pandas as pd
from models.execution import ExecutionHistoryRecord from models.execution import ExecutionHistoryRecord
from models.policy import Allowlist, Policy from models.policy import Allowlist, Policy
from services.API import AirlockAPIWrapper from services.API import AirlockAPIWrapper
from utils.configmanager import get_protected_value, load_env, load_env_json from utils.configmanager import get_system_list, get_system_value, load_env
from utils.selector import Selector from utils.selector import Selector
from utils.utils import ( from utils.utils import (
areYouSure, areYouSure,
@@ -88,7 +88,7 @@ def sortHashes(
): ):
working_dir = load_env("WORKING_DIR") working_dir = load_env("WORKING_DIR")
history_days = Selector.select_value( history_days = Selector.select_value(
prompt="Enter how many days of history to pull (1150): ", prompt="Enter how many days of history to pull (1–150): ",
value_type=int, value_type=int,
valid_range=(1, 150), valid_range=(1, 150),
) )
@@ -157,7 +157,7 @@ def buildPathsandPublishers(selected_policies: List[Policy], split):
f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv" f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv"
) )
path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv" path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv"
path_exclusion_constant = get_protected_value("PATH_EXCLUSION_CONST", cast_type=int) path_exclusion_constant = get_system_value("PATH_EXCLUSION_CONST", cast_type=int)
if os.path.exists(path1): if os.path.exists(path1):
df1 = pd.read_csv(path1) df1 = pd.read_csv(path1)
@@ -227,7 +227,7 @@ def buildPathsandPublishers(selected_policies: List[Policy], split):
all_approved_hashes["publisher"] != "Not Signed" all_approved_hashes["publisher"] != "Not Signed"
].drop_duplicates(subset=["publisher"]) ].drop_duplicates(subset=["publisher"])
# Remove Bad publisher if somehow they made it this far # Remove Bad publisher if somehow they made it this far
pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) pattern = regulator(get_system_list("BAD_PUBLISHERS"))
publist = publist[~publist["publisher"].str.contains(pattern, na=False)] publist = publist[~publist["publisher"].str.contains(pattern, na=False)]
publist = publist[["publisher"]] publist = publist[["publisher"]]
publist.sort_values(by="publisher", inplace=True) publist.sort_values(by="publisher", inplace=True)
@@ -313,7 +313,7 @@ def buildPreflights(selected_policies: List[Policy]):
def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"): def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"):
min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type=int) min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int)
def clean_split(path): def clean_split(path):
if not isinstance(path, (str, bytes, os.PathLike)): if not isinstance(path, (str, bytes, os.PathLike)):
@@ -385,8 +385,8 @@ def calculatePath(approved_hashes, path_exclusion_constant, split):
else: else:
dfs_by_policy = [approved_hashes] dfs_by_policy = [approved_hashes]
badpathparts = load_env_json("BAD_PATH_PARTS", "[]") badpathparts = get_system_list("BAD_PATH_PARTS")
min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type=int) min_files_for_path = get_system_value("MIN_FILES_FOR_PATH", cast_type=int)
processed_dfs = [] processed_dfs = []
@@ -655,7 +655,7 @@ def section_header(title):
def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist): def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist):
working_dir = load_env("WORKING_DIR") working_dir = load_env("WORKING_DIR")
section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒") section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒")
print( print(
colorText( colorText(
"\nSequentially follow these steps to prepare a policy for enforcement:", "\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: if not selected_policies:
print(colorText(" [] No policies have been chosen", "red")) print(colorText(" [✗] No policies have been chosen", "red"))
else: else:
print(colorText("The following policies have been chosen:", "green")) print(colorText("The following policies have been chosen:", "green"))
for policy in selected_policies: for policy in selected_policies:
print(colorText(f" [] {policy.name}", "green")) print(colorText(f" [✓] {policy.name}", "green"))
# Step 2: Destination Policy and Allowlist # Step 2: Destination Policy and Allowlist
print( print(
@@ -683,22 +683,22 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
if destination_policy: if destination_policy:
print( print(
colorText( 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", "green",
) )
) )
else: else:
print(colorText(" [] No destination policy has been chosen", "red")) print(colorText(" [✗] No destination policy has been chosen", "red"))
if destination_allowlist: if destination_allowlist:
print( print(
colorText( colorText(
f" [] {destination_allowlist[0].name} has been selected as allowlist", f" [✓] {destination_allowlist[0].name} has been selected as allowlist",
"green", "green",
) )
) )
else: else:
print(colorText(" [] No allowlist has been chosen", "red")) print(colorText(" [✗] No allowlist has been chosen", "red"))
# Step 3: Data Preparation # Step 3: Data Preparation
print( print(
@@ -713,9 +713,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
print( print(
colorText( colorText(
( (
" [] Data has been fetched" " [✓] Data has been fetched"
if os.path.exists(review_path) 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", "green" if os.path.exists(review_path) else "red",
) )
@@ -723,7 +723,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
else: else:
print( print(
colorText( 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( print(
colorText( colorText(
( (
" [] Reviewed hashes have been loaded" " [✓] Reviewed hashes have been loaded"
if os.path.exists(approved_path) 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", "green" if os.path.exists(approved_path) else "red",
) )
@@ -766,9 +766,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
print( print(
colorText( colorText(
( (
" [] Path review list created" " [✓] Path review list created"
if os.path.exists(second_review_path) 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", "green" if os.path.exists(second_review_path) else "red",
) )
@@ -776,7 +776,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
else: else:
print( print(
colorText( colorText(
" [] No policies selected, cannot check reviewed hashes or path list", " [✗] No policies selected, cannot check reviewed hashes or path list",
"red", "red",
) )
) )
@@ -812,9 +812,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
print( print(
colorText( colorText(
( (
" [] Reviewed path list detected" " [✓] Reviewed path list detected"
if os.path.exists(reviewed_path) 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", "green" if os.path.exists(reviewed_path) else "red",
) )
@@ -825,9 +825,9 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
print( print(
colorText( colorText(
( (
" [] Preflight Path Exclusion List has been generated" " [✓] Preflight Path Exclusion List has been generated"
if preflight_ready 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", "green" if preflight_ready else "red",
) )
@@ -835,7 +835,7 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
else: else:
print( print(
colorText( 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")) print(colorText(" Apply approved hashes to allowlist", "cyan"))
# Utility Options # Utility Options
print(colorText("F. 📂 - Open Working Directory", "cyan")) print(colorText("F. 📂 - Open Working Directory", "cyan"))
print(colorText("B. 🔚 - Back", "cyan")) print(colorText("B. 🔚 - Back", "cyan"))
-134
View File
@@ -1,134 +0,0 @@
# 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 dotenv
import pandas as pd
from flows.prepPolicy import selectPolicies
from services.API import AirlockAPIWrapper
from services.policyhandler import getPolicyInfo
from utils.configmanager import load_env
from utils.selector import Selector
from utils.utils import colorText, get_sanitized_input
logger = logging.getLogger(__name__)
dotenv.load_dotenv()
def findQuietAgents(api: AirlockAPIWrapper):
working_dir = load_env("WORKING_DIR")
# Get policy selection and agent list
selected_policy = selectPolicies(api, False)
if selected_policy:
agents = api.agents_find_by_group(selected_policy[0].groupid)
# Prompt user for history range
history_days = Selector.select_value(
prompt="Enter how many days of history to pull (1150): ",
value_type=int,
valid_range=(1, 150),
)
required_quiet = Selector.select_value(
prompt="Enter how many days without an untrusted execution before these are considered ready for enforcement? (1365): ",
value_type=int,
valid_range=(1, 150),
)
confirm = Selector.confirm(
f"Do you wish to proceed to pull history for {selected_policy[0].name}? Y/N : "
)
# Get execution history as a DataFrame
if confirm:
policy_exec_history = getPolicyInfo(
api, selected_policy[0], [1, 2, 6, 7], history_days
)
if policy_exec_history.empty:
logging.info(
"No execution history found for the selected policy and time range."
)
get_sanitized_input("Press enter to continue")
return
# Convert 'datetime' column to timezone-aware datetime objects
policy_exec_history["datetime"] = pd.to_datetime(
policy_exec_history["datetime"], format="%Y-%m-%dT%H:%M:%SZ", utc=True
)
# Get current UTC time
now = datetime.datetime.now(datetime.timezone.utc)
# Calculate days ago
policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply(
lambda dt: (now - dt).days
)
# Count total executions per hostname
hostname_counts = policy_exec_history["hostname"].value_counts()
# Map execution counts to agents
agents["execution_count"] = (
agents["hostname"].map(hostname_counts).fillna(0).astype(int)
)
# Find most recent execution per hostname
most_recent_exec = policy_exec_history.sort_values(
by="days_ago"
).drop_duplicates(subset="hostname", keep="first")
# Map most recent execution age to agents
agents["days_since"] = agents["hostname"].map(
most_recent_exec.set_index("hostname")["days_ago"]
)
# Check for enforcement readiness
agents["required_quiet"] = required_quiet
agents["enforce_ready"] = agents["days_since"].apply(
lambda x: True if pd.isna(x) or x > required_quiet else False
)
# Sort agents by execution count and hostname
agents = agents.sort_values(
by=["execution_count", "hostname"], ascending=[True, True]
)
# Save to CSV
filename = f"{working_dir}\\{selected_policy[0].name}_agents_last_{history_days}_days.csv"
logging.debug(f"Saving CSV to {filename}")
print(colorText(f"Saving CSV to {filename}", "green"))
agents.to_csv(filename, index=False)
# Summary statistics
total_agents = len(agents)
ready_agents = agents["enforce_ready"].sum()
not_ready_agents = total_agents - ready_agents
ready_percentage = (ready_agents / total_agents) * 100
# Print results
message = (
f"Total agents: {total_agents}\n"
f"Agents marked as 'enforce_ready': {ready_agents}\n"
f"Agents not ready: {not_ready_agents}\n"
f"Percentage ready for enforcement: {ready_percentage:.2f}%"
)
logger.debug(message)
colorText(message, "green")
get_sanitized_input("Press enter to continue")
+11 -11
View File
@@ -28,7 +28,7 @@ import pandas as pd
import airlock_libs import airlock_libs
from services.API import AirlockAPIWrapper from services.API import AirlockAPIWrapper
from utils.configmanager import get_protected_value, load_env_json from utils.configmanager import get_system_list, get_system_value
from utils.utils import colorText, regulator from utils.utils import colorText, regulator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -90,9 +90,9 @@ class Hash:
@classmethod @classmethod
def categorize_hashes(cls, hashes): def categorize_hashes(cls, hashes):
threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int) threat_tolerance = get_system_value("VT_THREAT_TOLERANCE", cast_type=int)
bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) bad_publishers_pattern = regulator(get_system_list("BAD_PUBLISHERS"))
pups_pattern = regulator(load_env_json("PUPS", "[]")) pups_pattern = regulator(get_system_list("PUPS"))
approved_count = 0 approved_count = 0
unapproved_count = 0 unapproved_count = 0
@@ -145,13 +145,13 @@ class Hash:
approved_count += 1 approved_count += 1
except (ValueError, TypeError): except (ValueError, TypeError):
logger.debug( logger.debug(
"Needs Review: Scannermatch score is missing or invalid. {e}" "Needs Review: Scannermatch score is missing or invalid. — {e}"
) )
hash_obj.at_decision = "needs_review" hash_obj.at_decision = "needs_review"
needs_review_count += 1 needs_review_count += 1
logger.debug( logger.debug(
f"Final counts Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}" f"Final counts — Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}"
) )
return hashes return hashes
@@ -384,9 +384,9 @@ class ExecutionHistoryRecord:
Returns: Returns:
List[ExecutionHistoryRecord]: The same list, with hash_obj.at_decision updated. List[ExecutionHistoryRecord]: The same list, with hash_obj.at_decision updated.
""" """
threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int) threat_tolerance = get_system_value("VT_THREAT_TOLERANCE", cast_type=int)
bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) bad_publishers_pattern = regulator(get_system_list("BAD_PUBLISHERS"))
pups_pattern = regulator(load_env_json("PUPS", "[]")) pups_pattern = regulator(get_system_list("PUPS"))
approved_count = 0 approved_count = 0
unapproved_count = 0 unapproved_count = 0
@@ -443,13 +443,13 @@ class ExecutionHistoryRecord:
approved_count += 1 approved_count += 1
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
logger.debug( logger.debug(
f"Needs Review: Scannermatch score is missing or invalid. {e}" f"Needs Review: Scannermatch score is missing or invalid. — {e}"
) )
hash_obj.at_decision = "needs_review" hash_obj.at_decision = "needs_review"
needs_review_count += 1 needs_review_count += 1
logger.debug( logger.debug(
f"Final counts Needs Review: {needs_review_count}, " f"Final counts — Needs Review: {needs_review_count}, "
f"Approved: {approved_count}, Unapproved: {unapproved_count}" f"Approved: {approved_count}, Unapproved: {unapproved_count}"
) )
+16 -16
View File
@@ -28,7 +28,7 @@ from flows.prepPolicy import selectPolicies
from models.agent import Agent from models.agent import Agent
from models.policy import Policy from models.policy import Policy
from services.API import AirlockAPIWrapper from services.API import AirlockAPIWrapper
from utils.configmanager import get_protected_json, load_env from utils.configmanager import get_system_json, load_env
from utils.selector import Selector from utils.selector import Selector
from utils.utils import colorText, get_sanitized_input from utils.utils import colorText, get_sanitized_input
@@ -38,7 +38,7 @@ logger = logging.getLogger(__name__)
def devicehistory(api: AirlockAPIWrapper, outputjson: bool): def devicehistory(api: AirlockAPIWrapper, outputjson: bool):
agents = selectAgents(api) agents = selectAgents(api)
history_days = Selector.select_value( history_days = Selector.select_value(
prompt="Enter how many days of history to pull (1150): ", prompt="Enter how many days of history to pull (1–150): ",
value_type=int, value_type=int,
valid_range=(1, 150), valid_range=(1, 150),
) )
@@ -60,7 +60,7 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool):
except Exception as e: except Exception as e:
print( print(
colorText( colorText(
f" Error retrieving history for {agent.hostname}: {e}", "red" f"❌ Error retrieving history for {agent.hostname}: {e}", "red"
) )
) )
continue continue
@@ -139,7 +139,7 @@ def findAgents(api, return_dataframe):
print( print(
colorText( colorText(
f"\n Matched devices exported to: {working_dir}\\{filename}", f"\n✅ Matched devices exported to: {working_dir}\\{filename}",
"green", "green",
) )
) )
@@ -148,7 +148,7 @@ def findAgents(api, return_dataframe):
def collect_device_names() -> List[str]: def collect_device_names() -> List[str]:
print(colorText("🔍 Device Search", "cyan")) print(colorText("🔍 Device Search", "cyan"))
print( print(
colorText( colorText(
"Enter the device hostnames you'd like to search for, one per line.", "cyan" "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: else:
print( print(
colorText( 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", "yellow",
) )
) )
@@ -235,8 +235,8 @@ def show_unmatched(
] ]
if unmatched: if unmatched:
logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}")
print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow"))
def enrich_agents(agents: List["Agent"], policies: List["Policy"]): def enrich_agents(agents: List["Agent"], policies: List["Policy"]):
@@ -248,7 +248,7 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]:
device_names = collect_device_names() device_names = collect_device_names()
if not device_names: if not device_names:
logger.debug("No device names entered") logger.debug("No device names entered")
print(colorText("⚠️ No device names entered.", "red")) print(colorText("⚠️ No device names entered.", "red"))
return [] return []
use_exact = choose_match_type() use_exact = choose_match_type()
@@ -261,11 +261,11 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]:
show_unmatched(device_names, matched_agents, use_exact) show_unmatched(device_names, matched_agents, use_exact)
if not matched_agents: if not matched_agents:
logger.debug(" No matching devices found.") logger.debug("❌ No matching devices found.")
print(colorText(" No matching devices found.", "red")) print(colorText("❌ No matching devices found.", "red"))
return [] 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:") logger.info("Matched agent hostnames:")
rows = (len(matched_agents) + 2) // 3 # 3 columns rows = (len(matched_agents) + 2) // 3 # 3 columns
for row in range(rows): for row in range(rows):
@@ -283,8 +283,8 @@ def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]:
) )
if not matched_agents: if not matched_agents:
logger.debug(" No matching devices remain after refinement.") logger.debug("❌ No matching devices remain after refinement.")
print(colorText(" No matching devices remain after refinement.", "red")) print(colorText("❌ No matching devices remain after refinement.", "red"))
return [] return []
enrich_agents(matched_agents, policies) enrich_agents(matched_agents, policies)
@@ -302,10 +302,10 @@ def moveAgentToRelatedPolicy(
Args: Args:
api: AirlockAPIWrapper instance. api: AirlockAPIWrapper instance.
agent: Agent object. 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. mode: 'audit' to move to audit, 'enforcement' to move to enforcement.
""" """
policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}")
if mode == "audit": if mode == "audit":
if agent.groupid in policy_relationship_map: if agent.groupid in policy_relationship_map:
+2 -2
View File
@@ -27,7 +27,7 @@ import tqdm
from models.policy import Policy from models.policy import Policy
from services.API import AirlockAPIWrapper from services.API import AirlockAPIWrapper
from utils.configmanager import get_protected_json from utils.configmanager import get_system_json
from utils.setup import get_base_directory from utils.setup import get_base_directory
from utils.utils import areYouSure, colorText, get_sanitized_input from utils.utils import areYouSure, colorText, get_sanitized_input
@@ -236,7 +236,7 @@ def skipback(days):
def updateAuditPoliciesFromEnforcementPolices(api: AirlockAPIWrapper): def updateAuditPoliciesFromEnforcementPolices(api: AirlockAPIWrapper):
policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") policy_relationship_map = get_system_json("POLICY_MAP_ENF_AUD", "{}")
for enforcement_policy, audit_policy in policy_relationship_map.items(): for enforcement_policy, audit_policy in policy_relationship_map.items():
api.policy_clone(enforcement_policy, audit_policy) api.policy_clone(enforcement_policy, audit_policy)
api.policy_set_auditmode(audit_policy, "1") api.policy_set_auditmode(audit_policy, "1")
+246 -43
View File
@@ -18,119 +18,279 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
import sys import sys
from typing import Callable, Optional, TypeVar from typing import Any, Callable, Optional, TypeVar
T = TypeVar("T") T = TypeVar("T")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROTECTED_KEYS = [ # System config keys - these are immutable and come from system_config.json (bundled in exe)
SYSTEM_CONFIG_KEYS = [
"URL", "URL",
"TELEM_URL", "TELEM_URL",
"APPNAME", "APPNAME",
"LOG_LEVEL", "LOG_LEVEL",
"BAD_PATH_PARTS",
"BAD_PUBLISHERS",
"PUPS",
"PATH_EXCLUSION_CONST", "PATH_EXCLUSION_CONST",
"MIN_FILES_FOR_PATH", "MIN_FILES_FOR_PATH",
"VT_THREAT_TOLERANCE", "VT_THREAT_TOLERANCE",
"POLICY_MAP_ENF_AUD", "POLICY_MAP_ENF_AUD",
] ]
_protected_config = {} # User config keys - these can be changed by the end user
USER_CONFIG_KEYS = [
"TELEMETRY", # User opt-in/out for telemetry
"TEXTUAL_THEME", # UI theme preference
"EXTRAS", # Feature flags
]
# In-memory config storage
_system_config = {}
_user_config = {}
def get_system_config_path() -> Path: def get_system_config_path() -> Path:
"""
Get path to system_config.json.
Priority:
1. Bundled in exe (_MEIPASS)
2. Next to this file (development)
"""
# Check inside bundled EXE directory first # Check inside bundled EXE directory first
bundled_dir = Path(getattr(sys, "_MEIPASS", "")) bundled_dir = Path(getattr(sys, "_MEIPASS", ""))
bundled_path = bundled_dir / "system_config.json" bundled_path = bundled_dir / "system_config.json"
if bundled_path.exists(): if bundled_path.exists():
return bundled_path return bundled_path
# Fallback to external location # Fallback to development location (next to this file)
return Path(__file__).parent.parent / "system_config.json" return Path(__file__).parent.parent / "system_config.json"
def load_protected_config() -> dict: def load_system_config() -> dict:
global _protected_config """
Load system configuration from system_config.json.
This should only be called once at startup.
Returns the full system config dict.
"""
global _system_config
try: try:
with open(get_system_config_path(), "r") as f: config_path = get_system_config_path()
system_config = json.load(f) with open(config_path, "r") as f:
_system_config = json.load(f)
logger.debug(f"✅ Loaded system config from {config_path}")
except FileNotFoundError: except FileNotFoundError:
logging.warning("⚠️ system_config.json not found. Using built-in defaults.") logger.warning("⚠️ system_config.json not found. Using minimal defaults.")
system_config = { # Minimal defaults for development without system_config.json
_system_config = {
"APPNAME": "Loxide", "APPNAME": "Loxide",
"LOG_LEVEL": "INFO",
"PATH_EXCLUSION_CONST": 4, "PATH_EXCLUSION_CONST": 4,
"MIN_FILES_FOR_PATH": 4, "MIN_FILES_FOR_PATH": 4,
"VT_THREAT_TOLERANCE": 4, "VT_THREAT_TOLERANCE": 4,
"POLICY_MAP_ENF_AUD": {"enforced_id": "audit_id"}, "POLICY_MAP_ENF_AUD": {},
} }
_protected_config = {key: system_config[key] for key in PROTECTED_KEYS} return _system_config
return _protected_config
def get_protected_value( def get_system_value(
key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None
) -> Optional[T]: ) -> Optional[T]:
value = _protected_config.get(key) """
Get a value from system config (immutable).
Parameters:
key: The config key to retrieve
cast_type: Function to cast the value to desired type
default: Default value if key not found
Returns:
The config value cast to the desired type, or default
"""
value = _system_config.get(key)
if value is None: if value is None:
logging.warning(f"Protected config key '{key}' not found.") logger.warning(f"System config key '{key}' not found.")
return default return default
try: try:
if isinstance(value, str): if isinstance(value, str):
value = value.strip("'\"") value = value.strip("'\"")
return cast_type(value) return cast_type(value)
except (ValueError, TypeError): except (ValueError, TypeError):
logging.warning( logger.warning(
f"Invalid value for protected key '{key}': {value}. Expected type {cast_type.__name__}." f"Invalid value for system key '{key}': {value}. Expected type {cast_type.__name__}."
) )
return default return default
def get_protected_json(key: str, default: str = "{}") -> dict: def get_system_json(key: str, default: Optional[dict] = None) -> dict:
raw = _protected_config.get(key, default) """
Get a JSON/dict value from system config.
Handles both dict values and JSON strings.
"""
if default is None:
default = {}
raw = _system_config.get(key, default)
if isinstance(raw, dict): if isinstance(raw, dict):
return raw return raw
try: try:
return json.loads(raw) return json.loads(raw)
except json.JSONDecodeError: except (json.JSONDecodeError, TypeError) as e:
try: logger.error(f"Failed to parse system JSON key '{key}': {e}")
escaped = raw.encode("unicode_escape").decode("utf-8") return default
return json.loads(escaped)
except Exception as e:
logging.error(f"Failed to parse protected JSON key '{key}': {e}")
return json.loads(default)
def load_env_json(key: str, default: str): def get_system_list(key: str, default: Optional[list] = None) -> list:
raw = os.getenv(key, default) """
Get a list value from system config.
Handles both list values and JSON strings.
Parameters:
key: The config key to retrieve
default: Default value if key not found or parsing fails
Returns:
The list value or default
"""
if default is None:
default = []
raw = _system_config.get(key, default)
if isinstance(raw, list):
return raw
try: try:
return json.loads(raw) result = json.loads(raw) if isinstance(raw, str) else raw
except json.JSONDecodeError: if isinstance(result, list):
try: return result
escaped = raw.encode("unicode_escape").decode("utf-8") logger.warning(f"System config key '{key}' is not a list: {type(result)}")
return json.loads(escaped) return default
except Exception as e: except (json.JSONDecodeError, TypeError) as e:
logging.error(f"Failed to parse {key}: {e}") logger.error(f"Failed to parse system list key '{key}': {e}")
return json.loads(default) return default
def load_user_config(config_dir: Path) -> dict:
"""
Load user configuration from user_config.json.
Creates the file with defaults if it doesn't exist.
Parameters:
config_dir: Directory containing user_config.json
Returns:
The user config dict
"""
global _user_config
user_config_path = config_dir / "user_config.json"
if not user_config_path.exists():
# Create default user config
default_user_config = {
"TELEMETRY": "false",
"TEXTUAL_THEME": "gruvbox",
"EXTRAS": "NOTTODAY",
}
user_config_path.parent.mkdir(parents=True, exist_ok=True)
with open(user_config_path, "w") as f:
json.dump(default_user_config, f, indent=4)
logger.debug(f"Created default user config at {user_config_path}")
_user_config = default_user_config
else:
with open(user_config_path, "r") as f:
_user_config = json.load(f)
logger.debug(f"✅ Loaded user config from {user_config_path}")
return _user_config
def save_user_config(config_dir: Path, updates: dict) -> None:
"""
Save updates to user configuration.
Only keys in USER_CONFIG_KEYS are allowed.
Parameters:
config_dir: Directory containing user_config.json
updates: Dict of key-value pairs to update
"""
global _user_config
# Validate that only user-configurable keys are being updated
invalid_keys = [k for k in updates.keys() if k not in USER_CONFIG_KEYS]
if invalid_keys:
logger.error(f"Attempted to save invalid user config keys: {invalid_keys}")
raise ValueError(f"Cannot modify system config keys: {invalid_keys}")
# Update in-memory config
_user_config.update(updates)
# Write to file
user_config_path = config_dir / "user_config.json"
user_config_path.parent.mkdir(parents=True, exist_ok=True)
with open(user_config_path, "w") as f:
json.dump(_user_config, f, indent=4)
logger.debug(f"✅ Saved user config to {user_config_path}: {updates}")
def get_user_value(
key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None
) -> Optional[T]:
"""
Get a value from user config (mutable).
Parameters:
key: The config key to retrieve
cast_type: Function to cast the value to desired type
default: Default value if key not found
Returns:
The config value cast to the desired type, or default
"""
value = _user_config.get(key)
if value is None:
logger.warning(f"User config key '{key}' not found.")
return default
try:
if isinstance(value, str):
value = value.strip("'\"")
return cast_type(value)
except (ValueError, TypeError):
logger.warning(
f"Invalid value for user key '{key}': {value}. Expected type {cast_type.__name__}."
)
return default
def load_env( def load_env(
key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None
) -> Optional[T]: ) -> Optional[T]:
""" """
Safely retrieves an environment variable and casts it to the desired type. Safely retrieves an environment variable from .env and casts it to the desired type.
This should ONLY be used for runtime/dynamic values like WORKING_DIR.
For system config, use get_system_value().
For user config, use get_user_value().
Parameters: Parameters:
key (str): The name of the environment variable. key: The name of the environment variable
cast_type (Callable[[str], T], optional): Function to cast the value. Defaults to str. cast_type: Function to cast the value. Defaults to str
default (Optional[T], optional): Default value if the variable is not set or invalid. default: Default value if the variable is not set or invalid
Returns: Returns:
Optional[T]: The casted value or the default. The casted value or the default
""" """
value = os.getenv(key) value = os.getenv(key)
if value is None: if value is None:
logger.warning(f"Environment variable '{key}' not set.") logger.debug(f"Environment variable '{key}' not set, using default.")
return default return default
try: try:
value = value.strip("'\"") # Strip surrounding quotes value = value.strip("'\"") # Strip surrounding quotes
return cast_type(value) return cast_type(value)
@@ -139,3 +299,46 @@ def load_env(
f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}." f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}."
) )
return default return default
def load_env_json(key: str, default: str = "[]") -> Any:
"""
Load a JSON value from environment or system config.
DEPRECATED: This function is kept for backward compatibility.
- For system config lists (BAD_PUBLISHERS, PUPS, BAD_PATH_PARTS), use get_system_list()
- For system config dicts, use get_system_json()
- For actual .env JSON values, parse manually
This function automatically redirects known system config keys to system config.
"""
# Known system config list keys - redirect to system config
system_list_keys = ["BAD_PUBLISHERS", "PUPS", "BAD_PATH_PARTS"]
if key in system_list_keys:
logger.debug(f"Redirecting load_env_json('{key}') to get_system_list()")
return get_system_list(key, json.loads(default) if default else [])
# Known system config dict keys - redirect to system config
system_dict_keys = ["POLICY_MAP_ENF_AUD"]
if key in system_dict_keys:
logger.debug(f"Redirecting load_env_json('{key}') to get_system_json()")
return get_system_json(key, json.loads(default) if default else {})
# Fall back to reading from .env (backward compatibility for unknown keys)
raw = os.getenv(key, default)
try:
return json.loads(raw)
except json.JSONDecodeError:
try:
escaped = raw.encode("unicode_escape").decode("utf-8")
return json.loads(escaped)
except Exception as e:
logger.error(f"Failed to parse {key}: {e}")
return json.loads(default)
# Backwards compatibility aliases (deprecated - use get_system_value instead)
get_protected_value = get_system_value
get_protected_json = get_system_json
load_protected_config = load_system_config
PROTECTED_KEYS = SYSTEM_CONFIG_KEYS # For backwards compatibility
+29 -80
View File
@@ -13,18 +13,20 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import logging import logging
import logging.config import logging.config
import logging.handlers import logging.handlers
import os import os
from pathlib import Path from pathlib import Path
import platform import platform
import sys
from dotenv import load_dotenv, set_key from dotenv import load_dotenv, set_key
from utils.configmanager import PROTECTED_KEYS, load_protected_config from utils.configmanager import (
get_system_value,
load_system_config,
load_user_config,
)
def get_base_directory() -> Path: def get_base_directory() -> Path:
@@ -38,7 +40,7 @@ def get_base_directory() -> Path:
return home / ".local" / "share" / "Loxide" return home / ".local" / "share" / "Loxide"
def configure_logging(log_dir: Path, log_level: str = "DEBUG"): def configure_logging(log_dir: Path, log_level: str = "INFO"):
log_file = log_dir / "Loxide.log" log_file = log_dir / "Loxide.log"
config = { config = {
@@ -62,12 +64,12 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"):
"interval": 1, # Every 1 day "interval": 1, # Every 1 day
"backupCount": 7, # Keep 7 days of logs "backupCount": 7, # Keep 7 days of logs
"encoding": "utf-8", # Ensure UTF-8 encoding "encoding": "utf-8", # Ensure UTF-8 encoding
"level": "DEBUG", # Always log DEBUG and above "level": "DEBUG", # Always log DEBUG and above to file
"formatter": "detailed", # Use detailed format "formatter": "detailed", # Use detailed format
}, },
"console": { "console": {
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"level": log_level.upper(), # Configurable log level "level": log_level.upper(), # System-configured level for console
"formatter": "simple", # Use simple format "formatter": "simple", # Use simple format
}, },
}, },
@@ -95,59 +97,15 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"):
logging.getLogger().debug("✅ Logging configured.") logging.getLogger().debug("✅ Logging configured.")
def get_system_config_path() -> Path:
base_path = Path(
getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
)
return base_path.parent / "system_config.json"
def load_system_config() -> dict:
try:
config_path = get_system_config_path()
with open(config_path, "r") as f:
return json.load(f)
except FileNotFoundError:
logging.warning("⚠️ system_config.json not found. Using built-in defaults.")
return {
"APPNAME": "Loxide",
"LOG_LEVEL": "DEBUG",
"PATH_EXCLUSION_CONST": 4,
"MIN_FILES_FOR_PATH": 4,
"VT_THREAT_TOLERANCE": 4,
"POLICY_MAP_ENF_AUD": {"enforced_id": "audit_id"},
}
def load_user_config(config_dir: Path) -> dict:
user_config_path = config_dir / "user_config.json"
if not user_config_path.exists():
default_user_config = {
"TELEMETRY": "FALSE",
"TEXTUAL_THEME": "gruvbox",
"EXTRAS": "NOTTODAY",
}
with open(user_config_path, "w") as f:
json.dump(default_user_config, f, indent=4)
logging.debug(f"Created user config at {user_config_path}")
with open(user_config_path, "r") as f:
return json.load(f)
def write_config_to_env(config: dict, env_path: Path):
for key, value in config.items():
if key in PROTECTED_KEYS:
continue # Skip protected keys
try:
serialized = (
json.dumps(value) if isinstance(value, (list, dict)) else str(value)
)
set_key(env_path, key, serialized)
except Exception as e:
logging.warning(f"Failed to write {key} to .env: {e}")
def setup(): def setup():
"""
Initialize the application environment:
1. Create directory structure
2. Load system config (immutable, from system_config.json)
3. Load user config (mutable, from user_config.json)
4. Configure logging
5. Set up .env with WORKING_DIR only
"""
base_dir = get_base_directory() base_dir = get_base_directory()
dirs = { dirs = {
"config": base_dir / "config", "config": base_dir / "config",
@@ -159,20 +117,30 @@ def setup():
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
logging.debug(f"{name.capitalize()} directory ensured at: {path}") logging.debug(f"{name.capitalize()} directory ensured at: {path}")
# Load system config (immutable)
system_config = load_system_config() system_config = load_system_config()
configure_logging(dirs["logs"], system_config.get("LOG_LEVEL", "DEBUG"))
# Configure logging with system-defined log level
log_level = get_system_value("LOG_LEVEL", str, "INFO")
configure_logging(dirs["logs"], log_level)
# Load user config (mutable)
user_config = load_user_config(dirs["config"])
# Set up .env file - ONLY for WORKING_DIR (runtime-configurable value)
env_path = base_dir / ".env" env_path = base_dir / ".env"
if not env_path.exists(): if not env_path.exists():
env_path.touch() env_path.touch()
load_dotenv(dotenv_path=env_path, override=True) load_dotenv(dotenv_path=env_path, override=True)
# Set up working directory (only dynamic value in .env)
working_dir = Path(os.getenv("WORKING_DIR") or (base_dir / "data")) working_dir = Path(os.getenv("WORKING_DIR") or (base_dir / "data"))
working_dir.mkdir(parents=True, exist_ok=True) working_dir.mkdir(parents=True, exist_ok=True)
set_key(env_path, "WORKING_DIR", str(working_dir)) set_key(str(env_path), "WORKING_DIR", str(working_dir))
os.environ["WORKING_DIR"] = str(working_dir) os.environ["WORKING_DIR"] = str(working_dir)
logging.debug(f"Working directory set to: {working_dir}") logging.debug(f"Working directory set to: {working_dir}")
# Create folder structure in working directory
folders_structure = { folders_structure = {
"Approved": [], "Approved": [],
"Needs_Review": ["Review_First", "Review_Second", "HTML"], "Needs_Review": ["Review_First", "Review_Second", "HTML"],
@@ -189,23 +157,4 @@ def setup():
subfolder_path.mkdir(parents=True, exist_ok=True) subfolder_path.mkdir(parents=True, exist_ok=True)
logging.debug(f" └─ '{subfolder}' subfolder created at: {subfolder_path}") logging.debug(f" └─ '{subfolder}' subfolder created at: {subfolder_path}")
user_config = load_user_config(dirs["config"]) logging.info("✅ Setup complete")
merged_config = {**system_config, **user_config}
protected_config = load_protected_config()
merged_config.update(protected_config)
# ✅ URL resolution order: system_config → .env → user prompt
url = system_config.get("URL")
if not url:
url = os.getenv("URL")
if not url:
url = input(
"🌐 Enter the service URL (e.g., https://example.com/api): "
).strip()
merged_config["URL"] = url
set_key(env_path, "URL", url)
os.environ["URL"] = url
logging.debug(f"Service URL set to: {url}")
write_config_to_env(merged_config, env_path)
View File