feat: add version updater and statistics enhancements (fixes #29)

- Implemented version checking system with update notifications
- Integrated Git for fetching and downloading the latest version
- Added statistics updates
- Removed unused code across the project
- Condensed project structure
- Updated README
- Cleaned up UI
This commit is contained in:
2025-12-19 16:31:36 -05:00
parent 09d2c125cd
commit e1e0cb7ac7
26 changed files with 1906 additions and 3806 deletions
-7
View File
@@ -336,10 +336,3 @@ def load_env_json(key: str, default: str = "[]") -> Any:
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
+78
View File
@@ -0,0 +1,78 @@
# 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 gc
import json
import logging
from bson import ObjectId
import pandas as pd
from services.API import AirlockAPIWrapper
logger = logging.getLogger(__name__)
def getExecutions(api: AirlockAPIWrapper, policy, type, days):
import airlock_libs
executionhist_policy = pd.DataFrame()
exehist = airlock_libs.pull_policy_exec_histories(api, policy.name, str(type), days)
if exehist is not None:
data = json.loads(exehist)
executionhist_policy = pd.DataFrame(data["response"]["exechistories"])
if not executionhist_policy.empty:
executionhist_policy = executionhist_policy[
[
"datetime",
"sha256",
"publisher",
"filename",
"hostname",
"username",
"pprocess",
"gprocess",
"commandline",
]
]
executionhist_policy["policy"] = policy # Add policy column here
executionhist_policy = executionhist_policy.drop_duplicates(
subset=["sha256", "filename", "hostname"]
)
executionhist_policy = executionhist_policy.sort_values(
by=["sha256", "filename"]
)
logger.debug(f"Staging of Execution history for policy: {policy} is complete")
del data
del exehist
gc.collect()
return executionhist_policy
def skipback(days):
"""
Generate a MongoDB ObjectId for a given number of days ago from today.
"""
adjusted_days = days
date_days_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta(
days=adjusted_days
)
timestamp = int(date_days_ago.timestamp())
hex_timestamp = format(timestamp, "08x")
objectid_hex = hex_timestamp + "0000000000000000"
return ObjectId(objectid_hex)
+171
View File
@@ -0,0 +1,171 @@
# 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 base64
from getpass import getpass
import logging
import os
import platform
import re
import sys
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import keyring
# Constants
KDF_ITERATIONS = 200_000
SALT_SIZE = 16 # 128-bit Salt
NONCE_SIZE = 12 # AES-GCM
KEY_SIZE = 32 # AES-256
logger = logging.getLogger(__name__)
def _derive_key(password: bytes, salt: bytes) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=KEY_SIZE,
salt=salt,
iterations=KDF_ITERATIONS,
)
return kdf.derive(password)
def configure_keyring_backend():
system = platform.system()
if system == "Windows":
import keyring.backends.Windows
keyring.set_keyring(keyring.backends.Windows.WinVaultKeyring())
elif system == "Linux":
import keyring.backends.kwallet
keyring.set_keyring(keyring.backends.kwallet.DBusKeyring())
else:
raise EnvironmentError(f"Unsupported OS: {system}")
def store_api_key(service: str, username: str, api_key: str, password: str):
configure_keyring_backend()
salt = os.urandom(SALT_SIZE)
key = _derive_key(password.encode(), salt)
aesgcm = AESGCM(key)
nonce = os.urandom(NONCE_SIZE)
ct = aesgcm.encrypt(nonce, api_key.encode(), associated_data=None)
blob = salt + nonce + ct
b64 = base64.b64encode(blob).decode()
keyring.set_password(service, username, b64)
logger.debug(
f"API key for service '{service}' and user '{username}' stored successfully."
)
print("\n✅ API key stored securely.")
print("The program will now exit. Press Enter to continue...")
try:
_ = input()
except Exception:
pass
_ = None
sys.exit(0)
def retrieve_api_key(service: str, username: str, password: str) -> str:
configure_keyring_backend()
b64 = keyring.get_password(service, username)
if b64 is None:
raise ValueError("No stored secret for this service/username.")
blob = base64.b64decode(b64)
salt = blob[:SALT_SIZE]
nonce = blob[SALT_SIZE : SALT_SIZE + NONCE_SIZE]
ct = blob[SALT_SIZE + NONCE_SIZE :]
key = _derive_key(password.encode(), salt)
aesgcm = AESGCM(key)
pt = aesgcm.decrypt(nonce, ct, associated_data=None)
return pt.decode()
def api_key_exists(service: str, username: str) -> bool:
configure_keyring_backend()
return keyring.get_password(service, username) is not None
def check_password_complexity(password: str) -> bool:
if len(password) < 12:
return False
if not re.search(r"[A-Z]", password):
return False
if not re.search(r"[a-z]", password):
return False
if not re.search(r"[0-9]", password):
return False
if not re.search(r"[^A-Za-z0-9]", password):
return False
return True
def getAPI(USERNAME, SERVICE_NAME):
logging.debug(
f"Checking for stored API key for user '{USERNAME}' in service '{SERVICE_NAME}'..."
)
if api_key_exists(SERVICE_NAME, USERNAME):
for attempt in range(1, 4):
password = getpass(
f"Attempt {attempt}/3 - Enter password to unlock your API key: "
)
try:
apikey = retrieve_api_key(SERVICE_NAME, USERNAME, password)
logging.debug("API key successfully retrieved.")
return apikey
except Exception as e:
logging.warning(f"Attempt {attempt} failed: {str(e)}")
logging.error("Failed to retrieve API key after 3 incorrect attempts.")
raise ValueError("Failed to retrieve API key after 3 incorrect attempts.")
else:
logging.warning(
f"No API key found for user '{USERNAME}' in service '{SERVICE_NAME}'."
)
api_key = getpass(
f"No API key found. Please enter your API key for '{SERVICE_NAME}': "
).strip()
print(
"Please exit and relaunch program after saving your credential to avoid errors"
)
while True:
password = getpass("Create a password to encrypt your API key: ")
confirm_password = getpass("Confirm your password: ")
if password != confirm_password:
logging.warning("Passwords do not match. Try again.")
continue
if check_password_complexity(password):
try:
store_api_key(SERVICE_NAME, USERNAME, api_key, password)
logging.info("API key stored securely.")
break
except Exception as e:
logging.error(f"Failed to store API key: {e}")
break
else:
logging.warning(
"Password does not meet complexity requirements. Try again."
)
-357
View File
@@ -1,357 +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 logging
from typing import Any, Callable, List, Optional, Union
import pandas as pd
from utils.utils import colorText, get_sanitized_input
logger = logging.getLogger(__name__)
class Selector:
@staticmethod
def _get_sorted_items(
items: List[Any], label_func: Callable[[Any], str]
) -> List[Any]:
return sorted(items, key=lambda item: label_func(item).lower())
@staticmethod
def _display_choices(
items: List[Any],
label_func: Callable[[Any], str],
num_columns: int = 3,
header: str = "Available Choices:",
) -> None:
# Force single column if items are DataFrame rows
if items and isinstance(items[0], (pd.Series, dict)):
num_columns = 1
rows = (len(items) + num_columns - 1) // num_columns
print(f"\n{header}")
for row in range(rows):
line = ""
for col in range(num_columns):
idx = row + col * rows
if idx < len(items):
label = label_func(items[idx])
line += f"{idx + 1}: {label:<30}"
print(line)
@staticmethod
def _display_selected_items(
selected: List[Any], label_func: Callable[[Any], str], num_columns: int = 3
) -> None:
print(colorText("\nCurrent selections:", "cyan"))
if not selected:
print(" (none)")
return
sorted_selected = sorted(selected, key=lambda item: label_func(item).lower())
rows = (len(sorted_selected) + num_columns - 1) // num_columns
for row in range(rows):
line = ""
for col in range(num_columns):
idx = row + col * rows
if idx < len(sorted_selected):
label = label_func(sorted_selected[idx])
line += f"{label:<30}"
print(line)
@staticmethod
def _parse_selection_input(input_str: str, max_index: int) -> List[int]:
selections = []
for part in input_str.split(","):
part = part.strip()
if "-" in part:
try:
start, end = map(int, part.split("-"))
selections.extend(range(start, end + 1))
except ValueError:
continue
elif part.isdigit():
selections.append(int(part))
return [i for i in selections if 1 <= i <= max_index]
@staticmethod
def _select_from_list(
items: List[Any],
label_func: Callable[[Any], str],
allow_multiple: bool = False,
prompt_each: bool = False,
header: str = "Available Choices:",
num_columns: int = 3,
) -> Union[Optional[Any], List[Any]]:
if not items:
logger.warning("No items available for selection.")
return None
full_sorted_items = Selector._get_sorted_items(items, label_func)
remaining_items = full_sorted_items.copy()
selected = []
if allow_multiple:
while True:
Selector._display_choices(
remaining_items, label_func, num_columns=num_columns, header=header
)
Selector._display_selected_items(
selected, label_func, num_columns=num_columns
)
choice = (
get_sanitized_input(
"Select item(s) by number (e.g. 1,3-5), R to reset, Q to finish: "
)
.strip()
.lower()
)
if choice == "q":
break
elif choice == "r":
selected.clear()
remaining_items = full_sorted_items.copy()
print(colorText("🔄 Selections reset.", "yellow"))
continue
indices = Selector._parse_selection_input(choice, len(remaining_items))
newly_selected = []
for index in indices:
item = remaining_items[index - 1]
if item not in selected:
selected.append(item)
newly_selected.append(item)
if prompt_each:
logger.info(f"Selected: {label_func(item)}")
else:
logger.warning("Item already selected.")
remaining_items = [
item for item in remaining_items if item not in newly_selected
]
return selected if selected else None
else:
Selector._display_choices(
full_sorted_items, label_func, num_columns=num_columns, header=header
)
try:
choice = int(get_sanitized_input("Select one item by number: "))
if 1 <= choice <= len(full_sorted_items):
selected_item = full_sorted_items[choice - 1]
logger.info(f"Selected: {label_func(selected_item)}")
return selected_item
else:
logger.warning("Selection out of range.")
except ValueError:
logger.warning("Invalid input.")
return None
@staticmethod
def select_with_mode(
items: List[Any],
label_func: Callable[[Any], str],
header: str = "Available Choices:",
) -> List[Any]:
print(
colorText(
"Choose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):",
"white",
)
)
mode = get_sanitized_input("").strip().lower()
if mode == "a":
return items
selected = Selector._select_from_list(
items,
label_func=label_func,
allow_multiple=True,
prompt_each=False,
header=header,
)
if not selected:
return items
if mode == "i":
print(colorText(f"✅ Included {len(selected)} item(s).", "green"))
return selected
elif mode == "e":
print(colorText(f"👫 Excluded {len(selected)} item(s).", "yellow"))
return [item for item in items if item not in selected]
else:
print(colorText("⚠️ Invalid mode. Returning all items.", "yellow"))
return items
@staticmethod
def select_objects(
objects: List[Any], allow_multiple: bool = False, prompt_each: bool = False
) -> Union[Optional[Any], List[Any]]:
return Selector._select_from_list(
objects,
label_func=lambda obj: getattr(obj, "name", str(obj)),
allow_multiple=allow_multiple,
prompt_each=prompt_each,
header="Available Objects:",
)
@staticmethod
def select_string(
options: List[str], allow_multiple: bool = False, prompt_each: bool = False
) -> Union[Optional[str], List[str]]:
return Selector._select_from_list(
options,
label_func=str,
allow_multiple=allow_multiple,
prompt_each=prompt_each,
header="Available Options:",
)
@staticmethod
def select_int(
options: List[int], allow_multiple: bool = False, prompt_each: bool = False
) -> Union[Optional[int], List[int]]:
return Selector._select_from_list(
options,
label_func=lambda x: str(x),
allow_multiple=allow_multiple,
prompt_each=prompt_each,
header="Available Integers:",
)
@staticmethod
def select_value(
prompt: str,
value_type: type = int,
valid_range: Optional[tuple] = None,
allow_quit: bool = False,
) -> Optional[Any]:
while True:
user_input = get_sanitized_input(prompt).strip().lower()
if allow_quit and user_input == "q":
logger.info("User opted to quit value selection.")
return None
try:
value = value_type(user_input)
if valid_range:
min_val, max_val = valid_range
if not (min_val <= value <= max_val):
logger.warning(f"Value out of range ({min_val}{max_val}).")
continue
logger.info(f"User selected value: {value}")
return value
except ValueError:
logger.warning(f"Invalid input. Expected a {value_type.__name__}.")
@staticmethod
def confirm(prompt: str = "Are you sure? (Y/N): ") -> bool:
while True:
response = get_sanitized_input(prompt).strip().lower()
if response in ["y", "yes"]:
logger.info("User confirmed action.")
return True
elif response in ["n", "no"]:
logger.info("User declined action.")
return False
else:
logger.warning("Invalid confirmation input. Expected 'Y' or 'N'.")
@staticmethod
def select_dataframe_rows(
df: pd.DataFrame,
columns: Optional[List[str]] = None,
allow_multiple: bool = False,
prompt_each: bool = False,
header: str = "Available Rows:",
) -> List[pd.Series]:
if df.empty:
print("DataFrame is empty.")
return []
if columns:
df = df[columns]
items = [row for _, row in df.iterrows()]
def label_func(row):
return str(row.to_dict())
result = Selector._select_from_list(
items,
label_func=label_func,
allow_multiple=allow_multiple,
prompt_each=prompt_each,
header=header,
)
if isinstance(result, pd.Series):
return [result]
elif isinstance(result, list):
return result
else:
return []
@staticmethod
def select_dataframe_with_mode(
df: pd.DataFrame,
columns: Optional[List[str]] = None,
header: str = "Available Rows:",
) -> List[pd.Series]:
if df.empty:
print("⚠️ DataFrame is empty.")
return []
# Filter columns if specified
if columns:
df = df[columns]
items = df.to_dict("records")
def label_func(row):
return " | ".join(str(row[col]) for col in df.columns)
# Show rows first
print(colorText(header, "cyan"))
for i, row in enumerate(items):
print(f"{i}: {label_func(row)}")
# Prompt for mode once
print(
colorText(
"\nChoose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):",
"white",
)
)
mode = get_sanitized_input("").strip().lower()
if mode == "a":
return [pd.Series(row) for row in items]
# Prompt for selection only once
selected = Selector._select_from_list(
items,
label_func=label_func,
allow_multiple=True,
prompt_each=False,
header=header,
)
if not selected:
return [pd.Series(row) for row in items]
if mode == "i":
print(colorText(f"✅ Included {len(selected)} row(s).", "green"))
return [pd.Series(row) for row in selected]
elif mode == "e":
print(colorText(f"👫 Excluded {len(selected)} row(s).", "yellow"))
return [pd.Series(row) for row in items if row not in selected]
else:
print(colorText("⚠️ Invalid mode. Returning no rows.", "yellow"))
return []
+26 -3
View File
@@ -83,7 +83,18 @@ def get_base_directory() -> Path:
return home / ".local" / "share" / "Loxide"
def configure_logging(log_dir: Path, log_level: str = "INFO"):
def configure_logging(log_dir: Path, cache_dir: Path, log_level: str = "INFO"):
"""
Configure logging and return function to attach notification handler.
Args:
log_dir: Directory for log files
cache_dir: Directory for cache files (used by version checker)
log_level: Logging level string
Returns:
Function to attach notification handler to Textual app
"""
log_file = log_dir / "Loxide.log"
config = {
@@ -134,7 +145,8 @@ def configure_logging(log_dir: Path, log_level: str = "INFO"):
# Return a function to attach the notification handler once the app is created
def attach_notification_handler(app):
"""Attach the Textual notification handler to the root logger."""
"""Attach the Textual notification handler and version checker to the app."""
# Attach logging handler
handler = TextualNotificationHandler(app)
handler.setLevel(logging.WARNING) # Only WARNING and above
formatter = logging.Formatter("%(name)s: %(message)s")
@@ -142,6 +154,17 @@ def configure_logging(log_dir: Path, log_level: str = "INFO"):
logging.getLogger().addHandler(handler)
logging.getLogger().debug("✅ Textual notification handler attached.")
# Attach version checker (checks in background, notifies if update available)
try:
from utils.versionchecker import create_update_notifier
create_update_notifier(app, cache_dir=cache_dir)
logging.getLogger().debug("✅ Version checker attached.")
except ImportError as e:
logging.getLogger().debug(f"Version checker not available: {e}")
except Exception as e:
logging.getLogger().warning(f"Could not initialize version checker: {e}")
return attach_notification_handler
@@ -173,7 +196,7 @@ def setup():
# Configure logging with system-defined log level
log_level = get_system_value("LOG_LEVEL", str, "INFO")
attach_handler = configure_logging(dirs["logs"], log_level)
attach_handler = configure_logging(dirs["logs"], dirs["cache"], log_level)
# Load user config (mutable)
load_user_config(dirs["config"])
-437
View File
@@ -19,81 +19,10 @@ import os
import platform
import re
import subprocess
import tempfile
import tkinter as tk
from tkinter import filedialog
import pandas as pd
logger = logging.getLogger(__name__)
def import_to_dataframe(file_path: str) -> pd.DataFrame:
df = pd.DataFrame()
try:
if not os.path.exists(file_path):
print(colorText(f"Error: File '{file_path}' does not exist.", "red"))
return df
ext = os.path.splitext(file_path)[1].lower()
if ext == ".csv":
df = pd.read_csv(file_path)
elif ext == ".parquet":
df = pd.read_parquet(file_path)
else:
print(colorText(f"Error: Unsupported file extension '{ext}'.", "red"))
return df
if df.empty:
print(colorText("Error: File has headers but no data rows.", "red"))
else:
print(colorText(f"Data loaded successfully from {file_path}", "green"))
return df
except pd.errors.EmptyDataError:
print(
colorText(
"Notice: CSV file is completely empty, falling back to empty frame",
"white",
)
)
return pd.DataFrame()
except Exception as e:
print(colorText(f"Error reading file: {e}", "red"))
return pd.DataFrame()
def choose_directory():
root = tk.Tk()
root.withdraw() # Hide the main window
directory = filedialog.askdirectory(title="Select a Directory")
print("Selected directory:", directory)
return directory
def choose_file(initial_directory=None, required_substring=None):
"""Open a file dialog and ensure the selected file contains a required substring."""
while True:
root = tk.Tk()
root.withdraw() # Hide the main window
file_path = filedialog.askopenfilename(initialdir=initial_directory)
if not file_path:
print("No file selected.")
return None
if required_substring and required_substring not in file_path:
print(
f"The selected file must contain '{required_substring}' in its path or name. Please try again."
)
else:
return file_path
def get_sanitized_input(prompt: str) -> str:
while True:
user_input = input(prompt)
@@ -151,235 +80,6 @@ def irtang():
)
def section_header(title):
print(
colorText(
"\n --------------------------------------------------------------------",
"cyan",
)
)
print(colorText(f" ------------- {title} -------------", "cyan"))
print(
colorText(
" --------------------------------------------------------------------",
"cyan",
)
)
def areYouSure():
print(
colorText(
"🛑****************************************************************************************************************************************🛑",
"red",
)
)
print(
colorText(
"⚠️=========================================================================================================================================⚠️",
"yellow",
)
)
print(
colorText(
"🛑========================================================================================================================================🛑",
"red",
)
)
print(
colorText(
"⚠️-------------This program will now begin to make changes to the Airlock Console. Do you understand and agree to proceed? ----------------⚠️",
"yellow",
)
)
print(
colorText(
"🛑========================================================================================================================================🛑",
"red",
)
)
print(
colorText(
"⚠️=========================================================================================================================================⚠️",
"yellow",
)
)
print(
colorText(
"🛑****************************************************************************************************************************************🛑",
"red",
)
)
def locked():
print(
colorText(
r"""
████████████████████████████████████████████████████████████████
███ ██
██ ██████ ███
██ ████████████ ███
██ ████ ███ ███
██ ███ ███ ███
██ ███ ███ ███
██ ▒████████████████████ ███
██ ██████████████████████ ███
██ ██████████████████████ ███
██ ██████████████████████ ███
██ ██████████████████████ ███
██ ██████████████████████ ███
██ ███
███ ███
████████████████████████████████████████████████████████████████████
▒██████████████████████████████████████████████████████████████████▒
▒████
▒████
▓██████████████████████████████████████████
█████████████████████████████████████████████░
""",
"yellow",
)
)
def printDeviceEnforceChecklist():
print(
colorText(
"\n --------------------------------------------------------------------",
"cyan",
)
)
print(
colorText(
" ------------- 🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒 ------------------",
"cyan",
)
)
print(
colorText(
" --------------------------------------------------------------------",
"cyan",
)
)
print(
colorText(
"\nSequentually follow these steps to prepare a policy for enforcement:",
"white",
)
)
print(
colorText(
"\n1. Choose which originating policy or policies to move to enforcement",
"cyan",
)
)
print(
colorText(
"2. Pull and stage event history, combine the histories, add hash info, then categorize the hashes",
"cyan",
)
)
print(colorText("3. Manually review the files:", "cyan"))
print(
colorText(
" 'needs_approved\\good_{first_policy}_{second_policy}.csv' and 'needs_approved\\unknown_{first_policy}_{second_policy}.csv'",
"cyan",
)
)
print(
colorText(
" Remove the rows containing hashes you do not approve of, and those you would not approve of without metarules.",
"cyan",
)
)
print(
colorText(
" If metarules need to be created, please make note of them, and remove the row from the csv.",
"cyan",
)
)
print(
colorText(
" When complete, save both csv files to the directory 'approved' and choose this option.",
"cyan",
)
)
print(
colorText(
" This will combine these approved hashes with the automatically approved hashes and generate a list of paths to be reviewed",
"cyan",
)
)
print(
colorText(
"4. Manually review the file 'needs_approved\\paths_needing_review.csv'",
"cyan",
)
)
print(
colorText(
" Remove the rows containing path exclusions you do not approve of",
"cyan",
)
)
print(
colorText(
" When complete, save the csv file to the directory 'approved'", "cyan"
)
)
print(
colorText(
" Do the same process with the list of publishers forthe same directories",
"cyan",
)
)
print(colorText(" Preflight Lists will be generated", "cyan"))
print(
colorText(
"5. Choose the destination policy and parent and child allow list", "cyan"
)
)
print(
colorText(
"6. Test ------------------------------------------------------", "cyan"
)
)
print(colorText(" Print rather than apply selected data.", "cyan"))
print(
colorText(
"7. Liftoff ------------------------------------------------------", "cyan"
)
)
print(
colorText(
" Apply path exclusions according to allowed and approved paths",
"cyan",
)
)
print(
colorText(" Apply signed or attested hashes to Parent Allow List", "cyan")
)
print(
colorText(
" Apply approved, but unsigned hashes to the Child Allow List", "cyan"
)
)
print(
colorText(
"R. Remove/Reset Generated data - will prompt to allow keeping execution history",
"cyan",
)
)
print(colorText("B. Back", "cyan"))
def colorText(text, color):
colors = {
"red": "\033[91m",
@@ -394,133 +94,6 @@ def colorText(text, color):
return f"{colors.get(color, colors['reset'])}{text}{colors['reset']}"
def formatHTML(df, output_html_path=None, overwrite=True):
from datetime import datetime
# Get current date and filename for subtitle
today = datetime.now().strftime("%d %B %Y") # Changed to "Day Month Year"
filename = output_html_path.replace(".html", "") if output_html_path else "Report"
dark_css = """
<style>
body {
background-color: #000000;
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #f8f8f2;
}
.header {
text-align: center;
margin: 20px auto;
padding: 10px;
border-bottom: 2px solid #ffd700;
max-width: 95%;
}
.header h1 {
color: #ffd700;
margin: 0;
font-size: 32px;
}
.header p {
color: #00bfff;
margin: 5px 0 0 0;
font-size: 18px;
}
.table-container {
overflow-y: scroll;
margin: 0 auto;
width: 95%;
max-height: calc(80vh - 100px);
display: block;
border: 1px solid #3a3a4d;
margin-bottom: 0;
}
table {
border-collapse: collapse;
font-size: 14px;
background-color: #1e1e2f;
color: #f8f8f2;
width: max-content;
}
th, td {
border: 1px solid #3a3a4d;
text-align: left;
padding: 10px;
max-width: 300px;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* First column: no wrap */
td:nth-child(1), th:nth-child(1) {
white-space: nowrap;
max-width: none !important;
word-wrap: normal !important;
}
th {
background-color: #2e2e40;
color: #ffd700;
position: sticky;
top: 0;
z-index: 10;
}
tr:nth-child(even) {
background-color: #262638;
}
tr:hover {
background-color: #33334d;
color: #00bfff;
}
/* Custom scrollbar styling */
.table-container::-webkit-scrollbar {
width: 12px;
}
.table-container::-webkit-scrollbar-track {
background: #1e1e2f;
}
.table-container::-webkit-scrollbar-thumb {
background-color: #3a3a4d;
border-radius: 6px;
}
</style>
"""
header = f"""
<div class="header">
<h1>Airlock Tools</h1>
<p>{filename} - {today}</p>
</div>
"""
html_table = df.to_html(index=False, escape=False)
styled_html = (
f"<html>\n"
f"<head><title>Airlock Tools Report</title></head>\n"
f"<body>\n"
f"{dark_css}\n"
f"{header}\n"
f"<div class='table-container'>\n"
f" {html_table}\n"
f"</div>\n"
f"</body>\n"
f"</html>"
)
if output_html_path:
with open(output_html_path, "w", encoding="utf-8") as f:
f.write(styled_html)
print(f"✅ Styled table saved to '{output_html_path}'")
elif overwrite:
with tempfile.NamedTemporaryFile(
suffix=".html", delete=False, mode="w", encoding="utf-8"
) as f:
f.write(styled_html)
temp_path = f.name
print(f"✅ Styled table saved to temporary file: {temp_path}")
else:
return styled_html
def open_directory(path):
system = platform.system()
@@ -530,13 +103,3 @@ def open_directory(path):
subprocess.run(["xdg-open", path])
else:
raise OSError(f"Unsupported operating system: {system}")
def print_x_wide(items: list, width: int):
for i in range(0, len(items), width):
row = items[i : i + width]
print(" | ".join(row))
def clear_screen():
os.system("cls" if os.name == "nt" else "clear")
+560
View File
@@ -0,0 +1,560 @@
# 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/>.
"""
Version checking and update notification system for Loxide.
Checks against Gitea releases at:
https://git.racooncity.org/brotoskyj/AirlockTools/releases
"""
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import json
import logging
from pathlib import Path
import re
import threading
from typing import Callable, Optional
import requests
logger = logging.getLogger(__name__)
# Current application version - UPDATE THIS ON EACH RELEASE
__version__ = "0.7.0"
# Gitea release API configuration
GITEA_API_BASE = "https://git.racooncity.org/api/v1"
REPO_OWNER = "brotoskyj"
REPO_NAME = "AirlockTools"
RELEASES_URL = f"{GITEA_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/releases"
RELEASES_PAGE_URL = f"https://git.racooncity.org/{REPO_OWNER}/{REPO_NAME}/releases"
# How often to check for updates (in hours)
CHECK_INTERVAL_HOURS = 24
@dataclass
class ReleaseInfo:
"""Information about a release."""
tag_name: str
version: tuple # Parsed semantic version (major, minor, patch)
name: str
body: str # Release notes
published_at: datetime
html_url: str
download_url: Optional[str] = None # URL to download the release asset
is_prerelease: bool = False
@dataclass
class UpdateCheckResult:
"""Result of an update check."""
current_version: str
latest_version: Optional[str]
update_available: bool
release_info: Optional[ReleaseInfo]
error: Optional[str] = None
def parse_version(version_str: str) -> Optional[tuple]:
"""
Parse a version string into a comparable tuple.
Supports formats: v1.2.3, 1.2.3, v1.2, 1.2
Returns (major, minor, patch) tuple or None if parsing fails.
"""
if not version_str:
return None
# Strip 'v' prefix if present
clean = version_str.lstrip("vV").strip()
# Match semantic version pattern
match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?", clean)
if not match:
return None
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
patch = int(match.group(3)) if match.group(3) else 0
return (major, minor, patch)
def compare_versions(v1: tuple, v2: tuple) -> int:
"""
Compare two version tuples.
Returns:
-1 if v1 < v2
0 if v1 == v2
1 if v1 > v2
"""
for a, b in zip(v1, v2):
if a < b:
return -1
if a > b:
return 1
return 0
def get_current_version() -> str:
"""Get the current application version."""
return __version__
def _parse_release_response(release_data: dict) -> Optional[ReleaseInfo]:
"""Parse a release from Gitea API response."""
try:
tag_name = release_data.get("tag_name", "")
version = parse_version(tag_name)
if not version:
logger.debug(f"Could not parse version from tag: {tag_name}")
return None
# Parse published date
published_str = release_data.get("published_at", "")
try:
published_at = datetime.fromisoformat(published_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
published_at = datetime.now(UTC)
# Get download URL from assets if available
download_url = None
assets = release_data.get("assets", [])
for asset in assets:
# Prefer .exe or .zip files
name = asset.get("name", "").lower()
if name.endswith((".exe", ".zip", ".msi")):
download_url = asset.get("browser_download_url")
break
return ReleaseInfo(
tag_name=tag_name,
version=version,
name=release_data.get("name", tag_name),
body=release_data.get("body", ""),
published_at=published_at,
html_url=release_data.get("html_url", RELEASES_PAGE_URL),
download_url=download_url,
is_prerelease=release_data.get("prerelease", False),
)
except Exception as e:
logger.warning(f"Failed to parse release data: {e}")
return None
def fetch_latest_release(
include_prerelease: bool = False, timeout: int = 10
) -> Optional[ReleaseInfo]:
"""
Fetch the latest release from Gitea.
Args:
include_prerelease: Whether to include pre-release versions
timeout: Request timeout in seconds
Returns:
ReleaseInfo for the latest release, or None if fetch fails
"""
try:
response = requests.get(
RELEASES_URL,
params={"limit": 10}, # Get last 10 releases to find latest stable
timeout=timeout,
headers={"Accept": "application/json"},
)
response.raise_for_status()
releases = response.json()
if not releases:
logger.debug("No releases found")
return None
# Find the latest release (first non-prerelease if we're excluding them)
for release_data in releases:
release_info = _parse_release_response(release_data)
if release_info is None:
continue
if include_prerelease or not release_info.is_prerelease:
return release_info
# If all are prereleases and we're excluding them, return the first one anyway
# but log a warning
if releases:
logger.debug("All releases are pre-releases")
return _parse_release_response(releases[0])
return None
except requests.exceptions.Timeout:
logger.warning("Timeout fetching releases from Gitea")
return None
except requests.exceptions.RequestException as e:
logger.warning(f"Failed to fetch releases: {e}")
return None
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"Failed to parse release response: {e}")
return None
def check_for_updates(include_prerelease: bool = False) -> UpdateCheckResult:
"""
Check if a newer version is available.
Args:
include_prerelease: Whether to consider pre-release versions
Returns:
UpdateCheckResult with the check results
"""
current = get_current_version()
current_parsed = parse_version(current)
if not current_parsed:
return UpdateCheckResult(
current_version=current,
latest_version=None,
update_available=False,
release_info=None,
error="Could not parse current version",
)
release_info = fetch_latest_release(include_prerelease=include_prerelease)
if release_info is None:
return UpdateCheckResult(
current_version=current,
latest_version=None,
update_available=False,
release_info=None,
error="Could not fetch release information",
)
is_newer = compare_versions(release_info.version, current_parsed) > 0
return UpdateCheckResult(
current_version=current,
latest_version=release_info.tag_name,
update_available=is_newer,
release_info=release_info,
)
class VersionChecker:
"""
Background version checker that periodically checks for updates
and can notify the application when updates are available.
"""
def __init__(
self,
cache_dir: Optional[Path] = None,
check_interval_hours: int = CHECK_INTERVAL_HOURS,
on_update_available: Optional[Callable[[UpdateCheckResult], None]] = None,
):
"""
Initialize the version checker.
Args:
cache_dir: Directory to store last check timestamp
check_interval_hours: Hours between automatic checks
on_update_available: Callback when update is available
"""
self.cache_dir = cache_dir
self.check_interval = timedelta(hours=check_interval_hours)
self.on_update_available = on_update_available
self._last_check: Optional[datetime] = None
self._last_result: Optional[UpdateCheckResult] = None
self._check_thread: Optional[threading.Thread] = None
self._dismissed_version: Optional[str] = None
# Load cached state
self._load_cache()
@property
def cache_file(self) -> Optional[Path]:
if self.cache_dir:
return self.cache_dir / "version_check_cache.json"
return None
def _load_cache(self) -> None:
"""Load cached check state."""
if not self.cache_file or not self.cache_file.exists():
return
try:
with open(self.cache_file, "r") as f:
data = json.load(f)
if "last_check" in data:
self._last_check = datetime.fromisoformat(data["last_check"])
# Don't load dismissed_version - dismiss is session-only
except (json.JSONDecodeError, ValueError, OSError) as e:
logger.debug(f"Could not load version check cache: {e}")
def _save_cache(self) -> None:
"""Save check state to cache."""
if not self.cache_file:
return
try:
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
data = {}
if self._last_check:
data["last_check"] = self._last_check.isoformat()
# Don't save dismissed_version - dismiss is session-only
with open(self.cache_file, "w") as f:
json.dump(data, f)
except OSError as e:
logger.debug(f"Could not save version check cache: {e}")
def should_check(self) -> bool:
"""Determine if enough time has passed to check again."""
if self._last_check is None:
return True
elapsed = datetime.now(UTC) - self._last_check
return elapsed >= self.check_interval
def check_now(
self, force: bool = False, include_prerelease: bool = False
) -> UpdateCheckResult:
"""
Check for updates immediately.
Args:
force: Check even if recently checked
include_prerelease: Include pre-release versions
Returns:
UpdateCheckResult
"""
if not force and not self.should_check() and self._last_result:
return self._last_result
result = check_for_updates(include_prerelease=include_prerelease)
self._last_check = datetime.now(UTC)
self._last_result = result
self._save_cache()
# Notify if update available and not dismissed
if (
result.update_available
and self.on_update_available
and result.latest_version != self._dismissed_version
):
self.on_update_available(result)
return result
def check_async(
self, force: bool = False, include_prerelease: bool = False
) -> None:
"""
Check for updates in background thread.
Args:
force: Check even if recently checked
include_prerelease: Include pre-release versions
"""
if self._check_thread and self._check_thread.is_alive():
return # Already checking
if not force and not self.should_check():
return # Too soon to check again
def _check():
try:
self.check_now(force=True, include_prerelease=include_prerelease)
except Exception as e:
logger.debug(f"Background version check failed: {e}")
self._check_thread = threading.Thread(target=_check, daemon=True)
self._check_thread.start()
def dismiss_update(self, version: str) -> None:
"""
Dismiss update notification for a specific version.
Only lasts for the current session - will nag again on next startup.
Args:
version: Version to dismiss (e.g., "v1.2.3")
"""
# Session-only dismiss - don't save to cache
self._dismissed_version = version
def clear_dismissed(self) -> None:
"""Clear the dismissed version so user will be nagged again."""
self._dismissed_version = None
def get_last_result(self) -> Optional[UpdateCheckResult]:
"""Get the result of the last check."""
return self._last_result
# Global instance for easy access
_global_checker: Optional[VersionChecker] = None
def get_version_checker(
cache_dir: Optional[Path] = None,
on_update_available: Optional[Callable[[UpdateCheckResult], None]] = None,
) -> VersionChecker:
"""
Get or create the global version checker instance.
Args:
cache_dir: Directory for caching (only used on first call)
on_update_available: Callback for updates (only used on first call)
Returns:
The global VersionChecker instance
"""
global _global_checker
if _global_checker is None:
_global_checker = VersionChecker(
cache_dir=cache_dir,
on_update_available=on_update_available,
)
return _global_checker
def format_update_message(result: UpdateCheckResult, short: bool = False) -> str:
"""
Format a human-readable update message.
Args:
result: The update check result
short: Whether to use a short format
Returns:
Formatted message string
"""
if not result.update_available:
return f"✅ Loxide is up to date (v{result.current_version})"
if short:
return f"🆕 Update available: {result.latest_version}"
msg = f"🆕 Loxide {result.latest_version} is available! (current: v{result.current_version})"
if result.release_info:
msg += f"\n📥 Download: {result.release_info.html_url}"
# Include release notes preview if available
if result.release_info.body:
notes = result.release_info.body.strip()
# Truncate if too long
if len(notes) > 200:
notes = notes[:200] + "..."
msg += f"\n\n📋 Release Notes:\n{notes}"
return msg
# ---------------------------------------------------------------------------
# Textual TUI Integration
# ---------------------------------------------------------------------------
def create_update_notifier(
app, cache_dir: Optional[Path] = None, nag_on_startup: bool = True
):
"""
Create a version checker that notifies via Textual toast notifications.
This should be called after the Textual app is created.
Args:
app: The Textual App instance
cache_dir: Directory for caching check state
nag_on_startup: Always show notification on startup if update available
Returns:
The VersionChecker instance
"""
def on_update_available(result: UpdateCheckResult):
"""Callback when update is available - show toast notification."""
try:
msg = f"🆕 Update available: {result.latest_version}\nGo to Settings to download"
try:
app.notify(
msg, title="Loxide Update Available", severity="warning", timeout=15
)
except RuntimeError:
app.call_from_thread(
app.notify,
msg,
title="Loxide Update Available",
severity="warning",
timeout=15,
)
except Exception as e:
logger.debug(f"Could not show update notification: {e}")
checker = get_version_checker(
cache_dir=cache_dir,
on_update_available=on_update_available,
)
# Store checker on app so Loxide.on_mount can use it
if nag_on_startup:
app._version_checker = checker
app._version_nag_shown = False
return checker
def check_for_updates_startup(
cache_dir: Optional[Path] = None,
) -> Optional[UpdateCheckResult]:
"""
Check for updates during application startup.
This performs a synchronous check but respects the cache interval,
so it will only actually query the network once per CHECK_INTERVAL_HOURS.
Returns the result if an update is available, None otherwise.
Example usage:
result = check_for_updates_startup(cache_dir)
if result and result.update_available:
print(format_update_message(result))
"""
checker = get_version_checker(cache_dir=cache_dir)
# Only check if enough time has passed (uses cache)
if not checker.should_check():
result = checker.get_last_result()
if result and result.update_available:
return result
return None
result = checker.check_now(force=False)
if result.update_available:
return result
return None