diff --git a/.gitignore b/.gitignore index 5145354..7d2a7e0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,12 @@ *.csv *__pycache__* *.parquet -chunkinator.json \ No newline at end of file +chunkinator.json +jobs.json +*.xl* +*.exe +securitytest.py +*.toml +system_config.json +Development/ +AirlockTools_client*/ \ No newline at end of file diff --git a/AirlockTools.py b/AirlockTools.py deleted file mode 100644 index d86dcb2..0000000 --- a/AirlockTools.py +++ /dev/null @@ -1,257 +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 . - -import dotenv -import os -import pandas as pd -import urllib3 -import utils.allowlist -import utils.getdeviceevents -import utils.hashfunctions -import utils.pathfunctions -import utils.policyfunctions -import utils.pretty as ct - - -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - -dotenv.load_dotenv() - -#Constants -url = os.getenv('url') -bad_publisher_list = ["Brave","Zoom", "GlavSoft", "VNC"] -pups = ["logmein", "invalid", "nmap"] -badpathparts = ["users", "wwwroot", "windows\\temp", "windows\\task", "windows\\system32", "startup", "windows\\fonts", "Recycle.Bin", "AppData", "programdata", "Solarwinds", "kaseya"] -path_exclusion_constant = 4 -min_files_for_path = 4 -threat_tolerance_constant = 4 - -def apivalidation(): - match os.getenv('APIKEY'): - case '': - print(ct.colorText("Please add your API Key to the .env file", "red")) - case _: - menu_main() - -def tryToReadCSV(csv): - try: - df =pd.read_csv(csv) - if df.empty: - print(ct.colorText("Error: CSV file has headers but no data rows.", "red")) - else: - print(ct.colorText(f"Data loaded successfully from {csv}", "green")) - except pd.errors.EmptyDataError: - print(ct.colorText("Notice : CSV file is completely empty (no headers, no data), falling back to empty frame", "white")) - df = pd.DataFrame() # Create an empty DataFrame as fallback - return df - -def tryToReadParquet(parquet): - try: - df = pd.read_parquet(parquet) - if df.empty: - print(ct.colorText("Error: Parquet file has headers but no data rows.", "red")) - else: - print(ct.colorText(f"Data loaded successfully from {parquet}", "green")) - except pd.errors.EmptyDataError: - print(ct.colorText("Notice : Parquet file is completely empty (no headers, no data), falling back to empty frame", "white")) - df = pd.DataFrame() # Create an empty DataFrame as fallback - return df - -def deduplicate_list(lst): - seen = set() - return [x for x in lst if not (x in seen or seen.add(x))] - -def menu_main(): - while True: - ct.displayIntro(); - print(ct.colorText("1. Get All Events for Single Device", "yellow")) - print(ct.colorText("2. Placeholder for Local Approval", "yellow")) - print(ct.colorText("3. Placeholder for Another Tool", "yellow")) - print(ct.colorText("4. Prepare Policy For Enforcement", "yellow")) - print(ct.colorText("Q. Quit", "yellow")) - - choice = input(ct.colorText("\nEnter Menu Item: ", "white")) - if choice == '1': - utils.getdeviceevents.devicehistory(url,False) - elif choice == "2": - menu_local_approve() - elif choice == "3": - menu_feature2() - elif choice == "4": - menu_prepare_to_enforce() - elif choice == "Q": - break - else: - print(ct.colorText("Invalid choice. Please try again.","red")) - -def menu_local_approve(): - while True: - print("\n--- Submenu ---") - print("1. Sub-option A") - print("2. Sub-option B") - print("3. Return to Main Menu") - choice = input("Enter your choice: ") - - if choice == "1": - print("You selected Sub-option A") - elif choice == "2": - print("You selected Sub-option B") - elif choice == "3": - print("Returning to Main Menu...") - break - else: - print("Invalid choice. Please try again.") - -def menu_feature2(): - while True: - print("\n--- Submenu ---") - print("1. Sub-option A") - print("2. Sub-option B") - print("3. Return to Main Menu") - choice = input("Enter your choice: ") - - if choice == "1": - print("You selected Sub-option A") - elif choice == "2": - print("You selected Sub-option B") - elif choice == "3": - print("Returning to Main Menu...") - break - else: - print("Invalid choice. Please try again.") - -def menu_prepare_to_enforce(): - - first_policy = " " - second_policy = " " - destination_name = " " - destination_id = " " - allowlist_parent_name = " " - allowlist_parent_id = " " - allowlist_child_name = " " - allowlist_child_id = " " - - #If the directorys where we're going to store our output dont exist, make them. - if not os.path.exists("parquet"): os.makedirs("parquet") - if not os.path.exists("needs_approved"): os.makedirs("needs_approved") - if not os.path.exists("approved"): os.makedirs("approved") - if not os.path.exists("preflight"): os.makedirs("preflight") - - while True: - - ct.printEnforceChecklist(first_policy, second_policy, allowlist_child_name, allowlist_parent_name, destination_name) - - choice = input(ct.colorText("\nEnter your choice: ", "white")) - - if choice == "1": - - choice, policynames, policyid = utils.allowlist.listPolicies(url) - first_policy = policynames[choice] - while True: - answer = input(ct.colorText(f"{"Do you want to load a second policy?"} (yes/no): ", "white").strip().lower()) - if answer in ("yes", "y"): - choice, policynames, policyid = utils.allowlist.listPolicies(url) - second_policy = policynames[choice] - - break - elif answer in ("no", "n"): - second_policy = first_policy - break - else: - print(ct.colorText("Please answer with 'yes' or 'no'.", "red")) - - elif choice == "2": - - if not os.path.exists(f"parquet\\execution_history_{first_policy}.parquet"): - utils.policyfunctions.getPolicyInfo(url, first_policy, 60) - - if not os.path.exists(f"parquet\\execution_history_{second_policy}.parquet"): - utils.policyfunctions.getPolicyInfo(url, second_policy, 60) - - if not os.path.exists(f"parquet\\combined_hashlist_{first_policy}_{second_policy}.parquet"): - utils.hashfunctions.combineHashes(url, first_policy, second_policy) - - if not os.path.exists(f"parquet\\hashes_rep_unknown_{first_policy}_{second_policy}.parquet") and not os.path.exists(f"parquet\\hashes_rep_good_{first_policy}_{second_policy}.parquet") and not os.path.exists(f"parquet\\hashes_rep_bad_{first_policy}_{second_policy}.parquet"): - utils.hashfunctions.categorizeHashes( - first_policy, - second_policy, - pd.read_parquet(f"parquet\\combined_hashlist_{first_policy}_{second_policy}.parquet"), - threat_tolerance_constant, - bad_publisher_list, - pups - ) - - if not os.path.exists(f"parquet\\condensed_executions_{first_policy}_{second_policy}.parquet"): - utils.hashfunctions.condenseExecutions(first_policy,second_policy) - - if os.path.exists(f"parquet\\hashes_rep_unknown_{first_policy}_{second_policy}.parquet") & os.path.exists(f"parquet\\hashes_rep_good_{first_policy}_{second_policy}.parquet") & os.path.exists(f"parquet\\hashes_rep_bad_{first_policy}_{second_policy}.parquet"): - utils.hashfunctions.divideSortedHashExecutions(first_policy,second_policy,pups) - - elif choice == "3": - - if os.path.exists(f"approved\\hashes_rep_unknown_{first_policy}_{second_policy}.csv") and os.path.exists(f"approved\\hashes_rep_good_{first_policy}_{second_policy}.csv"): - utils.pathfunctions.generatePathReview(first_policy, second_policy, badpathparts, min_files_for_path) - else: - print(ct.colorText(f"Please manually approve hashes prior to this step","red")) - - elif choice == "4": - - if os.path.exists(f"approved\\path_needs_approved_{first_policy}_{second_policy}.csv"): - if not os.path.exists(f"parquet\\final_hash_approvals_{first_policy}_{second_policy}.parquet") and not os.path.exists(f"parquet\\final_path_exclusions_{first_policy}_{second_policy}.parquet"): - utils.hashfunctions.generatePreflights(first_policy, second_policy) - - elif choice == "5": - - print(ct.colorText(f"Please choose destination_name Policy for Path Exclusions","white")) - choice, policynames, policyid = utils.allowlist.listPolicies(url) - #print(allowlist_parent_tuple) - destination_name = policynames[choice] - destination_id = policyid[choice] - - print(ct.colorText(f"Please choose Parent Allowlist for Known Hashes","white")) - choice, allowlists,allowid = utils.allowlist.listAllowlists(url) - #print(allowlist_parent_tuple) - allowlist_parent_name = allowlists[choice] - allowlist_parent_id = allowid[choice] - - print(ct.colorText(f"Please choose Child Allowlist for Less-Known Hashes","white")) - choice, allowlists, allowid = utils.allowlist.listAllowlists(url) - #print(allowlist_child_tuple) - allowlist_child_name = allowlists[choice] - allowlist_child_id = allowid[choice] - - elif choice == "6": - if os.path.exists(f"preflight\\final_path_exclusions_{first_policy}_{second_policy}.html") and os.path.exists(f"preflight\\final_hash_approvals_{first_policy}_{second_policy}.html") and allowlist_parent_name != " " and allowlist_child_name != " " and destination_name != " ": - utils.policyfunctions.sendToPolicy( - url, - first_policy, - second_policy, - destination_name, - destination_id, - allowlist_parent_name, - allowlist_parent_id, - allowlist_child_name, - allowlist_child_id - ) - - elif choice == "Q": - break - else: - print(ct.colorText("Invalid choice. Please try again.", "red")) - - -if __name__ == "__main__": - apivalidation() - diff --git a/AirlockTools_Client.py b/AirlockTools_Client.py new file mode 100644 index 0000000..c2d4093 --- /dev/null +++ b/AirlockTools_Client.py @@ -0,0 +1,90 @@ +# 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 . + + +#TODO Continue implementing logger +#TODO Add input sanitation and CSV injection prevention +#TODO Continue OTP and Local approval rewrites +#TODO Explore pywin32 +#TODO Fix Requirements.txt +#TODO Create Generic system_config.json for gitea + +import logging +import os +import tempfile +import dotenv +import urllib3 + +from services.API import AirlockAPIWrapper +from services.security import getAPI +from utils.setup import get_base_directory, setup +from utils.TUI import run_AirlockTools +from utils.utils import irtang + +urllib3.disable_warnings( + urllib3.exceptions.InsecureRequestWarning +) + +def main(): + + + if "NUITKA_ONEFILE_PARENT" in os.environ: + splash_filename = os.path.join( + tempfile.gettempdir(), + f"onefile_{int(os.environ['NUITKA_ONEFILE_PARENT'])}_splash_feedback.tmp" + ) + if os.path.exists(splash_filename): + os.unlink(splash_filename) + + + + irtang() + #Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored + setup() + base_dir = get_base_directory() + logger = logging.getLogger(__name__) + dotenv.load_dotenv(dotenv_path=base_dir / ".env") + + try: + url = os.getenv("URL") + username = os.getenv("USERNAME") + + if not url: + raise ValueError("Missing URL in environment variables.") + if not username: + raise ValueError("Missing USERNAME in environment variables.") + + logger.debug(f"Retrieved URL: {url}") + logger.debug(f"Retrieved Username: {username}") + + except ValueError as e: + logger.error(f"Configuration error: {e}", exc_info=True) + raise + + + api_key = getAPI(username, "AirlockTools") + if api_key is None: + raise ValueError("API key for AirlockTools is missing.") + + api = AirlockAPIWrapper( + base_url=str(os.getenv("URL")), + api_key=api_key, + ) + run_AirlockTools(api) + + + +if __name__ == "__main__": + main() diff --git a/AirlockTools_Server.py b/AirlockTools_Server.py new file mode 100644 index 0000000..56a58a0 --- /dev/null +++ b/AirlockTools_Server.py @@ -0,0 +1,90 @@ +# 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 . + + + +#TODO Add CSV injection prevention +#TODO Continue OTP and Local approval rewrites +#TODO Explore pywin32 +#TODO Fix Requirements.txt +#TODO Create Generic system_config.json for gitea + + +import logging +import os + +import dotenv +import urllib3 + +import flows.localApproval as la +from Server.scheduler_async import recurring_job, register_function, reload_jobs, start_scheduler +from services.API import AirlockAPIWrapper +from services.policyhandler import updateAuditPoliciesFromEnforcementPolices +from services.security import getAPI +from utils.setup import setup + +urllib3.disable_warnings( + urllib3.exceptions.InsecureRequestWarning +) + +def main(): + + #Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored + + working_dir = setup() + + logger = logging.getLogger(__name__) + + dotenv.load_dotenv(dotenv_path=working_dir / ".env") + + try: + url = os.getenv("URL") + username = os.getenv("USERNAME") + + if not url: + raise ValueError("Missing URL in environment variables.") + if not username: + raise ValueError("Missing USERNAME in environment variables.") + + logger.debug(f"Retrieved URL: {url}") + logger.debug(f"Retrieved Username: {username}") + + except ValueError as e: + logger.error(f"Configuration error: {e}", exc_info=True) + raise + + api = AirlockAPIWrapper( + base_url=str(os.getenv("URL")), + api_key = getAPI(username, "AirlockTools"), + ) + + + logger.info("Running non-interactively to start monitoring Airlock Changes") + + + register_function("monitorLA", la.scheduleAddingLAHashes) + register_function("updateAuditPolicies", updateAuditPoliciesFromEnforcementPolices) + + + if not os.path.exists("scheduling\\jobs.json"): + recurring_job("monitorLA", "monitorLA", interval=50, unit="seconds", args=[api]) + recurring_job("updateAuditPolicies", "updateAudit", interval=5, unit="minutes", args=[api]) + else: + reload_jobs() + + start_scheduler() + +if __name__ == "__main__": + main() diff --git a/IRT_icon_32-512.ico b/IRT_icon_32-512.ico new file mode 100644 index 0000000..103b72e Binary files /dev/null and b/IRT_icon_32-512.ico differ diff --git a/README.md b/README.md index 86edfdb..a464e7a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,59 @@ -[![AGPL License](https://img.shields.io/badge/license-AGPL-blue.svg)](http://www.gnu.org/licenses/agpl-3.0) -# Airlock Digital Local Approval +# 🛡️ Airlock Tools -Python based Carbon Black App Control feature implementation for Airlock -## Features +Python toolkit for secure, auditable, and automated airlock agent and policy management. Designed for enterprise environments, it supports advanced policy workflows, device tracking, and terminal-based interaction. -- "Local Approval Initialization" -This programmatically scans devices in audit mode within Airlock and subsequently adds the identified blocks to a user-specified whitelist. +--- + + +## 🚀 Features +- 🔍 **Fuzzy Device Search** + Quickly locate devices using partial or approximate matches. + +- 📦 **Batch Move Devices** + Move multiple devices between groups or policies easily. + +- 🔄 **Toggle Enforcement/Audit Policies** + Seamlessly switch devices between enforcement and audit modes. + +- 🕵️‍♂️ **Device History Search** + Track agent executions. + +- 🧰 **Prepare Policies for Enforcement** + Validate and stage policies before pushing them to enforcement. + +- 💤 **Find Quiet Hosts** + Identify devices ready for enforcement. + +- 🎛️ **TUI** + Navigate with arrow keys and F-key shortcuts using a custom ANSI-colored terminal UI. + +--- + +## 🧭 Roadmap + +- ⚙️ **Rust-based Async API Calls** + Improve performance and concurrency with a Rust-powered backend. + +- ✅ **Carbon Black-style Local Approval** + Enable local user approvals for policy exceptions and enforcement actions. + +- 📊 **Audit Logging & Export** + Add detailed logging and export capabilities for compliance and analysis. + +--- + +## 🧑‍💻 Requirements + +[airlock_libs](https://git.racooncity.org/brotoskyj/-/packages/pypi/airlock-libs/0.1.1) + +--- + +## 📜 License -## License **AirlockTools** is licensed under the **GNU Affero General Public License v3.0**. You may copy, distribute, and modify the software under the terms of the AGPL-3.0 license. -See the [LICENSE](LICENSE.md) file for full details, or visit -[https://www.gnu.org/license/agpl-3.0.html](https://www.gnu.org/license/agpl-3.0.html) \ No newline at end of file +See the [LICENSE](LICENSE.md) file for full details, or visit +[https://www.gnu.org/license/agpl-3.0.html](https://www.gnu.org/license/agpl-3.0.html) diff --git a/Server/scheduler_async.py b/Server/scheduler_async.py new file mode 100644 index 0000000..6f90d36 --- /dev/null +++ b/Server/scheduler_async.py @@ -0,0 +1,192 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import asyncio +import json +import logging +import os +from typing import Any, Callable, Dict, List + +logger = logging.getLogger(__name__) + +# Registry of functions that can be scheduled +FUNCTION_MAP: Dict[str, Callable] = {} + +# Dictionary to manually track scheduled jobs by ID +scheduled_jobs: Dict[str, asyncio.TimerHandle] = {} + +# Path to the JSON file for job persistence TODO - pin this to the correct place +JOBS_FILE = os.path.join(os.getcwd(), "jobs.json") + +def register_function(name: str, func: Callable): + """ + Register a function so it can be called by name later. + Example: + register_function("say_hello", say_hello) + """ + FUNCTION_MAP[name] = func + +def load_jobs() -> List[Dict[str, Any]]: + """ + Load jobs from the JSON file, or return [] if none exist. + """ + if not os.path.exists(JOBS_FILE): + return [] + with open(JOBS_FILE, "r") as f: + return json.load(f) + +def save_jobs(jobs: List[Dict[str, Any]]): + """ + Save jobs to the JSON file (overwrite). + """ + with open(JOBS_FILE, "w") as f: + json.dump(jobs, f, indent=4) + +def cancel_job(job_id: str): + """ + Cancel a scheduled job by ID and remove it from the registry and persistence. + """ + handle = scheduled_jobs.pop(job_id, None) + if handle: + handle.cancel() + logger.info(f"Cancelled job '{job_id}'") + + jobs = [j for j in load_jobs() if j.get("id") != job_id] + save_jobs(jobs) + +def run_once_job(job_id: str, func_name: str, delay_seconds: float, args=None, kwargs=None, persist=True): + """ + Schedule a job to run once after a delay (in seconds). + """ + args = args or [] + kwargs = kwargs or {} + + def job_wrapper(): + func = FUNCTION_MAP.get(func_name) + if func is None: + logger.error(f"Function '{func_name}' is not registered.") + return + func(*args, **kwargs) + cancel_job(job_id) + + loop = asyncio.get_event_loop() + handle = loop.call_later(delay_seconds, job_wrapper) + scheduled_jobs[job_id] = handle + + if persist: + jobs = [j for j in load_jobs() if j.get("id") != job_id] + jobs.append({ + "id": job_id, + "type": "once", + "delay": delay_seconds, + "function": func_name, + "args": args, + "kwargs": kwargs + }) + save_jobs(jobs) + logger.info(f"Scheduled one-time job '{job_id}' to run in {delay_seconds} seconds.") + +def recurring_job(job_id: str, func_name: str, interval: float, args=None, kwargs=None, persist=True): + """ + Schedule a recurring job. + """ + args = args or [] + kwargs = kwargs or {} + + def job_wrapper(): + func = FUNCTION_MAP.get(func_name) + if func is None: + logger.error(f"Function '{func_name}' is not registered.") + return + func(*args, **kwargs) + # Reschedule the job + handle = asyncio.get_event_loop().call_later(interval, job_wrapper) + scheduled_jobs[job_id] = handle + + cancel_job(job_id) + handle = asyncio.get_event_loop().call_later(interval, job_wrapper) + scheduled_jobs[job_id] = handle + + if persist: + jobs = [j for j in load_jobs() if j.get("id") != job_id] + jobs.append({ + "id": job_id, + "type": "recurring", + "interval": interval, + "function": func_name, + "args": args, + "kwargs": kwargs + }) + save_jobs(jobs) + logger.info(f"Scheduled recurring job '{job_id}' every {interval} seconds.") + +def reload_jobs(): + """ + Reload jobs from JSON and reschedule them. + """ + jobs = load_jobs() + for job in jobs: + if job["type"] == "once": + run_once_job( + job["id"], + job["function"], + job["delay"], + job.get("args"), + job.get("kwargs"), + persist=False + ) + elif job["type"] == "recurring": + recurring_job( + job["id"], + job["function"], + job["interval"], + job.get("args"), + job.get("kwargs"), + persist=False + ) + +async def start_scheduler(): + """ + Start the asynchronous scheduler loop. + + This function is a placeholder to keep the event loop alive. + Jobs are scheduled using asyncio.call_later and do not require polling. + """ + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + logger.critical("Scheduler stopped.") + + """ + Start the asynchronous scheduler loop. + + This function is a placeholder for compatibility. Since we use asyncio.call_later, + jobs are scheduled directly on the event loop and no polling is required. + + Usage: + # In an async app (e.g., Textual) + asyncio.create_task(start_scheduler()) + + # Or in a standalone script + async def main(): + await start_scheduler() + + asyncio.run(main()) + """ + try: + while True: + await asyncio.sleep(3600) # Sleep indefinitely; jobs run via call_later + except asyncio.CancelledError: + logger.critical("Scheduler stopped.") \ No newline at end of file diff --git a/airlock_libs/.gitea/workflows/build.yaml b/airlock_libs/.gitea/workflows/build.yaml new file mode 100644 index 0000000..e69de29 diff --git a/airlock_libs/.gitignore b/airlock_libs/.gitignore new file mode 100644 index 0000000..84ed743 --- /dev/null +++ b/airlock_libs/.gitignore @@ -0,0 +1,3 @@ +/target +build.sh +pythontest.py \ No newline at end of file diff --git a/airlock_libs/Cargo.lock b/airlock_libs/Cargo.lock new file mode 100644 index 0000000..5faf19a --- /dev/null +++ b/airlock_libs/Cargo.lock @@ -0,0 +1,3016 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "airlock_libs" +version = "1.0.1" +dependencies = [ + "chrono", + "indicatif", + "mongodb", + "pyo3", + "reqwest", + "serde", + "serde-pyobject", + "serde_json", + "tokio", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bson" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" +dependencies = [ + "ahash", + "base64 0.22.1", + "bitvec", + "getrandom 0.2.16", + "getrandom 0.3.4", + "hex", + "indexmap 2.12.0", + "js-sys", + "once_cell", + "rand 0.9.2", + "serde", + "serde_bytes", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "console" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b430743a6eb14e9764d4260d4c0d8123087d504eeb9c48f2b2a5e810dd369df4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive-syn-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive-where" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.12.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.8.5", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.24.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand 0.8.5", + "resolv-conf", + "smallvec", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.1", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade6dfcba0dfb62ad59e59e7241ec8912af34fd29e0e743e3db992bd278e8b65" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "macro_magic" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc33f9f0351468d26fbc53d9ce00a096c8522ecb42f19b50f34f2c422f76d21d" +dependencies = [ + "macro_magic_core", + "macro_magic_macros", + "quote", + "syn", +] + +[[package]] +name = "macro_magic_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1687dc887e42f352865a393acae7cf79d98fab6351cde1f58e9e057da89bf150" +dependencies = [ + "const-random", + "derive-syn-parse", + "macro_magic_core_macros", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "macro_magic_core_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b02abfe41815b5bd98dbd4260173db2c116dda171dc0fe7838cb206333b83308" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "macro_magic_macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ea28ee64b88876bf45277ed9a5817c1817df061a74f2b988971a12570e5869" +dependencies = [ + "macro_magic_core", + "quote", + "syn", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mongocrypt" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22426d6318d19c5c0773f783f85375265d6a8f0fa76a733da8dc4355516ec63d" +dependencies = [ + "bson", + "mongocrypt-sys", + "once_cell", + "serde", +] + +[[package]] +name = "mongocrypt-sys" +version = "0.1.4+1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda42df21d035f88030aad8e877492fac814680e1d7336a57b2a091b989ae388" + +[[package]] +name = "mongodb" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f272c59e54a3c85f5902c6b8e7b1653a6b6681f45e4c42d6581301119a4b8" +dependencies = [ + "async-trait", + "base64 0.13.1", + "bitflags 1.3.2", + "bson", + "chrono", + "derive-where", + "derive_more", + "futures-core", + "futures-executor", + "futures-io", + "futures-util", + "hex", + "hickory-proto", + "hickory-resolver", + "hmac", + "macro_magic", + "md-5", + "mongocrypt", + "mongodb-internal-macros", + "once_cell", + "pbkdf2", + "percent-encoding", + "rand 0.8.5", + "rustc_version_runtime", + "rustls", + "rustversion", + "serde", + "serde_bytes", + "serde_with", + "sha1", + "sha2", + "socket2 0.5.10", + "stringprep", + "strsim", + "take_mut", + "thiserror", + "tokio", + "tokio-rustls", + "tokio-util", + "typed-builder", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "mongodb-internal-macros" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63981427a0f26b89632fd2574280e069d09fb2912a3138da15de0174d11dd077" +dependencies = [ + "macro_magic", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8e48c12afdeb26aa4be4e5c49fb5e11c3efa0878db783a960eea2b9ac6dd19" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc1989dbf2b60852e0782c7487ebf0b4c7f43161ffe820849b56cf05f945cee1" +dependencies = [ + "python3-dll-a", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c808286da7500385148930152e54fb6883452033085bf1f857d85d4e82ca905c" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0543c16be0d86cf0dbf2e2b636ece9fd38f20406bb43c255e0bc368095f92" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a00da2ce064dcd582448ea24a5a26fa9527e0483103019b741ebcbe632dcd29" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "python3-dll-a" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d381ef313ae70b4da5f95f8a4de773c6aa5cd28f73adec4b4a31df70b66780d8" +dependencies = [ + "cc", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustc_version_runtime" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" +dependencies = [ + "rustc_version", + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1317c3bf3e7df961da95b0a56a172a02abead31276215a0497241a7624b487ce" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-pyobject" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5614e792b7c36d3feeb45b8287ea2dc615f063896e59258d11ef5015c18bdf6a" +dependencies = [ + "log", + "pyo3", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "indexmap 2.12.0", + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.0", + "schemars 0.9.0", + "schemars 1.0.5", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "unit-prefix" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.4", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/airlock_libs/README.md b/airlock_libs/README.md new file mode 100644 index 0000000..1eea549 --- /dev/null +++ b/airlock_libs/README.md @@ -0,0 +1,59 @@ + +# Airlock Libs + +A Rust implementation of common Airlock API integrations for use in [AirlockTools](https://git.racooncity.org/brotoskyj/AirlockTools) + +## Deployment + +To install and use this library in a standalone Python script +### Install Using pip +Follow the instructions [here](https://git.racooncity.org/brotoskyj/-/packages/pypi/airlock-libs/) +### Install Wheel +#### Linux + +```bash + cd target/wheels/ + python3 -m pip install airlock_libs--cp313-manylinux_2_34_x86_64.whl +``` + +#### Windows +```powershell + cd target/wheels + python3 -m pip install airlock_libs--cp313-win_amd64.whl +``` + +or + +### Import DLL/SO +#### Linux + +```bash +cd /target/x86_64-pc-windows-gnu/release/ +Copy libairlock_libs.so to current project directory +``` + +#### Windows +```powershell +cd /target/x86_64-unknown-linux-gnu/release/ +Copy airlock_libs.dll to current project directory +``` +## Usage/Examples + +```python +import airlock_libs + +def pullExecHistories() { + airlock_libs.pull_policy_exec_histories(api, execution_types, checkpoint_number, policy_names) +} +``` + + +## Features + +- Pull Policy Execution Histories + + +## License + +[AGPLv3](https://choosealicense.com/licenses/agpl-3.0/) + diff --git a/airlock_libs/airlock_libs.pyi b/airlock_libs/airlock_libs.pyi new file mode 100644 index 0000000..2e061f1 --- /dev/null +++ b/airlock_libs/airlock_libs.pyi @@ -0,0 +1,87 @@ +from typing import Dict, List, Optional +def pull_policy_exec_histories(self, type: List[str], checkpoint: str, policy: List[str]) -> str: + """Retrieve execution history logs.""" + +def api(AirlockAPIWrapper): + """ + An implementation of the python AirlockAPIWrapper class to pass Python data into Rust + + Parameters + ---------- + base_url : str + (Required) Base URL of the Airlock API, this should be in your .env file. + api_key : str + (Required) API Key for your profile in airlock, this should be in your credential manager. + headers : {"X-APIKey": self.api_key} + + ```def __init__(self, base_url: str, api_key: str): + self.base_url = base_ur.rstrip("/") + self.api_key = api_key + self.headers = {"X-APIKey": self.api_key} + ``` + """ + +def history_logging( + api, + exec_types: str, + checkpoint_number: str, + policy_names: str, + ) -> List[Dict[str, Any]]: + """ + Query execution history logs from the Airlock API. + + Parameters + ---------- + exec_types : str + A JSON-style string list of execution types to retrieve. + Example: "[3,5,8]" + - 0 = Trusted Execution + - 1 = Blocked Execution + - 2 = Untrusted Execution [Audit] + - 3 = Untrusted Execution [OTP] + - 5 = Trusted Publisher Execution + - 8 = Trusted Process Execution + (etc.) + + checkpoint_number : str + The checkpoint ID. Used to fetch results after a certain event. + Example: "601d275487bacb01e3470713" + + policy_names : str + A comma-separated or JSON-style list of policy group names. + Example: "Apple Mac" or "["Apple Mac", "Servers London"]" + + Returns + ------- + List[Dict[str, Any]] + A list of dictionaries, where each dictionary represents an + execution history record. Each record can include fields like: + + - checkpoint: str + - type: int + - username: str + - hostname: str + - filename: str + - ppolicy: str + - policyname: str + - policyver: str + - commandline: str + - publisher: str + - pprocess: str + - gprocess: str + - sha256: str + - datetime: str + - ip: str + - localip: str + Raises + ------ + RuntimeError + If the request fails or the response cannot be parsed. + + Example + ------- + >>> histories = await airlock_libs.history_logging("[3,5,8]", "601d275487bacb01e3470713", "Apple Mac") + >>> print(histories[0]["filename"]) + 'chrome.exe' + """ + ... \ No newline at end of file diff --git a/airlock_libs/rust_rewrite.txt b/airlock_libs/rust_rewrite.txt new file mode 100644 index 0000000..51fb023 --- /dev/null +++ b/airlock_libs/rust_rewrite.txt @@ -0,0 +1,170 @@ +use chrono::{Datelike, Duration, NaiveDate, Utc}; +use indicatif::{ProgressBar, ProgressStyle}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::Path; + +// Example API response type +#[derive(Debug, Serialize, Deserialize, Clone)] +struct HistoryItem { + checkpoint: Option, + datetime: String, + sha256: Option, + filename: Option, + hostname: Option, + // other fields... +} + +// JSON file structure +#[derive(Debug, Serialize, Deserialize)] +struct JsonFile { + error: String, + response: ResponseData, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ResponseData { + exechistories: Vec, +} + +// Mock API function +fn history_logging(_type: &str, checkpoint: &str, _policy: &[&str]) -> Vec { + // Replace with actual API call + vec![] +} + +fn main() { + let file_path = Path::new("data/example.json"); + let mut checkpoint = "initial_checkpoint".to_string(); + let policy_name = "policy1".to_string(); + let days = 7; + + // Initialize JSON output + let mut json_output = JsonFile { + error: "Success".to_string(), + response: ResponseData { + exechistories: vec![], + }, + }; + + // Outer progress bar (filebar) + let filebar = ProgressBar::new(10_000); + filebar.set_style( + ProgressStyle::default_bar() + .template("Checkpoint Progress: {msg} [{bar:40.cyan/blue}] {pos}/{len}") + .unwrap(), + ); + filebar.set_message(&checkpoint); + + // Inner progress bar (total progress) + let pbar = ProgressBar::new(100); + pbar.set_style( + ProgressStyle::default_bar() + .template("Total of {msg} Complete: [{bar:40.cyan/blue}] {pos}/{len}") + .unwrap(), + ); + pbar.set_message(&policy_name); + + loop { + let histories = history_logging("type", &checkpoint, &[&policy_name]); + + if !histories.iter().all(|h| h.datetime.len() > 0) { + eprintln!("Unexpected response format from API."); + break; + } + + filebar.set_length(histories.len() as u64); + + if histories.is_empty() { + break; + } + + for (index, history_item) in histories.iter().enumerate() { + if history_item.checkpoint.is_none() || history_item.datetime.is_empty() { + continue; + } + + if index == histories.len() - 1 { + checkpoint = history_item.checkpoint.clone().unwrap(); + filebar.set_message(&checkpoint); + break; + } + + // Parse date + let history_date = match NaiveDate::parse_from_str( + &history_item.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) { + Ok(date) => date, + Err(_) => continue, + }; + + let cutoff = Utc::today().naive_utc() - Duration::days(days); + if history_date >= cutoff { + json_output.response.exechistories.push(history_item.clone()); + } + + filebar.inc(1); + filebar.tick(); + } + + // Deduplicate + let mut seen: HashMap<(Option, Option, Option), HistoryItem> = + HashMap::new(); + + let combined = if file_path.exists() { + let mut f = File::open(file_path).unwrap(); + let mut contents = String::new(); + f.read_to_string(&mut contents).unwrap(); + let existing_data: JsonFile = serde_json::from_str(&contents).unwrap_or(JsonFile { + error: "Success".to_string(), + response: ResponseData { + exechistories: vec![], + }, + }); + [existing_data.response.exechistories, json_output.response.exechistories.clone()] + .concat() + } else { + json_output.response.exechistories.clone() + }; + + for entry in combined { + let key = (entry.sha256.clone(), entry.filename.clone(), entry.hostname.clone()); + seen.insert(key, entry); + } + + let deduplicated: Vec = seen.into_values().collect(); + + // Write to file + let output_file = File::create(file_path).unwrap(); + serde_json::to_writer_pretty(&output_file, &json!({ + "error": "Success", + "response": { "exechistories": deduplicated } + })) + .unwrap(); + + json_output.response.exechistories.clear(); + + // Update inner progress bar (percentage based on last valid item) + if let Some(last_item) = histories.last() { + if let Ok(last_date) = NaiveDate::parse_from_str( + &last_item.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) { + let date_diff = Utc::today().naive_utc() - last_date; + let percentage_diff = ((days + 10) - date_diff.num_days()) as f64 / (days + 10) as f64 * 100.0; + pbar.set_position(percentage_diff.round() as u64); + pbar.set_message(&policy_name); + pbar.tick(); + } + } + + filebar.set_position(1); + } + + filebar.finish(); + pbar.finish(); +} diff --git a/airlock_libs/src/lib.rs b/airlock_libs/src/lib.rs new file mode 100644 index 0000000..d305d0c --- /dev/null +++ b/airlock_libs/src/lib.rs @@ -0,0 +1,7 @@ +use pyo3::prelude::*; +mod services; +#[pymodule] +fn airlock_libs(py: Python<'_>, m: &Bound) -> PyResult<()> { + m.add_function(wrap_pyfunction!(services::pull_policy_exec_histories, py)?)?; + Ok(()) +} diff --git a/airlock_libs/src/services.rs b/airlock_libs/src/services.rs new file mode 100644 index 0000000..bb7b11e --- /dev/null +++ b/airlock_libs/src/services.rs @@ -0,0 +1,278 @@ +use chrono::{Duration, Local, NaiveDate}; +use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; +use mongodb::bson::oid::ObjectId; +use pyo3::{prelude::*, types::PyString}; +use reqwest::{ + Client, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::HashMap, + env, + fmt::Write, + fs::{self, File}, + io::Read, + path::PathBuf, + str::FromStr, +}; + +#[derive(Debug, Deserialize, Serialize)] +struct ApiResponse { + error: String, + response: ExecHistories, +} +#[derive(Debug, Deserialize, Serialize)] +struct ExecHistories { + exechistories: Vec, +} +#[derive(Debug, Deserialize, Serialize, Clone)] +struct Group { + checkpoint: String, + #[serde(rename = "type")] + exectype: u8, + username: String, + hostname: String, + netdomain: String, + filename: String, + ppolicy: String, + policyname: String, + policyver: String, + commandline: String, + publisher: String, + pprocess: String, + gprocess: String, + sha256: String, + datetime: String, + md5: String, + sha128: String, + sha384: String, + sha512: String, + ip: String, + localip: String, +} + +#[pyfunction] +pub fn pull_policy_exec_histories( + py: Python<'_>, + py_self: Py, + policy_names: String, + exec_types: String, + days: i64, +) -> Py { + let file_path: PathBuf = format!( + "{}\\cache\\chunkinator.json", + get_base_directory().display() + ) + .into(); + let writeable_filepath = file_path.clone(); + if !file_path.exists() { + if let Some(parent_dir) = file_path.parent() + && !parent_dir.exists() + { + fs::create_dir_all(parent_dir).unwrap(); + } + fs::File::create(file_path).unwrap(); + } + let data = ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }; + let data_write = serde_json::to_string_pretty(&data).expect("Failed to serialize"); + fs::write(writeable_filepath.clone(), data_write).unwrap(); + let mut checkpoint_number: String = skipback(days).to_string(); + let multi_progress = MultiProgress::new(); + multi_progress.set_draw_target(ProgressDrawTarget::stdout()); + let progress_bar = multi_progress.add(ProgressBar::new(100)); + progress_bar.set_style( + ProgressStyle::default_bar() + .template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len}") + .unwrap(), + ); + progress_bar.enable_steady_tick(std::time::Duration::from_millis(100)); + let client = build_client(py, &py_self); + let api: Py = py_self; + loop { + let execution_histories = history_logging( + py, + &api, + &exec_types, + &checkpoint_number, + &policy_names, + &client, + ); + let parsed_responses = execution_histories.response.exechistories; + if parsed_responses.is_empty() { + break; + } + let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists() { + let mut f = File::open(&writeable_filepath).unwrap(); + let mut contents = String::new(); + f.read_to_string(&mut contents).unwrap(); + let existing_data: ApiResponse = + serde_json::from_str(&contents).unwrap_or(ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }); + existing_data + .response + .exechistories + .into_iter() + .map(|entry| { + ( + ( + entry.sha256.clone(), + entry.filename.clone(), + entry.hostname.clone(), + ), + entry, + ) + }) + .collect() + } else { + HashMap::new() + }; + for (index, executions) in parsed_responses.iter().enumerate() { + if executions.checkpoint.is_empty() || executions.datetime.is_empty() { + continue; + } + if index == parsed_responses.len() - 1 { + checkpoint_number = executions.checkpoint.clone(); + break; + } + let history_date = match NaiveDate::parse_from_str( + &executions.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) { + Ok(date) => date, + Err(_) => continue, + }; + let cutoff = Local::now().naive_local() - Duration::days(days); + if history_date >= cutoff.into() { + let key = ( + executions.sha256.clone(), + executions.filename.clone(), + executions.hostname.clone(), + ); + seen.entry(key).or_insert(executions.clone()); + } + } + let final_response = ApiResponse { + error: "Success".to_string(), + response: ExecHistories { + exechistories: seen.values().cloned().collect(), + }, + }; + let data_write = serde_json::to_string_pretty(&final_response).unwrap(); + fs::write(&writeable_filepath, data_write).unwrap(); + if let Some(last_item) = &final_response.response.exechistories.last() + && let Ok(last_date) = NaiveDate::parse_from_str( + &last_item.datetime.replace(" +0000 UTC", ""), + "%Y-%m-%dT%H:%M:%SZ", + ) + { + let date_diff = Local::now().naive_local().date() - last_date; + let percentage_diff = + ((days + 10) - date_diff.num_days()) as f64 / (days + 10) as f64 * 100.0; + progress_bar.set_position(percentage_diff.round() as u64); + progress_bar.set_message("Total Percent Complete"); + } + } + progress_bar.finish_with_message("All Checkpoints Complete"); + let return_data = fs::read_to_string(&writeable_filepath).unwrap(); + PyString::new(py, &return_data).into() +} + +fn build_client(py: Python<'_>, py_self: &Py) -> Client { + let headers = py_self.getattr(py, "headers").unwrap().to_string(); + let headers_replace = headers.replace('\'', "\""); + let parsed: Value = serde_json::from_str(headers_replace.as_str()).unwrap(); + let mut header_map = HeaderMap::new(); + if let Some(obj) = parsed.as_object() { + for (_key, value) in obj { + if let Some(v) = value.as_str() { + let val = HeaderValue::from_str(v).unwrap(); + header_map.insert(HeaderName::from_str("X-APIKey").unwrap(), val); + } + } + } + + Client::builder() + .danger_accept_invalid_certs(true) + .default_headers(header_map) + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap() +} + +#[tokio::main] +async fn history_logging( + py: Python<'_>, + py_self: &Py, + exec_types: &String, + checkpoint_number: &String, + policy_names: &String, + client: &Client, +) -> ApiResponse { + let base_url = py_self.getattr(py, "base_url").unwrap().to_string(); + let payload = format!( + r#"{{ + "type": {}, + "checkpoint": "{}", + "policy": ["{}"] + }}"#, + exec_types, checkpoint_number, policy_names + ); + let res = client + .post(format!("{}/v1/logging/exechistories", base_url)) + .body(payload) + .send() + .await; + match res { + Ok(res) => { + let first_response: ApiResponse = serde_json::from_str(&res.text().await.unwrap()) + .expect("Failed to retrieve response from API"); + return first_response; + } + Err(_res) => { + let failed_response: ApiResponse = ApiResponse { + error: "Failed".to_string(), + response: ExecHistories { + exechistories: vec![], + }, + }; + return failed_response; + } + } +} + +fn get_base_directory() -> PathBuf { + let home = env::var_os("HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("USERPROFILE").map(PathBuf::from)) + .expect("Could not find Home Directory"); + let os = std::env::consts::OS; + match os { + "windows" => { + let appdata = env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Roaming")); + appdata.join("Loxide") + } + _ => home.join(".local").join("share").join("Loxide"), + } +} + +fn skipback(days: i64) -> ObjectId { + let date_days_ago = Local::now() - Duration::days(days); + let timestamp = date_days_ago.timestamp() as u32; + let mut hex_timestamp = String::new(); + write!(&mut hex_timestamp, "{:08x}", timestamp).unwrap(); + let objectid_hex = format!("{}0000000000000000", hex_timestamp); + ObjectId::parse_str(&objectid_hex).expect("Invalid ObjectId hex") +} diff --git a/allowlistandgroup.py b/allowlistandgroup.py deleted file mode 100644 index 5fcf86f..0000000 --- a/allowlistandgroup.py +++ /dev/null @@ -1,22 +0,0 @@ -import requests -import dotenv -import json -import os -import utils.pretty as ct - -url = 'https://172.17.22.240:3129' -policiesnames = [] -policyids=[] -dotenv.load_dotenv() -endpoint = url + '/v1/application' -print(ct.colorText("[+] Grabbing All Allowlists", "cyan")) -payload = {} -headers = { - "X-APIKey": os.getenv('APIKEY') -} -response = requests.request("POST", endpoint, headers=headers, data=payload, verify=False) -parse_text = json.loads(response.text) -for index, list in enumerate(parse_text['response']['applications'], start=1): - if index >= 38: - print(list) -#Need else and catch for upper bound diff --git a/default_system_config.json b/default_system_config.json new file mode 100644 index 0000000..b09f901 --- /dev/null +++ b/default_system_config.json @@ -0,0 +1,14 @@ +{ + "APPNAME": "AirlockTools", + "URL": "https://server:3129", + "LOG_LEVEL": "INFO", + "BAD_PATH_PARTS": ["users","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, + "MIN_FILES_FOR_PATH": 4, + "VT_THREAT_TOLERANCE": 4, + "POLICY_MAP_ENF_AUD": { + + } +} \ No newline at end of file diff --git a/flows/localApproval.py b/flows/localApproval.py new file mode 100644 index 0000000..789b917 --- /dev/null +++ b/flows/localApproval.py @@ -0,0 +1,291 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import datetime +import logging +import os +import re +import time + +import dotenv +import numpy as np +import pandas as pd + +from models.agent import Agent +from services.agenthandler import findAllAgents, moveAgentToRelatedPolicy, selectAgents +from services.API import AirlockAPIWrapper +from utils.configmanager import get_protected_json, load_env, load_env_json +from utils.setup import get_base_directory +from utils.utils import colorText, get_sanitized_input + +logger = logging.getLogger(__name__) + +dotenv.load_dotenv() + + +def getLocalApprovals(api: AirlockAPIWrapper): + base_dir = get_base_directory + result = api.otp_find_awaiting() + local_approval = pd.DataFrame(result["response"]["otpusage"]) + if os.path.exists(f"{base_dir}\\cache\\newest_local_approval.parquet"): + previous_run = pd.read_parquet(f"{base_dir}\\cache\\newest_local_approval.parquet") + previous_run.to_parquet( + f"{base_dir}\\cache\\last_local_approval.parquet", index=False + ) + os.remove(f"{base_dir}\\cache\\newest_local_approval.parquet") + + # Only keep rows presumably created by the generate local approval function + local_approval = local_approval[ + local_approval["purpose"].str.startswith("🎫 Local Approval 🎫") + ] + + local_approval["batchid"] = local_approval["purpose"].apply( + lambda x: (match := re.search(r"batch:(\S+)", str(x))) and match.group(1) + ) + + if not local_approval.empty: + local_approval.to_parquet( + f"{base_dir}\\cache\\newest_local_approval.parquet", index=False + ) + + return local_approval + + +def scheduleAddingLAHashes(api: AirlockAPIWrapper): + + policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD","{}") + bad_publisher_list = load_env_json("BAD_PUBLISHER", "[]") + pups = load_env_json("PUPS", "[]") + threat_tolerance_constant = load_env("VT_THREAT_TOLERANCE", cast_type = int) + + try: + register_function("add_hash", returnFromLocalApproval) + register_function("move_device", moveAgentToRelatedPolicy) + except Exception as e: + logger.warning(f"Failed to register functions: {e}") + return + + try: + approvals_df = getNewLocalApprovals(api) + if approvals_df.empty: + logger.debug("No new local approvals found. Nothing to schedule.") + return + batches = approvals_df.groupby("batchid") + except Exception as e: + logger.warning(f"Failed to retrieve or group local approvals: {e}") + return + + for batchid, batch_df in batches: + try: + duration_minutes = int(batch_df["duration"].iloc[0]) + start_time = datetime.datetime.now() + run_time = start_time + datetime.timedelta(minutes=duration_minutes) + early_time = start_time + datetime.timedelta(minutes=np.floor(duration_minutes * 0.95)) + + early_timestamp = early_time.timestamp() + run_timestamp = run_time.timestamp() + + # Schedule add_hash job + try: + run_once_job( + f"add_hash_{batchid}", + "add_hash", + early_timestamp, + [ + api, + batch_df, + policy_relationship_map, + bad_publisher_list, + pups, + threat_tolerance_constant, + ], + None, + ) + logger.debug(f"Scheduled add_hash for batch {batchid} at {early_time}") + except Exception: + logger.debug("Failed to schedule add_hash for batch {batchid}: {e}") + + # Schedule move_device jobs + devices = batch_df["agentid"].drop_duplicates().tolist() + agents = [] + + for device in devices: + rows = api.agent_find_by_hostname(device).iterrows() + agents += [Agent(**row["data"]) for _, row in rows] + + for agent in agents: + try: + run_once_job( + f"move_device_{agent.hostame}_{batchid}", + "move_device", + run_timestamp, + [api, agent, policy_relationship_map], + "enforcement", + ) + + print( + f"Scheduled move_device for device {agent.hostname} in batch {batchid} at {run_time}" + ) + except Exception as e: + print( + f"Failed to schedule move_device for device {agent.hostname} in batch {batchid}: {e}" + ) + + except Exception as e: + logger.warning(f"Failed to process batch {batchid}: {e}") + + +def returnFromLocalApproval(api, device_df, policy_relationship_map, bad_publisher_list, pups, threat_tolerance_constant +): + """ + # Get unique policy names from device list + policies_in_devicelist = sorted(device_df['policy_name'].unique().tolist()) + + # Create inverse map to go from Audit to Enforcement + inverse_map = {v: k for k, v in policy_relationship_map.items()} + + # Fetch all policies + all_policies = [Policy(row['groupid'], row['hidden'], row['name'], row['parent']) for _, row in api.policy_find_all().iterrows()] + + # Define policy types + policy_types = [1, 2, 6, 7] + + #TODO finish logic for adding hashes + """ + working_dir = load_env("WORKING_DIR") + policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD","{}") + bad_publisher_list = load_env_json("BAD_PUBLISHER", "[]") + pups = load_env_json("PUPS", "[]") + threat_tolerance_constant = load_env("VT_THREAT_TOLERANCE") + print(f"{working_dir}, {policy_relationship_map}, {bad_publisher_list}, {pups}, {threat_tolerance_constant}") + +def moveToLocalApproval(api: AirlockAPIWrapper): + possible_durations = [15, 60, 360, 1440, 10080] + duration_selected = None + + print(colorText("Please select a duration:", "white")) + for i, option in enumerate(possible_durations, start=1): + print(f"{i}. {option}") + + try: + + choice = int(get_sanitized_input("Enter the number of your choice:")) + if 1 <= choice <= len(possible_durations): + duration_selected = possible_durations[choice - 1] + print(colorText(f"You selected: {duration_selected}", "yellow")) + logger.debug(f"You selected: {duration_selected}") + else: + print(colorText("❌ Invalid choice.", "red")) + logger.debug("Invalid Input") + return + except ValueError: + print(colorText("❌ Invalid input. Please enter a number.", "red")) + logger.debug("Invalid Input") + return + + agents = selectAgents(api) + batch = int(time.time()) + + if not agents: + print(colorText("❌ No agents found or error retrieving agents.", "red")) + logger.debug("No agents found or error retrieving agents") + return + + for agent in agents: + try: + addLocalApproval(api, batch, duration_selected, agent.agentid) + moveAgentToRelatedPolicy(api, agent, "audit") + except Exception as e: + print(colorText(f"❌ Error processing agent {agent.hostname}: {e}", "red")) + + +def addLocalApproval(api: AirlockAPIWrapper, batchid, duration_selected, agentid): + + purpose = f"🎫 Local Approval 🎫 - {duration_selected} mins - batch:{batchid} Client:{agentid}" + api.otp_generate(agentid, duration_selected, purpose) + + + +def monitorAuditStatus(api: AirlockAPIWrapper): + current_agents = findAllAgents(api) + last_agents = [] + if not last_agents: + last_agents = current_agents + policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD","{}") + + # Reverse map for audit → enforcement + reverse_policy_map = {v: k for k, v in policy_relationship_map.items()} + known_transitions = set(policy_relationship_map.items()) | set(reverse_policy_map.items()) + + # Index last_agents by hostname for quick lookup + last_agent_map = {agent.hostname: agent for agent in last_agents} + + # Result buckets + newly_added = [] + same_policy = [] + moved_to_audit = [] + moved_to_enforcement = [] + unusual_move = [] + + for current in current_agents: + previous = last_agent_map.get(current.hostname) + + if not previous: + newly_added.append(current) + continue + + if current.groupid == previous.groupid: + same_policy.append(current) + elif (previous.groupid, current.groupid) in known_transitions: + moved_to_audit.append(current) + elif (current.groupid, previous.groupid) in known_transitions: + moved_to_enforcement.append(current) + else: + unusual_move.append(current) + + # Return all five DataFrames + return newly_added, same_policy, moved_to_audit, moved_to_enforcement, unusual_move + + +def getNewLocalApprovals(api: AirlockAPIWrapper): + + working_dir = load_env("WORKING_DIR") + current_la = getLocalApprovals(api) + + # Load old approval list + old_la_path = f"{working_dir}\\Scheduling\\last_local_approval.parquet" + if os.path.exists(old_la_path): + old_la = pd.read_parquet(old_la_path) + else: + old_la = pd.DataFrame(columns=current_la.columns) + + # Create composite keys + current_la["key"] = current_la["clientid"].astype(str) + "_" + current_la["granted"].astype(str) + old_la["key"] = old_la["clientid"].astype(str) + "_" + old_la["granted"].astype(str) + + # Find new entries + new_entries = current_la[~current_la["key"].isin(old_la["key"])] + + # Convert 'granted' to datetime and filter by last 10 minutes + new_entries["granted"] = pd.to_datetime(new_entries["granted"], utc=True, errors="coerce") + ten_minutes_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=10) + recent_entries = new_entries[new_entries["granted"] > ten_minutes_ago] + + # Save current approvals for next run + current_la.drop(columns=["key"], inplace=True) + current_la.to_parquet(old_la_path, index=False) + + return recent_entries diff --git a/flows/otp.py b/flows/otp.py new file mode 100644 index 0000000..6c09db2 --- /dev/null +++ b/flows/otp.py @@ -0,0 +1,170 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + + +from datetime import datetime +import logging +import os + +import pandas as pd + +from services.agenthandler import selectAgents +from services.API import AirlockAPIWrapper +from utils.configmanager import load_env +from utils.selector import Selector +from utils.utils import colorText, get_sanitized_input + +logger = logging.getLogger(__name__) + + +def otp_generate(api: AirlockAPIWrapper): + otp_dict = {} + agents = selectAgents(api) + print(colorText("Would you like to continue with these devices?","white")) + for agent in agents: + print(agent.hostname) + confirm = Selector.confirm() + if agents and confirm: + requester = get_sanitized_input("Who is requesting the OTP: ") + because = get_sanitized_input("Why/What work are they doing?: ") + + purpose = f"Requester: {requester} - for : {because}" + possible_durations = [15, 60, 360, 1440, 10080] + + print(colorText("Please select a duration in minutes: ", "white")) + print(colorText("15 mins, 60 mins, 360 mins(6 Hours), 1440 mins (24 Hours), 10080 mins (7 Days):", "white")) + duration_selected = Selector.select_int(possible_durations) + + if isinstance(duration_selected, list): + duration_selected = duration_selected[0] if duration_selected else None + + if duration_selected is not None: + for agent in agents: + logging.info(f"Querying API for {agent.hostname}") + otp_code = api.otp_generate(agent.agentid, duration_selected, purpose) + logger.debug(f"Generated OTP for {agent.hostname}: {otp_code}") + otp_dict[agent.hostname] = otp_code + + print(colorText("Requested Codes:", "green")) + for key, value in otp_dict.items(): + print(colorText(f"{key} | {value}","green")) + +def otp_activities_by_agent(api: AirlockAPIWrapper): + activeagents = api.otp_find_active() + awaitingagents = api.otp_find_awaiting() + enforcedagents = api.otp_find_enforced() + revokedagents = api.otp_find_revoked() + + + # Add a 'status' column to each DataFrame + activeagents['status'] = 'active' + awaitingagents['status'] = 'awaiting' + enforcedagents['status'] = 'enforced' + revokedagents['status'] = 'revoked' + + # Combine all into one DataFrame + combined_agents = pd.concat([activeagents, awaitingagents, enforcedagents, revokedagents], ignore_index=True) + combined_agents = combined_agents.sort_values(by='otpid', ascending=False) + + #Optionally, select specific hosts + user_input = get_sanitized_input("\nWould you like to search for a specific device? (y/n): ").strip().lower() + if user_input == 'y': + agentnames = [] + agents = selectAgents(api) + for agent in agents: + agentnames.append(agent.hostname) + + combined_agents = combined_agents[combined_agents['hostname'].isin(agentnames)] + + #Present and select rows + selected_rows = Selector.select_dataframe_with_mode( + combined_agents, + columns=['otpid', 'hostname', 'status','purpose','granted'], + header="OTP Sessions" + ) + combined_df = pd.DataFrame() + + for row in selected_rows: + otpid = row['otpid'] + hostname = row['hostname'] + result = api.otp_get_activities(otpid) + result['hostname'] = hostname + if not result.empty: + logger.info(f"Activities for {hostname} (otpid: {otpid}):\n{result}") + combined_df = pd.concat([combined_df, result], ignore_index=True) + else: + logger.info(f"No activities found for {hostname} (otpid: {otpid})") + + user_input = get_sanitized_input("\nWould you like to export the results to a CSV file? (y/n): ").strip().lower() + if user_input == 'y': + working_dir = load_env("WORKING_DIR") + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"otp_activities_{timestamp}.csv" + file_path = os.path.join(str(working_dir), filename) + + combined_df.to_csv(file_path, index=False) + logging.info(f"Exported Data to {file_path}") + + print( + colorText( + f"\n✅ OTP Activity exported to: {working_dir}\\{filename}", + "green", + ) + ) + else: + logging.debug("User declined to export the DataFrame.") + + + +def otp_revoke(api: AirlockAPIWrapper): + + activeagents = api.otp_find_active() + awaitingagents = api.otp_find_awaiting() + + activeagents['status'] = 'active' + awaitingagents['status'] = 'awaiting' + + combined_agents = pd.concat([activeagents, awaitingagents], ignore_index=True) + combined_agents = combined_agents.sort_values(by='otpid', ascending=False) + + # Combine all into one DataFrame + combined_agents = pd.concat([activeagents, awaitingagents], ignore_index=True) + combined_agents = combined_agents.sort_values(by='otpid', ascending=False) + + #Optionally, select specific hosts + user_input = get_sanitized_input("\nWould you like to search for a specific device? (y/n): ").strip().lower() + if user_input == 'y': + agentnames = [] + agents = selectAgents(api) + for agent in agents: + agentnames.append(agent.hostname) + + combined_agents = combined_agents[combined_agents['hostname'].isin(agentnames)] + + #Present and select rows + selected_rows = Selector.select_dataframe_with_mode( + combined_agents, + columns=['otpid', 'hostname', 'status','purpose','granted'], + header="OTP Sessions" + ) + + for row in selected_rows: + otpid = row['otpid'] + hostname = row['hostname'] + result = api.otp_revoke(otpid) + logger.info(f"{hostname} (otpid: {otpid}):\n{result}") + + diff --git a/flows/prepPolicy.py b/flows/prepPolicy.py new file mode 100644 index 0000000..f66eee7 --- /dev/null +++ b/flows/prepPolicy.py @@ -0,0 +1,627 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import logging +import os +import os.path +import re +from typing import List + +import dotenv +import pandas as pd + +from models.execution import ExecutionHistoryRecord +from models.policy import Allowlist, Policy +from services.API import AirlockAPIWrapper +from utils.configmanager import get_protected_value, load_env, load_env_json +from utils.selector import Selector +from utils.utils import ( + areYouSure, + clear_screen, + colorText, + formatHTML, + get_sanitized_input, + locked, + open_directory, + print_x_wide, + regulator, +) + +logger = logging.getLogger(__name__) + +dotenv.load_dotenv() + + + +def selectPolicies(api: AirlockAPIWrapper, allow_multiple=True) -> List[Policy]: + + policies = [Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()] + logger.debug("Prompting for Policies") + print(colorText("Please select policy/policies", "white")) + selected = Selector.select_objects(policies, allow_multiple, prompt_each=True) + + if selected is None: + return [] + + # Normalize to always return a list + logger.debug("Returning {selected.dict}") + return selected if isinstance(selected, list) else [selected] + + +def selectAllowlists(api: AirlockAPIWrapper, policy = all, allow_multiple=True) -> List[Allowlist]: + if policy == "all": allowlists = [Allowlist(**row.to_dict()) for _, row in api.allowlist_find_all().iterrows()] + else: allowlists = [Allowlist(**row.to_dict()) for _, row in api.policy_list_allowlists(policy[0].groupid).iterrows()] + logger.debug("Prompting for Allowlist(s)") + print(colorText("Please select allowlist(s)", "white")) + selected = Selector.select_objects(allowlists, allow_multiple, prompt_each=True) + + if selected is None: + return [] + + # Normalize to always return a list + logger.debug(f"Returning {selected}") + return selected if isinstance(selected, list) else [selected] + + +def sortHashes( + api: AirlockAPIWrapper, + selected_policies: List[Policy], + type=[1, 2, 6, 7] +): + working_dir = load_env("WORKING_DIR") + history_days = Selector.select_value( + prompt="Enter how many days of history to pull (1–150): ", + value_type=int, + valid_range=(1, 150), + ) + + logger.debug(f"{history_days} day selected for history") + + if history_days is None: + logging.warning("No history range selected. Aborting.") + return + + policy_executions = ExecutionHistoryRecord.from_policies( + api, selected_policies, type_=type, history_days=history_days + ) + + logger.debug(f"Executions contains {policy_executions}") + + enriched_executions = ExecutionHistoryRecord.enrich_with_hashes(api, policy_executions) + categorized_executions = ExecutionHistoryRecord.categorize_executions_by_hash_decision(enriched_executions) + approved, unapproved, needs_review, unknown = ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions) + + categories = { + "needs_review": needs_review, + "approved": approved, + "unapproved": unapproved, + "leftover" : unknown + } + + + for label, records in categories.items(): + if not records: + continue # Skip empty or falsy categories + + csv_path = f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_{label}_executions.csv" + html_path = f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{label}.html" + + # Convert ExecutionHistoryRecord objects to dictionaries + df = pd.DataFrame([r.__dict__ for r in records]) + + # Optional: flatten hash_obj if needed + if not df.empty and 'hash_obj' in df.columns: + hash_df = df['hash_obj'].apply(lambda h: h.to_dict() if h else {}) + df = pd.concat([df.drop(columns=['hash_obj']), hash_df], axis=1) + + # Save to CSV + df.to_csv(csv_path, index=False) + logger.info(f"Saved {label} executions to {csv_path}") + + # Generate HTML + formatHTML(df, html_path) + logger.info(f"Generated HTML report at {html_path}") + + +def buildPathsandPublishers(selected_policies: List[Policy], split): + working_dir = load_env("WORKING_DIR") + df1 = pd.DataFrame() + df2 = pd.DataFrame() + all_approved_hashes = pd.DataFrame() + path1 = f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv" + path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv" + path_exclusion_constant = get_protected_value("PATH_EXCLUSION_CONST", cast_type= int) + + if os.path.exists(path1): + df1 = pd.read_csv(path1) + else: + logger.warning(f"File not found: {path1}") + + if os.path.exists(path2): + df2 = pd.read_csv(path2) + else: + logger.warning(f"File not found: {path2}") + + if df1.empty and df2.empty: + logger.warning("Both DataFrames are empty. Skipping sort.") + all_approved_hashes = pd.DataFrame() + logger.debug(all_approved_hashes.head) + else: + all_approved_hashes = pd.concat([df1, df2], ignore_index=True) + if "filename" in all_approved_hashes.columns: + all_approved_hashes = all_approved_hashes.sort_values(by="filename") + else: + logger.warning("Warning: 'filename' column not found in concatenated DataFrame.") + + if not all_approved_hashes.empty and path_exclusion_constant: + + primary_path_exclusions = calculatePath( + all_approved_hashes, path_exclusion_constant, + split, + ) + remaining_hashes = all_approved_hashes[ + ~all_approved_hashes["sha256"].isin(primary_path_exclusions["sha256"]) + ] + secondary_path_exclusions = calculatePath( + remaining_hashes,(path_exclusion_constant - 1), split + ) + remaining_hashes = remaining_hashes[ + ~remaining_hashes["sha256"].isin(secondary_path_exclusions["sha256"]) + ] + dataframes = { + "all_approved_hashes" : all_approved_hashes, + "primary_Paths": primary_path_exclusions, + "secondary_Paths": secondary_path_exclusions, + "hashes_not_approvable_by_path": remaining_hashes + } + logger.debug("Preparing to sort dataframes") + for name, df in dataframes.items(): + logger.debug(f" DataFrame headers: {list(df.columns)}") + if "hashes" in name : df.sort_values(by="filename", inplace=True) + else: df.sort_values(by="longestcfp", inplace=True) + + df.to_csv(f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_{name}.csv", index=False) + formatHTML(df, f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{name}.html") + + if not all_approved_hashes.empty: + # Drop all not signed, only keep unique values + publist = all_approved_hashes[ + all_approved_hashes["publisher"] != "Not Signed" + ].drop_duplicates(subset=["publisher"]) + # Remove Bad publisher if somehow they made it this far + pattern = regulator(load_env_json("BAD_PUBLISHERS","[]")) + publist = publist[~publist["publisher"].str.contains(pattern, na=False)] + publist = publist[["publisher"]] + publist.sort_values(by="publisher", inplace=True) + publist.to_csv(f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_publishers.csv", index=False) + else: + logger.debug("Approved Hashes list appears empty") + +def buildPreflights(selected_policies: List[Policy]): + working_dir = load_env("WORKING_DIR") + + df1 = pd.DataFrame() + df2 = pd.DataFrame() + approved_hashes = pd.DataFrame() + approved_publishers = pd.DataFrame() + + hash = f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_all_approved_hashes.csv" + path1 = f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv" + path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_secondary_Paths.csv" + publishers = f"{working_dir}\\Approved\\{selected_policies[0].name}_publishers.csv" + + + #Read in and combine the two path generations + if os.path.exists(path1): + df1 = pd.read_csv(path1) + else: + logger.warning(f"File not found: {path1}") + + if os.path.exists(path2): + df2 = pd.read_csv(path2) + else: + logger.warning(f"File not found: {path2}") + + if df1.empty and df2.empty: + logger.warning("Both DataFrames are empty. Skipping sort.") + approved_paths = pd.DataFrame() + else: + approved_paths = pd.concat([df1, df2], ignore_index=True) + + approved_paths = approved_paths.drop_duplicates(subset="longestcfp", keep ="first") + + #We create a list of hashes that are left over if we exclude the ones that are covered by the path exclusions. + if os.path.exists(hash): + hashes = pd.read_csv(hash) + approved_hashes = hashes[~hashes['filename'].isin(approved_paths['longestcfp'])] + + approved_hashes = approved_hashes.drop_duplicates(subset="sha256", keep ="first") + + else: + logger.warning(f"File not found: {hash}") + + + if os.path.exists(publishers): + approved_publishers = pd.read_csv(publishers) + + else: + logger.warning(f"File not found: {publishers}") + + dataframes = {"approved_paths": approved_paths, "approved_hashes": approved_hashes, "approved_publishers": approved_publishers} + + for name, df in dataframes.items(): + logger.debug(f" DataFrame headers: {list(df.columns)}") + if name == "approved_paths":df.sort_values(by="longestcfp", inplace=True) + elif name == "approved_hashes":df.sort_values(by="filename", inplace=True) + elif name == "approved_publishers" : df.sort_values(by="publisher", inplace=True) + + df.to_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_{name}.csv", index=False) + formatHTML(df, f"{working_dir}\\Preflight\\HTML\\{selected_policies[0].name}_{name}.html") + +def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"): + min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type= int) + + def clean_split(path): + if not isinstance(path, (str, bytes, os.PathLike)): + return [] + parts = str(os.path.normpath(path)).split(os.sep) + parts = [p for p in parts if p] # Remove empty strings + return parts + + # Diagnostic: log any non-string entries + non_string_entries = df[~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike)))] + if not non_string_entries.empty: + print(f"[WARNING] Non-string entries found in column '{col}':") + print(non_string_entries) + + df = df.copy() + split_paths = df[col].apply(clean_split) + + if min_files_for_path is not None: + df = df[split_paths.apply(lambda parts: len(parts) >= min_files_for_path)].copy() + split_paths = split_paths[df.index] + + df["group_key"] = split_paths.apply(lambda parts: os.sep.join(parts[:path_exclusion_constant])) + grouped = df.groupby("group_key") + new_rows = [] + + for _, group_df in grouped: + paths = group_df[col].tolist() + split_parts = [clean_split(p) for p in paths] + + def longest_common_prefix(paths): + if not paths: + return [] + prefix = paths[0] + for path in paths[1:]: + prefix = [a for a, b in zip(prefix, path) if a == b] + if not prefix: + break + return prefix + + common_prefix = longest_common_prefix(split_parts) + prefix_str = os.sep.join(common_prefix) + + for i, parts in enumerate(split_parts): + filename = parts[-1] + middle = ( + os.sep.join(parts[len(common_prefix):-1]) + if len(parts) > len(common_prefix) + 1 + else "" + ) + row = group_df.iloc[i].copy() + row["longestcfp"] = prefix_str + row["middle"] = middle + row["filename_only"] = filename + row["file_extension"] = os.path.splitext(filename)[1].lower() + new_rows.append(row) + + return pd.DataFrame(new_rows).drop(columns=["group_key"]) + +def calculatePath(approved_hashes, path_exclusion_constant, split): + if split: + dfs_by_policy = [group for _, group in approved_hashes.groupby("policy")] + else: + dfs_by_policy = [approved_hashes] + + badpathparts = load_env_json("BAD_PATH_PARTS", "[]") + min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type = int) + + processed_dfs = [] + + for df in dfs_by_policy: + haslcp = splitFilepathsGrouped(df, path_exclusion_constant, "filename") + haslcp = haslcp.drop_duplicates() + + forbidden = regulator(badpathparts, True) + forbidden_lcfp = haslcp["longestcfp"].str.contains(forbidden, na=False) + + logger.debug("Removing forbidden filepaths for path exceptions") + print(colorText("Removing forbidden filepaths for path exceptions", "green")) + lcp_not_forbidden = haslcp[~forbidden_lcfp].copy() + + lcp_not_forbidden_review = lcp_not_forbidden[ + [ + "policyname", + "longestcfp", + "middle", + "filename_only", + "file_extension", + "sha256", + ] + ] + + unique_sha_counts = ( + lcp_not_forbidden_review.groupby("longestcfp")["sha256"].nunique().reset_index() + ) + unique_sha_counts.columns = ["longestcfp", "unique_sha256_count"] + + lcp_not_forbidden_review = lcp_not_forbidden_review.merge( + unique_sha_counts, on="longestcfp", how="left" + ) + lcp_not_forbidden_review = lcp_not_forbidden_review[ + lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path + ] + processed_dfs.append(lcp_not_forbidden_review) + + pathExclusions = pd.concat(processed_dfs, ignore_index=True) + + return pathExclusions + +def testChange(selected_policies, destination_policy, destination_allowlist): + working_dir = load_env("WORKING_DIR") + + logger.info("These path exclusions would be added to:") + logger.info(destination_policy) + + pathexclusions = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv") + hashes = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv") + + unique_combinations = pathexclusions[["longestcfp", "file_extension"]].drop_duplicates() + + drive_letter_pattern = re.compile(r"^[a-zA-Z]:\\") + processed_paths = [ + (path if drive_letter_pattern.match(path) else f"\\\\{path}") + f"\\**{ext}" + for path, ext in unique_combinations.itertuples(index=False, name=None) + ] + + for path in processed_paths: + logger.info(path) + + print(colorText("These publishers would added", "yellow")) + processed_publishers = [] + if os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"): + publishers = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv") + if publishers.empty: + print(colorText("The publishers list is empty.", "red")) + else: + processed_publishers = ( + publishers[publishers["publisher"] != "Not Signed"] + ["publisher"] + .drop_duplicates() + .tolist() + ) + for publisher in processed_publishers: + print(publisher) + + print(colorText("These hashes would be added to:", "yellow")) + print(destination_allowlist) + + processed_hashes = hashes["sha256"].unique().tolist() + print_x_wide(processed_hashes, 3) + + return processed_paths, processed_hashes, processed_publishers + +def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7 into functions + selected_policies = [] + destination_policy = [] + destination_allowlist = [] + processed_paths = [] + processed_hashes = [] + processed_publishers = [] + working_dir = load_env("WORKING_DIR") + + while True: + printEnforceChecklist(selected_policies, destination_policy, destination_allowlist) + choice = get_sanitized_input("\nEnter your choice: ") + + if choice == "1": + clear_screen() + selected_policies = selectPolicies(api,True) + + elif choice == "2": + clear_screen() + print(colorText("Please choose destination_name Policy for Path Exclusions", "white")) + + destination_policy = selectPolicies(api, False) + + print(colorText("Please choose Allowlist for Hashes", "white")) + + destination_allowlist = selectAllowlists(api, destination_policy, False) + + elif choice == "3": + clear_screen() + sortHashes( + api, + selected_policies, + type=[1, 2, 6, 7], + ) + + elif choice == "4": + clear_screen() + if os.path.exists(f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_approved_executions.csv"): + buildPathsandPublishers(selected_policies, False) + else: + print("File not found. Please make sure it's saved correctly and try again.") + + elif choice == "5": + clear_screen() + if os.path.exists(f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv") and os.path.exists( + f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv" + ): + buildPreflights(selected_policies) + else: + print("File not found. Please make sure it's saved correctly and try again.") + + elif choice == "6": + clear_screen() + if ( + os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv") + and os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv") + and destination_policy + and destination_allowlist + ): + processed_paths, processed_hashes, processed_publishers = testChange(selected_policies, destination_policy, destination_allowlist) + else: + # Log which condition(s) failed + missing_items = [] + if not os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"): + missing_items.append("approved_paths.csv not found") + if not os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"): + missing_items.append("approved_hashes.csv not found") + if not destination_policy: + missing_items.append("destination_policy is empty or None") + if not destination_allowlist: + missing_items.append("destination_allowlist is empty or None") + + logger.error("Preflight check failed due to the following:") + for item in missing_items: + logger.error(f" - {item}") + + elif choice == "7": + clear_screen() + areYouSure() + confirmation = get_sanitized_input("Type 'I AGREE' to continue: ") + if ( + processed_paths + and processed_hashes + and processed_publishers + and destination_policy + and destination_allowlist + and confirmation.strip() == "I AGREE" + ): + print(colorText("Proceeding with the code...", "yellow")) + api.hash_add_to_allowlist(destination_allowlist[0].applicationid, processed_hashes) + api.policy_add_path_exclusions(destination_policy[0].groupid, processed_paths) + if processed_publishers: + api.policy_add_publishers(destination_policy[0].groupid, processed_publishers) + + locked() + + else: + logger.error("Confirmation block failed. Reasons:") + if not processed_publishers or processed_hashes or processed_paths: + logger.error(" - Test not performed.") + if not destination_policy: + logger.error(" - `destination_policy` is missing or invalid.") + if not destination_allowlist: + logger.error(" - `destination_allowlist` is missing or invalid.") + if confirmation.strip() != "I AGREE": + logger.error(" - User did not confirm with 'I AGREE'. Received: '%s'", confirmation.strip()) + + elif choice.upper() == "F": + open_directory(working_dir) + elif choice.upper() == "B": + break + + + else: + print(colorText("Invalid choice. Please try again.", "red")) + +def section_header(title): + print(colorText("\n --------------------------------------------------------------------", "cyan")) + print(colorText(f" ------------- {title} -------------", "cyan")) + print(colorText(" --------------------------------------------------------------------", "cyan")) + + +def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist): + working_dir = load_env("WORKING_DIR") + section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒") + print(colorText("\nSequentially follow these steps to prepare a policy for enforcement:", "white")) + + # Step 1: Originating Policies + print(colorText("\n1. Choose which policy or policies to gather execution info from", "cyan")) + if not selected_policies: + print(colorText(" [✗] No policies have been chosen", "red")) + else: + print(colorText("The following policies have been chosen:", "green")) + for policy in selected_policies: + print(colorText(f" [✓] {policy.name}", "green")) + + # Step 2: Destination Policy and Allowlist + print(colorText("2. Choose the destination policy and associated allowlist", "cyan")) + if destination_policy: + print(colorText(f" [✓] {destination_policy[0].name} has been selected as the destination policy", "green")) + else: + print(colorText(" [✗] No destination policy has been chosen", "red")) + + if destination_allowlist: + print(colorText(f" [✓] {destination_allowlist[0].name} has been selected as allowlist", "green")) + else: + print(colorText(" [✗] No allowlist has been chosen", "red")) + + # Step 3: Data Preparation + print(colorText(f"3. Select to begin pulling execution history. The executions will be sorted and placed in {working_dir}\\data\\Needs_Review", "cyan")) + if selected_policies: + policy_id = selected_policies[0].name + review_path = f"{working_dir}\\Needs_Review\\Review_First\\{policy_id}_approved_executions.csv" + print(colorText(" [✓] Data has been fetched" if os.path.exists(review_path) else " [✗] Data has not been fetched", "green" if os.path.exists(review_path) else "red")) + else: + print(colorText(" [✗] No policies selected, cannot check data fetch status", "red")) + + # Step 4: Manual Review + print(colorText("4. Manually review the files:", "cyan")) + print(colorText(" Remove the rows containing hashes you do not approve of", "cyan")) + print(colorText(f" When complete, save both csv files to {working_dir}\\data\\Approved and choose this option.", "cyan")) + print(colorText(" This will start the process to generate possible filepath approvals", "cyan")) + + if selected_policies: + policy_id = selected_policies[0].name + approved_path = f"{working_dir}\\Approved\\{policy_id}_approved_executions.csv" + second_review_path = f"{working_dir}\\Needs_Review\\Review_Second\\{policy_id}_primary_Paths.csv" + print(colorText(" [✓] Reviewed hashes have been loaded" if os.path.exists(approved_path) else " [✗] Reviewed hashes have not been loaded", "green" if os.path.exists(approved_path) else "red")) + print(colorText(" [✓] Path review list created" if os.path.exists(second_review_path) else " [✗] Path review list has not been created", "green" if os.path.exists(second_review_path) else "red")) + else: + print(colorText(" [✗] No policies selected, cannot check reviewed hashes or path list", "red")) + + # Step 5: Path Review + print(colorText(f"5. Manually review the files in {working_dir}\\Needs_Review\\Review_Second\\", "cyan")) + print(colorText(" Remove the rows containing path exclusions or publishers you do not approve of.", "cyan")) + print(colorText(f" When complete, save the files to {working_dir}\\data\\Approved", "cyan")) + print(colorText(" Choose this option when done to build your preflights", "cyan")) + + if selected_policies: + policy_id = selected_policies[0].name + reviewed_path = f"{working_dir}\\Approved\\{policy_id}_primary_Paths.csv" + preflight_paths = f"{working_dir}\\Preflight\\{policy_id}_approved_paths.csv" + preflight_hashes = f"{working_dir}\\Preflight\\{policy_id}_approved_hashes.csv" + print(colorText(" [✓] Reviewed path list detected" if os.path.exists(reviewed_path) else " [✗] Path review list has not been detected", "green" if os.path.exists(reviewed_path) else "red")) + preflight_ready = os.path.exists(preflight_paths) and os.path.exists(preflight_hashes) + print(colorText(" [✓] Preflight Path Exclusion List has been generated" if preflight_ready else " [✗] Preflight Path Exclusion List has not been generated", "green" if preflight_ready else "red")) + else: + print(colorText(" [✗] No policies selected, cannot check preflight status", "red")) + + # Final Steps + print(colorText("6. Test ------------------------------------------------------", "cyan")) + print(colorText(" Prints to console the changes that would be made, must be done to proceed. ", "cyan")) + + print(colorText("7. Liftoff ------------------------------------------------------", "cyan")) + print(colorText(" Apply path exclusions and approved publishers to selected policy", "cyan")) + print(colorText(" Apply approved hashes to allowlist", "cyan")) + + + # Utility Options + print(colorText("F. 📂 - Open Working Directory", "cyan")) + print(colorText("B. 🔚 - Back", "cyan")) diff --git a/flows/quietAgent.py b/flows/quietAgent.py new file mode 100644 index 0000000..6bc05ea --- /dev/null +++ b/flows/quietAgent.py @@ -0,0 +1,129 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import datetime +import logging + +import 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 (1–150): ", + 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? (1–365): ", + 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") diff --git a/loading.png b/loading.png new file mode 100644 index 0000000..1dc81bd Binary files /dev/null and b/loading.png differ diff --git a/loxide_tight.ico b/loxide_tight.ico new file mode 100644 index 0000000..631fb96 Binary files /dev/null and b/loxide_tight.ico differ diff --git a/models/agent.py b/models/agent.py new file mode 100644 index 0000000..9a4c580 --- /dev/null +++ b/models/agent.py @@ -0,0 +1,75 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from dataclasses import dataclass, field +from typing import ClassVar, List, Optional + +from models.policy import Policy + + +@dataclass +class Agent: + agentid: str + clientversion: str + domain: str + freespace: int + groupid: str # Changed to str to match UUID-style IDs + hostname: str + ip: str + localip: str + lastcheckin: str + os: str + policyversion: str + status: int # raw status code + username: str + groupname: Optional[str] = field(default=None) + status_text: Optional[str] = field(default=None) + + # Class-level status map + status_map: ClassVar[dict] = { + 0: "Offline", + 1: "Online", + 2: "Hidden", + 3: "Safemode" + } + + + def enrich_with_policies(self, policies: List[Policy]): + """Enrich the agent with groupname and human-readable status.""" + self.status_text = self.status_map.get(self.status, "Unknown") + for policy in policies: + if policy.groupid == self.groupid: + self.groupname = policy.name + break + if not self.groupname: + self.groupname = "Unknown" +""" + +from models.agent import Agent +from modesls.policy + +# Step 1: Load data from API +policies = [Policy(**row['data']) for _, row in api.policy_find_all().iterrows()] +agents = [Agent(**row['data']) for _, row in api.agent_find_all().iterrows()] + +# Step 2: Create groupid → groupname map +groupid_to_name = {policy.groupid: policy.name for policy in policies} + +# Step 3: Enrich agents +for agent in agents: + agent.enrich_with_policies(groupid_to_name) + + +""" diff --git a/models/execution.py b/models/execution.py new file mode 100644 index 0000000..13281d1 --- /dev/null +++ b/models/execution.py @@ -0,0 +1,471 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import dataclasses +from dataclasses import asdict, dataclass +from datetime import datetime +import inspect +import json +import logging +import os +import re +from typing import List, Optional, Tuple + +import dotenv +import pandas as pd + +import airlock_libs +from services.API import AirlockAPIWrapper +from services.policyhandler import pullPolicyExechistories +from utils.configmanager import get_protected_value, load_env_json +from utils.utils import colorText, regulator + +logger = logging.getLogger(__name__) + +dotenv.load_dotenv() + +@dataclass +class Hash: + """ + Hash model representing Hash data + """ + sha256: str + applications: str + baselines: str + blocklists: str + createtime: str + datetime: str + description: str + filename: str + filepath: str + filesize: str + md5: str + modtime: str + origname: str + productname: str + productversion: str + publisher: str + reputation: str + sha128: str + sha384: str + sha512: str + at_decision: Optional[str] = None + + def to_dict(self): + return asdict(self) + + @classmethod + def from_dict(cls, data: dict): + return cls(**data) + + @classmethod + def deduplicate(cls, hash_list): + """ + Deduplicates a list of Hash objects based on sha256. + Args: + hash_list (list): List of Hash instances. + Returns: + list: Deduplicated list of Hash instances. + """ + seen = set() + deduped = [] + for h in hash_list: + if h.sha256 not in seen: + seen.add(h.sha256) + deduped.append(h) + return deduped + + @classmethod + def categorize_hashes(cls, hashes): + threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int) + bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) + pups_pattern = regulator(load_env_json("PUPS", "[]")) + + approved_count = 0 + unapproved_count = 0 + needs_review_count = 0 + + for hash_obj in hashes: + publisher = hash_obj.publisher or "" + description = hash_obj.description or "" + reputation = hash_obj.reputation if isinstance(hash_obj.reputation, dict) else {} + scannermatch = reputation.get("scannermatch") + + logger.debug(f"Evaluating hash: {hash_obj}") + logger.debug(f"Publisher: {publisher}, Description: {description}, Scannermatch: {scannermatch}") + + # 1. Unapproved: bad publisher or PUP + if re.search(bad_publishers_pattern, publisher, re.IGNORECASE): + logger.debug("Unapproved: Publisher matches bad publisher pattern.") + hash_obj.at_decision = "unapproved" + unapproved_count += 1 + continue + + if re.search(pups_pattern, description, re.IGNORECASE): + logger.debug("Unapproved: Description matches PUP pattern.") + hash_obj.at_decision = "unapproved" + unapproved_count += 1 + continue + + # 2. Approved: signed + if publisher != "Not Signed": + logger.debug("Approved: File is signed and not flagged.") + hash_obj.at_decision = "approved" + approved_count += 1 + continue + + # 3. Approved or Unapproved based on threat level + try: + score = int(scannermatch) # pyright: ignore[reportArgumentType] + logger.debug(f"Parsed scannermatch score: {score}") + if score > threat_tolerance: # pyright: ignore[reportOperatorIssue] + logger.debug("Unapproved: Unsigned file with high threat score.") + hash_obj.at_decision = "unapproved" + unapproved_count += 1 + else: + logger.debug("Approved: Unsigned file with low threat score.") + hash_obj.at_decision = "approved" + approved_count += 1 + except (ValueError, TypeError): + logger.debug("Needs Review: Scannermatch score is missing or invalid. — {e}") + hash_obj.at_decision = "needs_review" + needs_review_count += 1 + + + logger.debug(f"Final counts — Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}") + return hashes + + + @classmethod + def export_to_csv(cls, hash_list, directory_path): + """ + Exports a list of Hash objects to a CSV file in the specified directory. + The filename is derived from the variable name of the list if possible, + and includes a timestamp to ensure uniqueness. + """ + filename = "hashes_export.csv" + frame = inspect.currentframe() + if frame is not None and frame.f_back is not None: + callers_local_vars = frame.f_back.f_locals.items() + for var_name, var_val in callers_local_vars: + if var_val is hash_list: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{var_name}_{timestamp}.csv" + break + else: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"hashes_export_{timestamp}.csv" + + os.makedirs(directory_path, exist_ok=True) + file_path = os.path.join(directory_path, filename) + + df = pd.DataFrame([h.to_dict() for h in hash_list]) + df.to_csv(file_path, index=False) + + logger.info(f"CSV file saved to: {file_path}") + + +@dataclass +class ExecutionHistoryRecord: + # Mandatory fields + username: str + hostname: str + netdomain: str + filename: str + ppolicy: str + policyname: str + policyver: str + commandline: str + publisher: str + sha256: str + datetime: str + + # Optional fields + type: Optional[int] = None + pprocess: Optional[str] = None + gprocess: Optional[str] = None + md5: Optional[str] = None + sha128: Optional[str] = None + sha384: Optional[str] = None + sha512: Optional[str] = None + ip: Optional[str] = None + localip: Optional[str] = None + extid: Optional[str] = None + extname: Optional[str] = None + exttype: Optional[int] = None # 1 = CRX Chromium Extension, 2 = XPI Firefox Extension + extbrowser: Optional[int] = None # 1 = Chrome, 2 = Firefox, 3 = Edge + hash_obj: Optional[Hash] = None + + + @classmethod + def from_dict(cls, data: dict): + mandatory_fields = [ + "username", + "hostname", + "netdomain", + "filename", + "ppolicy", + "policyname", + "policyver", + "commandline", + "publisher", + "sha256", + "datetime", + ] + missing_fields = [ + field for field in mandatory_fields if field not in data or data[field] is None + ] + if missing_fields: + raise ValueError(f"Missing mandatory fields: {missing_fields}") + + return cls( + username=data["username"], + hostname=data["hostname"], + netdomain=data["netdomain"], + filename=data["filename"], + ppolicy=data["ppolicy"], + policyname=data["policyname"], + policyver=data["policyver"], + commandline=data["commandline"], + publisher=data["publisher"], + sha256=data["sha256"], + datetime=data["datetime"], + type=data.get("type"), + pprocess=data.get("pprocess"), + gprocess=data.get("gprocess"), + md5=data.get("md5"), + sha128=data.get("sha128"), + sha384=data.get("sha384"), + sha512=data.get("sha512"), + ip=data.get("ip"), + localip=data.get("localip"), + extid=data.get("extid"), + extname=data.get("extname"), + exttype=data.get("exttype"), + extbrowser=data.get("extbrowser"), + hash_obj=data.get("hash_obj") + ) + + @classmethod + def from_policies( + cls, api, selected_policies, type_: list, history_days: int + ) -> List["ExecutionHistoryRecord"]: + executions = [] + for policy in selected_policies: + execs = airlock_libs.pull_policy_exec_histories(api, policy.name, str([1,2,6,7]), history_days) + if execs: + data = json.loads(execs) + exechistories = data.get("response", {}).get("exechistories", []) + if not exechistories: + continue + + df = pd.DataFrame(exechistories) + df = df.drop_duplicates(subset=["sha256", "filename", "hostname"]) + df = df.sort_values(by=["sha256", "filename"]) + + executions.extend([cls.from_dict(row.to_dict()) for _, row in df.iterrows()]) + logger.debug(f"Staging of Execution history for policy: {policy.name} is complete") + print( + colorText( + f"Staging of Execution history for policy: {policy.name} is complete", + "green", + ) + ) + + return executions + + @staticmethod + def enrich_with_hashes( + api: AirlockAPIWrapper, + executions: List["ExecutionHistoryRecord"] + ) -> List["ExecutionHistoryRecord"]: + """ + Enriches each ExecutionHistoryRecord with a matching Hash object by querying the API. + """ + sha256_list = list({e.sha256.strip().lower() for e in executions if e.sha256}) + logger.info(f"Extracted {len(sha256_list)} unique sha256 values from {len(executions)} execution records.") + + if not sha256_list: + logger.warning("No sha256 values found in execution records. Skipping enrichment.") + return executions + + logger.debug("Querying hash data from API...") + hash_df = api.hash_query(sha256_list) + logger.info(f"Retrieved {len(hash_df)} hash records from API.") + + hash_objects = [] + required_fields = { + f.name for f in dataclasses.fields(Hash) + if f.default == dataclasses.MISSING and f.default_factory == dataclasses.MISSING + } + + for sha256, (_, row) in zip(sha256_list, hash_df.iterrows()): + row_dict = row.to_dict() + + # Unwrap nested 'data' field if present + if "data" in row_dict and isinstance(row_dict["data"], dict): + row_dict = row_dict["data"] + + # Inject the sha256 back into the row + row_dict["sha256"] = sha256 + + missing = required_fields - row_dict.keys() + if missing: + logger.warning(f"Skipping hash row due to missing fields: {missing}") + logger.debug(f"Row content: {row_dict}") + continue + + try: + hash_obj = Hash.from_dict(row_dict) + hash_objects.append(hash_obj) + except Exception as e: + logger.warning(f"Failed to create Hash from row: {e}") + logger.debug(f"Row content: {row_dict}") + + logger.debug("Converted hash DataFrame to Hash objects.") + + hash_lookup = {h.sha256.strip().lower(): h for h in hash_objects} + logger.debug("Built hash lookup table.") + + enriched_count = 0 + for exec_record in executions: + hash_obj = hash_lookup.get(exec_record.sha256.strip().lower()) + if hash_obj: + exec_record.hash_obj = hash_obj + enriched_count += 1 + + logger.info(f"Enriched {enriched_count} out of {len(executions)} execution records with hash data.") + return executions + + @staticmethod + def categorize_executions_by_hash_decision(executions: List["ExecutionHistoryRecord"]) -> List["ExecutionHistoryRecord"]: + """ + Categorizes the hash_obj of each ExecutionHistoryRecord based on publisher, description, and reputation. + + Modifies the `at_decision` field of each associated Hash object in-place. + + Returns: + List[ExecutionHistoryRecord]: The same list, with hash_obj.at_decision updated. + """ + threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int) + bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]")) + pups_pattern = regulator(load_env_json("PUPS", "[]")) + + approved_count = 0 + unapproved_count = 0 + needs_review_count = 0 + + for record in executions: + hash_obj = record.hash_obj + if not hash_obj: + continue # Skip if no hash object is attached + + publisher = hash_obj.publisher or "" + description = hash_obj.description or "" + reputation = hash_obj.reputation if isinstance(hash_obj.reputation, dict) else {} + scannermatch = reputation.get("scannermatch") + + logger.debug(f"Evaluating hash: {hash_obj}") + logger.debug(f"Publisher: {publisher}, Description: {description}, Scannermatch: {scannermatch}") + + # 1. Unapproved: bad publisher or PUP + if re.search(bad_publishers_pattern, publisher, re.IGNORECASE): + logger.debug("Unapproved: Publisher matches bad publisher pattern.") + hash_obj.at_decision = "unapproved" + unapproved_count += 1 + continue + + if re.search(pups_pattern, description, re.IGNORECASE): + logger.debug("Unapproved: Description matches PUP pattern.") + hash_obj.at_decision = "unapproved" + unapproved_count += 1 + continue + + # 2. Approved: signed + if publisher != "Not Signed": + logger.debug("Approved: File is signed and not flagged.") + hash_obj.at_decision = "approved" + approved_count += 1 + continue + + # 3. Approved or Unapproved based on threat level + try: + score = int(scannermatch) # pyright: ignore[reportArgumentType] + logger.debug(f"Parsed scannermatch score: {score}") + if threat_tolerance is not None and score >= threat_tolerance: + logger.debug("Unapproved: Unsigned file with high threat score.") + hash_obj.at_decision = "unapproved" + unapproved_count += 1 + else: + logger.debug("Approved: Unsigned file with low threat score.") + hash_obj.at_decision = "approved" + approved_count += 1 + except (ValueError, TypeError) as e: + logger.debug(f"Needs Review: Scannermatch score is missing or invalid. — {e}") + hash_obj.at_decision = "needs_review" + needs_review_count += 1 + + logger.debug( + f"Final counts — Needs Review: {needs_review_count}, " + f"Approved: {approved_count}, Unapproved: {unapproved_count}" + ) + + return executions + + + @classmethod + def sort_by_hash_decision( + cls, executions: List["ExecutionHistoryRecord"] + ) -> Tuple[List["ExecutionHistoryRecord"], List["ExecutionHistoryRecord"], List["ExecutionHistoryRecord"], List["ExecutionHistoryRecord"]]: + """ + Sorts ExecutionHistoryRecord objects into approved, unapproved, needs_review, and unknown groups + based on the value of hash_obj.at_decision. + + Returns: + Tuple of lists: (approved, unapproved, needs_review, unknown) + """ + approved = [] + unapproved = [] + needs_review = [] + unknown = [] + + sorted_executions = sorted(executions, key=lambda x: x.filename) + + for record in sorted_executions: + decision = getattr(record.hash_obj, "at_decision", None) + if decision == "approved": + approved.append(record) + elif decision == "unapproved": + unapproved.append(record) + elif decision == "needs_review": + needs_review.append(record) + else: + unknown.append(record) + + logger.info(f"[ExecutionHistoryRecord] Sorted {len(sorted_executions)} records by hash_obj.at_decision:") + logger.info(f" Approved: {len(approved)}") + logger.info(f" Unapproved: {len(unapproved)}") + logger.info(f" Needs Review: {len(needs_review)}") + logger.info(f" Unknown/Unset: {len(unknown)}") + + return approved, unapproved, needs_review, unknown + + + +""" +executions = ExecutionHistoryRecord.from_policies(api, selected_policies, type_=[0,1,3], history_days=30) + +ExecutionHistoryRecord.enrich_with_hashes_and_export(executions, hash_objects, "C:/Users/Brandon/Documents/EnrichedExports") +""" diff --git a/models/policy.py b/models/policy.py new file mode 100644 index 0000000..7040f32 --- /dev/null +++ b/models/policy.py @@ -0,0 +1,65 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json + +""" +Policy model representing policy data and relationships. +""" + + +class Policy: + def __init__(self, groupid, hidden, name, parent): + self.groupid = groupid + self.hidden = hidden + self.name = name + self.parent = parent + + def __repr__(self): + # Show all current attributes, including dynamically added ones + attrs = ", ".join(f"{key}={repr(value)}" for key, value in self.__dict__.items()) + return f"" + + def to_dict(self): + # Return all attributes as a dictionary + return self.__dict__ + + def to_json(self): + # Convert to JSON string, handling non-serializable types gracefully + return json.dumps(self.to_dict(), default=str) + + +class Allowlist: + """ + Represents Allowlist + """ + + def __init__(self, applicationid, name, version): + self.applicationid = applicationid + self.name = name + self.version = version + + def __repr__(self): + # Show all current attributes, including dynamically added ones + attrs = ", ".join(f"{key}={repr(value)}" for key, value in self.__dict__.items()) + return f"" + + def to_dict(self): + # Return all attributes as a dictionary + return self.__dict__ + + def to_json(self): + # Convert to JSON string, handling non-serializable types gracefully + return json.dumps(self.to_dict(), default=str) diff --git a/requirements.txt b/requirements.txt index 8a3c64f..bb00042 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,29 +1,11 @@ -bson==0.5.10 -certifi==2025.8.3 -charset-normalizer==3.4.3 -colorama==0.4.6 -cramjam==2.11.0 -docopt==0.6.2 -dotenv==0.9.9 -fastparquet==2024.11.0 -fsspec==2025.9.0 -idna==3.10 -ijson==3.4.0 -lxml==6.0.0 -markdown-it-py==4.0.0 -mdurl==0.1.2 +cryptography==46.0.1 +keyring==25.6.0 numpy==2.3.2 -packaging==25.0 pandas==2.3.1 -pretty-tables==3.1.0 -pyarrow==21.0.0 -Pygments==2.19.2 -python-dateutil==2.9.0.post0 python-dotenv==1.1.1 -pytz==2025.2 -requests==2.32.4 -six==1.17.0 +pymongo +requests==2.32.5 +schedule==1.2.2 tqdm==4.67.1 -tzdata==2025.2 urllib3==2.5.0 -yarg==0.1.10 \ No newline at end of file +bson==0.5.10 \ No newline at end of file diff --git a/services/API.py b/services/API.py new file mode 100644 index 0000000..e5a3c76 --- /dev/null +++ b/services/API.py @@ -0,0 +1,302 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import logging +from typing import Dict, List, Optional + +import pandas as pd +import requests + +logger = logging.getLogger(__name__) + + + +class AirlockAPIWrapper: + """ + A wrapper class for interacting with the Airlock API. + Provides methods for managing agents, policies, hashes, OTPs, and execution history. + """ + + def __init__(self, base_url: str, api_key: str): + """ + Initialize the API wrapper. + + Parameters: + - base_url (str): Base URL of the Airlock API. + - api_key (str): API key for authentication. + """ + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.headers = {"X-APIKey": self.api_key} + + def _post(self, endpoint: str, payload: Optional[dict] = None) -> dict: + """ + Internal method to send POST requests to the API. + + Parameters: + - endpoint (str): API endpoint. + - payload (dict, optional): Request payload. + + Returns: + - dict: JSON response from the API. + """ + url = f"{self.base_url}{endpoint}" + data = json.dumps(payload or {}) + try: + logger.debug(f"POST Request to {url} with payload: {payload}") + response = requests.post(url, headers=self.headers, data=data, verify=False) + response.raise_for_status() + logger.debug(f"Response received from {url}") + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"API request failed: {e}") + raise + + # Allowlist Management + def allowlist_find_all(self) -> pd.DataFrame: + """ + Retrieve all applications in the allowlist. + + Returns: + - pd.DataFrame: DataFrame containing allowlisted applications. + """ + result = self._post("/v1/application", {}) + return pd.DataFrame(result["response"]["applications"]) + + # Agent Management + def agent_find_all(self) -> pd.DataFrame: + """Retrieve all agents.""" + result = self._post("/v1/agent/find", {}) + return pd.DataFrame(result["response"]["agents"]) + + def agent_find_by_hostname(self, hostname: str) -> pd.DataFrame: + """Find agents by hostname.""" + payload = {"hostname": hostname} + result = self._post("/v1/agent/find", payload) + return pd.DataFrame(result["response"]["agents"]) + + def agent_find_by_id(self, agentid: str) -> pd.DataFrame: + """Find agents by agent ID.""" + payload = {"agentid": agentid} + result = self._post("/v1/agent/find", payload) + return pd.DataFrame(result["response"]["agents"]) + + def agent_find_by_status(self, status: int) -> pd.DataFrame: + """Find agents by status (0 = Offline, 1 = Online, 3 = Safemode).""" + payload = {"status": status} + result = self._post("/v1/agent/find", payload) + return pd.DataFrame(result["response"]["agents"]) + + def agent_find_by_username(self, username: str) -> pd.DataFrame: + """Find agents by username.""" + payload = {"username": username} + result = self._post("/v1/agent/find", payload) + return pd.DataFrame(result["response"]["agents"]) + + def agent_move(self, agentid: str, groupid: str) -> dict: + """Move an agent to a different group.""" + payload = {"agentid": agentid, "groupid": groupid} + return self._post("/v1/agent/move", payload) + + def agents_find_by_group(self, groupid: str) -> pd.DataFrame: + """Find agents by group ID.""" + payload = {"groupid": groupid} + result = self._post("/v1/agent/find", payload) + return pd.DataFrame(result["response"]["agents"]) + + # Hash Management + def hash_add_to_allowlist(self, applicationid: str, hashes: List[str]) -> dict: + """Add hashes to the allowlist for a specific application.""" + payload = {"applicationid": applicationid, "hashes": hashes} + return self._post("/v1/hash/application/add", payload) + + def hash_query(self, hashes: List[str]) -> pd.DataFrame: + """Query information about specific hashes.""" + payload = {"hashes": hashes} + result = self._post("/v1/hash/query", payload) + return pd.DataFrame(result["response"]["results"]) + + # OTP Management + def otp_find_active(self) -> pd.DataFrame: + """Find active OTPs.""" + payload = {"status": "1"} + result = self._post("/v1/otp/usage", payload) + return pd.DataFrame(result["response"]["otpusage"]) + + def otp_find_awaiting(self) -> pd.DataFrame: + """Find OTPs that are awaiting activation.""" + payload = {"status": "0"} + result = self._post("/v1/otp/usage", payload) + return pd.DataFrame(result["response"]["otpusage"]) + + def otp_find_enforced(self) -> pd.DataFrame: + """Find OTPs that are awaiting activation.""" + payload = {"status": "2"} + result = self._post("/v1/otp/usage", payload) + return pd.DataFrame(result["response"]["otpusage"]) + + def otp_find_revoked(self) -> pd.DataFrame: + """Find OTPs that are awaiting activation.""" + payload = {"status": "3"} + result = self._post("/v1/otp/usage", payload) + return pd.DataFrame(result["response"]["otpusage"]) + + def otp_find_by_agent(self, agentid) -> pd.DataFrame: + """Find OTP by agent.""" + payload = {"agentid": agentid} + result = self._post("/v1/otp/usage", payload) + return pd.DataFrame(result["response"]["otpusage"]) + + def otp_generate(self, agentid: str, duration: int, purpose: str) -> str: + """Generate a new OTP for an agent.""" + payload = { + "duration": str(duration), + "agentid": str(agentid), + "purpose": purpose, + } + result = self._post("/v1/otp/retrieve", payload) + return result["response"]["otpcode"] + + def otp_get_activities(self, otpid: str) -> pd.DataFrame: + """Retrieve activities associated with a specific OTP.""" + payload = {"otpid": otpid} + result = self._post("/v1/otp/activities", payload) + return pd.DataFrame(result["response"]["otpactivities"]) + + def otp_revoke(self, otpid: str) -> dict: + """ + Revoke an active OTP. + Parameters: + - otpid (str): The ID of the OTP to revoke. + Returns: + - dict: JSON response from the API. + """ + payload = {"otpid": otpid} + return self._post("/v1/otp/revoke", payload) + + def otp_validate(self, otpcode: str) -> dict: + """ + Validate an OTP code. + Parameters: + - otpcode (str): The OTP code to validate. + Returns: + - dict: JSON response indicating validity. + """ + payload = {"otpcode": otpcode} + return self._post("/v1/otp/validate", payload) + + + # Policy Management + def policy_add_path_exclusions(self, groupid: str, paths: List[str]) -> dict: + """Add path exclusions to a policy group.""" + payload = {"groupid": groupid, "path": paths} + return self._post("/v1/group/path/add", payload) + + def policy_add_publishers(self, groupid: str, publishers: List[str]) -> dict: + """Add publishers to a policy group.""" + payload = {"groupid": groupid, "publisher": publishers} + return self._post("/v1/group/publisher/add", payload) + + def policy_clone(self, source_groupid: str, target_groupid: str) -> dict: + """Clone a policy from one group to another.""" + payload = {"groupid": source_groupid, "targetgroupid": target_groupid} + return self._post("/v1/group/assign", payload) + + def policy_find_all(self) -> pd.DataFrame: + """Retrieve all policy groups.""" + result = self._post("/v1/group") + return pd.DataFrame(result["response"]["groups"]) + + def policy_list_agents(self, groupid: str) -> pd.DataFrame: + """List agents assigned to a specific policy group.""" + payload = {"groupid": groupid} + result = self._post("/v1/group/agents", payload) + return pd.DataFrame(result["response"]["agents"]) + + def policy_list_allowlists(self, groupid: str) -> pd.DataFrame: + """List allowlists assigned to a specific policy group.""" + payload = {"groupid": groupid} + result = self._post("/v1/group/policies", payload) + return pd.DataFrame(result["response"]["applications"]) + + def policy_set_auditmode(self, groupid: str, auditmode: str) -> dict: + """Set audit mode for a policy group. 1=Audit, 0=Enforcement""" + payload = {"groupid": groupid, "auditmode": auditmode} + return self._post("/v1/group/settings/auditmode", payload) + + def policy_set_script_custom(self, + groupid: str, + script_custom: int, + scripts_audit: List[str], + scripts_disabled: List[str], + scripts_respect: List[str], + ) -> dict: + """Set audit mode for a policy group. 1=Audit, 0=Enforcement""" + payload = {"groupid": groupid, + "script_custom": script_custom, + "scripts_audit": scripts_audit, + "scripts_disabled": scripts_disabled, + "scripts_respect": scripts_respect + } + return self._post("/v1/group/settings/script_custom", payload) + + # Execution History + def history_logging(self, type: List[str], checkpoint: str, policy: List[str]) -> str: + """Retrieve execution history logs.""" + payload = {"type": type, "checkpoint": checkpoint, "policy": policy} + result = self._post("/v1/logging/exechistories", payload) + return result["response"]["exechistories"] + + def history_execution(self, today: str, date_selected: str, agent_name: str) -> List[Dict]: + """ + Retrieve execution history logs. + + "datefrom":"", //(Optional) Datefrom is for date range search, formatted as "YYYY-MM-DD" + "dateto":"", //(Optional) Dateto is for date range search, formatted as "YYYY-MM-DD" + "category":"", //(Optional) Category for filtering type + "hostname":"", //(Optional) Hostname to filter + "username":"admin", //(Optional) Username to filter + "netdomain":"", //(Optional) Domain (or group) to filter + "filename":"", //(Optional) Filename to filter + "ppolicy":"", //(Optional) Parent Policy name to filter + "policyname":"", //(Optional) Policy name to filter + "policyver":"", //(Optional) Policy version to filter (e.g. "v95") + "commandline":"", //(Optional) Commandline to filter + "publisher":"", //(Optional) Publisher to filter + "pprocess":"", //(Optional) Parent Process to filter + "sha256":"", //(Optional) SHA256 hash to filter + "contains":["hostname"], //(Optional) Contains is an array for wildcard searches on a filter + "limit":"5" //(Optional) Limit the amount of results returned, default set to 50 + + """ + + payload = {"datefrom": date_selected, "dateto": today, "hostname": agent_name} + result = self._post("/v1/getexechistory", payload) + return result["response"]["exechistory"] + + +""" +from services.API import AirlockAPIWrapper + + +api = AirlockAPIWrapper(base_url="https://airlock.example.com/api", api_key="your_api_key_here") + +#Example: Get all agents + +agents_df = api.agent_find_all() +print("All Agents:") +print(agents_df) +""" diff --git a/services/agenthandler.py b/services/agenthandler.py new file mode 100644 index 0000000..531fc41 --- /dev/null +++ b/services/agenthandler.py @@ -0,0 +1,323 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +from dataclasses import asdict +from datetime import datetime, timedelta +import json +import logging +import os +import re +from typing import List + +import pandas as pd + +from flows.prepPolicy import selectPolicies +from models.agent import Agent +from models.policy import Policy +from services.API import AirlockAPIWrapper +from utils.configmanager import get_protected_json, load_env +from utils.selector import Selector +from utils.utils import colorText, get_sanitized_input + +logger = logging.getLogger(__name__) + + +def devicehistory(api: AirlockAPIWrapper, outputjson: bool): + agents = selectAgents(api) + history_days = Selector.select_value( + prompt="Enter how many days of history to pull (1–150): ", + value_type=int, + valid_range=(1, 150), + ) + + if not agents or not history_days: + print(colorText("No agents selected or invalid history range.", "red")) + return + + historical_date = (datetime.now() - timedelta(days=history_days)).strftime("%Y-%m-%d") + today = datetime.now().strftime("%Y-%m-%d") + + all_history = [] + + for agent in agents: + try: + exechistory = api.history_execution(today, historical_date, agent.hostname) + except Exception as e: + print(colorText(f"❌ Error retrieving history for {agent.hostname}: {e}", "red")) + continue + + if isinstance(exechistory, list): + for block in exechistory: + record = { + "Command": block.get("commandline", "N/A"), + "Date": block.get("datetime", "N/A"), + "Filename": block.get("filename", "N/A"), + "Policy Name": block.get("policyname", "N/A"), + "Hostname": block.get("hostname", "N/A"), + "Hash": block.get("sha256", "N/A"), + } + all_history.append(record) + + if not outputjson: + for key, value in record.items(): + print(colorText(f"{key}: {value}", "green")) + print("\n") + else: + print(colorText(f"No execution history found for {agent.hostname}.", "yellow")) + + if outputjson: + print(json.dumps(all_history, indent=2)) + + +def findAllAgents(api): + # Step 1: Load data from API + policies = [Policy(**row["data"]) for _, row in api.policy_find_all().iterrows()] + agents = [Agent(**row["data"]) for _, row in api.agent_find_all().iterrows()] + + for agent in agents: + agent.enrich_with_policies(policies) + + return agents + +def findAgents(api, return_dataframe): + agents = selectAgents(api) + working_dir = load_env("WORKING_DIR") + + if not agents: + logging.warning("No agents or policies found.") + print("No agents matched the criteria.") + return + + # Convert enriched agents to DataFrame + agent_dicts = [asdict(agent) for agent in agents] + agent_df = pd.DataFrame(agent_dicts) + + if return_dataframe: + logging.debug("Returning DataFrame to caller.") + return agent_df + + # Otherwise, print and optionally export + print(agent_df) + logging.debug("Displayed DataFrame to console.") + + user_input = get_sanitized_input("\nWould you like to export the results to a CSV file? (y/n): ").strip().lower() + if user_input == 'y': + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"agentsearch_{timestamp}.csv" + file_path = os.path.join(str(working_dir), filename) + + agent_df.to_csv(file_path, index=False) + logging.info(f"Exported DataFrame to {file_path}") + + print( + colorText( + f"\n✅ Matched devices exported to: {working_dir}\\{filename}", + "green", + ) + ) + else: + logging.debug("User declined to export the DataFrame.") + +def collect_device_names() -> List[str]: + print(colorText("🔍 Device Search", "cyan")) + print(colorText("Enter the device hostnames you'd like to search for, one per line.", "cyan")) + print(colorText("When you're done, press Enter twice (Three times if you have a single device).\n", "cyan")) + print(colorText("Example:", "cyan")) + print(colorText("H00000\nUTN00000\ni-hSuperSecretServer\nu-hVenderBroke\n", "cyan")) + print(colorText("Paste or type your device names below:", "white")) + + device_input_lines = [] + empty_line_count = 0 + valid_line_pattern = re.compile(r'^[a-zA-Z0-9_\- ]+$') + + while True: + line = get_sanitized_input("") + stripped_line = line.strip() + + if stripped_line == "": + empty_line_count += 1 + if empty_line_count == 2: + break + continue + else: + empty_line_count = 0 + + if valid_line_pattern.match(stripped_line): + device_input_lines.append(stripped_line) + else: + print(colorText(f"⚠️ Invalid input: '{stripped_line}' — only letters, numbers, underscores, spaces, and hyphens are allowed.", "yellow")) + + return [name for name in device_input_lines if name] + + +def choose_match_type() -> bool: + print(colorText("Use exact match? (Y for exact, N for fuzzy):", "white")) + return get_sanitized_input("").strip().lower() in ["y", "yes"] + + +def match_agents(device_names: List[str], agents: List['Agent'], use_exact: bool) -> List['Agent']: + if use_exact: + return [ + agent for agent in agents + if agent.hostname.lower() in [name.lower() for name in device_names] + ] + else: + pattern = "|".join(map(re.escape, device_names)) + regex = re.compile(pattern, re.IGNORECASE) + return [agent for agent in agents if regex.search(agent.hostname)] + + +def show_unmatched(device_names: List[str], matched_agents: List['Agent'], use_exact: bool): + if use_exact: + unmatched = [name for name in device_names if not any(agent.hostname.lower() == name.lower() for agent in matched_agents)] + else: + unmatched = [name for name in device_names if not any(re.search(re.escape(name), agent.hostname, re.IGNORECASE) for agent in matched_agents)] + + if unmatched: + logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}") + print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow")) + + +def enrich_agents(agents: List['Agent'], policies: List['Policy']): + for agent in agents: + agent.enrich_with_policies(policies) + + +def selectAgents(api: 'AirlockAPIWrapper') -> List['Agent']: + device_names = collect_device_names() + if not device_names: + logger.debug("No device names entered") + print(colorText("⚠️ No device names entered.", "red")) + return [] + + use_exact = choose_match_type() + + policies = [Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()] + agents = [Agent(**row.to_dict()) for _, row in api.agent_find_all().iterrows()] + matched_agents = match_agents(device_names, agents, use_exact) + matched_agents.sort(key=lambda agent: agent.hostname.lower()) + + show_unmatched(device_names, matched_agents, use_exact) + + if not matched_agents: + logger.debug("❌ No matching devices found.") + print(colorText("❌ No matching devices found.", "red")) + return [] + + print(colorText(f"✅ Found {len(matched_agents)} matching device(s).", "green")) + logger.info("Matched agent hostnames:") + rows = (len(matched_agents) + 2) // 3 # 3 columns + for row in range(rows): + line = "" + for col in range(3): + idx = row + col * rows + if idx < len(matched_agents): + line += f"{matched_agents[idx].hostname:<30}" + logger.info(line) + + matched_agents = Selector.select_with_mode( + matched_agents, + label_func=lambda agent: agent.hostname, + header="Matched Devices:" + ) + + if not matched_agents: + logger.debug("❌ No matching devices remain after refinement.") + print(colorText("❌ No matching devices remain after refinement.", "red")) + return [] + + enrich_agents(matched_agents, policies) + return matched_agents + + +def moveAgentToRelatedPolicy( + api: AirlockAPIWrapper, + agent: Agent, + mode: str = "audit", +): + """ + Moves an agent between audit and enforcement policies based on the mode. + + Args: + api: AirlockAPIWrapper instance. + agent: Agent object. + policy_relationship_map: Dict mapping enforcement → audit. + mode: 'audit' to move to audit, 'enforcement' to move to enforcement. + """ + policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + + if mode == "audit": + if agent.groupid in policy_relationship_map: + target_policy = policy_relationship_map[agent.groupid] + elif agent.groupid in policy_relationship_map.values(): + logger.debug(f"Agent {agent.hostname} is already in an audit group. No action needed.") + print(f"Agent {agent.hostname} is already in an audit group. No action needed.") + return + else: + logger.warning(f"Error: No corresponding audit policy found for groupid: {agent.groupid}.") + return + + elif mode == "enforcement": + inverse_map = {v: k for k, v in policy_relationship_map.items()} + if agent.groupid in inverse_map: + target_policy = inverse_map[agent.groupid] + elif agent.groupid in inverse_map.values(): + logger.info(f"Agent {agent.hostname} is already in an enforcement group. No action needed.") + return + else: + logger.warning(f"Error: No corresponding enforcement policy found for groupid: {agent.groupid}.") + return + + else: + logger.error(f"Unknown mode '{mode}'. Use 'audit' or 'enforcement'.") + return + + result = api.agent_move(agent.agentid, target_policy) + return result + + +def toggleEnforcement(api: AirlockAPIWrapper): + choices = ["Audit", "Enforcement", "Exit"] + print(colorText("Move devices to which state?:", "yellow")) + direction = Selector.select_string(choices, False, False) + if direction == "Exit": + pass + else: + devices = selectAgents(api) + for device in devices: + print(device.hostname) + confirm = Selector.confirm("Would you like to continue with these devices? Y/N: ") + if direction and devices and confirm: + for device in devices: + result = moveAgentToRelatedPolicy(api,device, str(direction).lower()) + logger.info(f"{device.hostname}: result: {result}") + get_sanitized_input("Press enter to continue") + +def moveAgents(api: AirlockAPIWrapper): + devices = selectAgents(api) + for device in devices: + print(device.hostname) + confirm_devices = Selector.confirm("Would you like to continue with these devices? Y/N: ") + if devices and confirm_devices: + policies = selectPolicies(api, False) + confirm_move = Selector.confirm(f"Would you like to move these devices to {policies[0].name}?") + if confirm_move: + for device in devices: + result = api.agent_move(device.agentid, policies[0].groupid) + logger.info(f"{device.hostname}: result: {result}") + else: + logger.info("Exiting without change") + get_sanitized_input("Press enter to continue") \ No newline at end of file diff --git a/services/policyhandler.py b/services/policyhandler.py new file mode 100644 index 0000000..5acc422 --- /dev/null +++ b/services/policyhandler.py @@ -0,0 +1,244 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +import datetime +import gc +import json +import logging +import os +import sys + +from bson import ObjectId +import pandas as pd +import tqdm + +from models.policy import Policy +from services.API import AirlockAPIWrapper +from utils.configmanager import get_protected_json +from utils.setup import get_base_directory +from utils.utils import areYouSure, colorText, get_sanitized_input + +logger = logging.getLogger(__name__) + + + +def pullPolicyExechistories( + api: AirlockAPIWrapper, + policy: Policy, + type: list, + days, + outputjson: bool, +): + + file_path = f"{get_base_directory()}\\cache\\chunkinator.json" + + # Ensure the file exists + if not os.path.exists(file_path): + with open(file_path, "w") as file: + json.dump({"error": "Success", "response": {"exechistories": []}}, file) + logger.debug(f"File '{file_path}' has been created.") + else: + logger.debug(f"File '{file_path}' already exists.") + + checkpoint = str(skipback(days)) + json_output = {"error": "Success", "response": {"exechistories": []}} + + with tqdm.tqdm( + file=sys.stdout, + leave=True, + total=10000, + desc=f"Checkpoint Progress: {checkpoint}", + colour="blue", + initial=1, + ) as filebar: + with tqdm.tqdm( + file=sys.stdout, + leave=True, + total=100, + desc=f"Total of {policy} Complete: ", + ) as pbar: + while True: + histories = api.history_logging( + type=type, checkpoint=checkpoint, policy= [policy.name] + ) + + # Ensure histories is a list of dictionaries + if not isinstance(histories, list) or not all( + isinstance(h, dict) for h in histories + ): + logger.error( + "Unexpected response format from API. Expected list of dictionaries." + ) + break + + filebar.total = len(histories) + + if not histories: + break + + for index, history_item in enumerate(histories): + if ( + "checkpoint" not in history_item + or "datetime" not in history_item + ): + continue # Skip malformed entries + + # Update checkpoint on last item + if index == len(histories) - 1: + checkpoint = history_item["checkpoint"] # pyright: ignore[reportArgumentType] + filebar.desc = f"Checkpoint Progress: {checkpoint}" + break + + try: + history_date = datetime.datetime.strptime( + history_item["datetime"].replace(" +0000 UTC", ""), # pyright: ignore[reportArgumentType] + "%Y-%m-%dT%H:%M:%SZ", + ).date() + except ValueError: + continue # Skip if date format is invalid + + if ( + datetime.date.today() - datetime.timedelta(days=days) + ) <= history_date: + json_output["response"]["exechistories"].append(history_item) + + filebar.update(1) + filebar.refresh() + + # Deduplicate entries + seen = {} + if os.path.exists(file_path): + with open(file_path, "r") as file: + existing_data = json.load(file) + combined = ( + existing_data["response"]["exechistories"] + + json_output["response"]["exechistories"] + ) + else: + combined = json_output["response"]["exechistories"] + + for entry in combined: + key = ( + entry.get("sha256"), + entry.get("filename"), + entry.get("hostname"), + ) + seen[key] = entry + + deduplicated = list(seen.values()) + with open(file_path, "w") as file: + json.dump( + { + "error": "Success", + "response": {"exechistories": deduplicated}, + }, + file, + ) + + json_output["response"]["exechistories"].clear() + + # Update progress bar based on last valid item + try: + last_date = datetime.datetime.strptime( + history_item["datetime"].replace(" +0000 UTC", ""), # type: ignore + "%Y-%m-%dT%H:%M:%SZ", + ).date() + date_diff = datetime.date.today() - last_date + percentage_diff = ( + ((days + 10) - date_diff.days) / (days + 10) + ) * 100 + pbar.n = round(percentage_diff) + pbar.set_description_str(f"Total of {policy} Complete: ") + pbar.refresh() + except Exception: + pass + + filebar.n = 1 + + # Final output + with open(file_path, "r") as file: + final_output = json.load(file) + os.remove(file_path) + + return json.dumps(final_output) if outputjson else None + + +def getPolicyInfo(api: AirlockAPIWrapper, policy, type, days): + executionhist_policy = pd.DataFrame() + exehist = pullPolicyExechistories(api, policy, type, days, True) + 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") + print( + colorText( + f"Staging of Execution history for policy: {policy} is complete", + "green", + ) + ) + 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) + + +def updateAuditPoliciesFromEnforcementPolices(api: AirlockAPIWrapper): + policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}") + for enforcement_policy, audit_policy in policy_relationship_map.items(): + api.policy_clone(enforcement_policy, audit_policy) + api.policy_set_auditmode(audit_policy, "1") + + +def confirmUpdateAfromE(api: AirlockAPIWrapper): + areYouSure() + confirmation = get_sanitized_input("Type 'I AGREE' to continue: ") + if confirmation.strip() == "I AGREE": + updateAuditPoliciesFromEnforcementPolices(api) \ No newline at end of file diff --git a/services/security.py b/services/security.py new file mode 100644 index 0000000..aa95929 --- /dev/null +++ b/services/security.py @@ -0,0 +1,172 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import 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.") + + +class APIKeyManager: + _api_key = None + + @classmethod + def load(cls, service: str, username: str, password: str): + cls._api_key = retrieve_api_key(service, username, password) + + @classmethod + def get(cls) -> str: + if cls._api_key is None: + raise ValueError("API key not loaded. Call APIKeyManager.load() first.") + return cls._api_key \ No newline at end of file diff --git a/utils/allowlist.py b/utils/allowlist.py deleted file mode 100644 index 0f02958..0000000 --- a/utils/allowlist.py +++ /dev/null @@ -1,155 +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 . -import datetime -import requests -import json -import os -import utils.pretty as ct -import ijson -import os -from bson import ObjectId -import datetime -import tqdm -import sys - -def pullPolicyExechistories(url, policiesnames, days, outputjson: bool): - file_path = 'chunkinator.json' - if not os.path.exists(file_path): - with open(file_path, 'w') as file: - json.dump({'error': 'Success', 'response': {'exechistories': []}}, file) - print(f"File '{file_path}' has been crated.") - else: - print(f"File '{file_path}' already exists.") - headers = {"X-APIKey": os.getenv('APIKEY')} - checkpoint = str(skipback(days)) - json_output = {'error': 'Success', 'response': {'exechistories': []}} - with tqdm.tqdm(file=sys.stdout, leave=True, total=10000, desc=f"Checkpoint Progess: {checkpoint}", colour="blue", initial=1) as filebar: - with tqdm.tqdm(file=sys.stdout, leave=True, total=100, desc=f"Total of {policiesnames} Complete: ") as pbar: - while True: - json_response_data = checkpoint_stomper(checkpoint, url, policiesnames, headers) - histories = json_response_data['response']['exechistories'] - filebar.total=len(histories) - if not histories: - break - match_found = True - if match_found == True: - for index, item in enumerate(histories): - if index == len(histories) - 1: - checkpoint = item['checkpoint'] - filebar.desc = f"Checkpoint Progress: {checkpoint}" - break - else: - if (datetime.date.today() - datetime.timedelta(days=days) > datetime.datetime.strptime(item['datetime'].replace(' +0000 UTC', ''), '%Y-%m-%dT%H:%M:%SZ').date()): - pass - else: json_output['response']['exechistories'].append(item) - filebar.update(1) - filebar.refresh() - seen = {} - if os.path.exists(file_path): - with open(file_path, 'r') as file: - existing_data = json.load(file) - combined = existing_data['response']['exechistories'] + json_output['response']['exechistories'] - else: - combined = json_output['response']['exechistories'] - for item in combined: - key = (item.get('sha256'), item.get('filename'), item.get('hostname')) - seen[key] = item - deduplicated = list(seen.values()) - with open(file_path, 'w') as file: - json.dump({'error': 'Success', 'response': {'exechistories': deduplicated}}, file) - json_output['response']['exechistories'].clear() - date_diff = datetime.date.today() - datetime.datetime.strptime(item['datetime'].replace(' +0000 UTC', ''), '%Y-%m-%dT%H:%M:%SZ').date() - percentage_diff = (((days + 10) - date_diff.days) / (days + 10)) * 100 - pbar.n = round(percentage_diff) - pbar.set_description_str(f"Total of {policiesnames} Complete: ") - pbar.refresh() - filebar.n = 1 - with open(file_path, 'r') as file: - final_output = json.load(file) - os.remove(file_path) - return json.dumps(final_output) if outputjson else None - -def checkpoint_stomper(checkpoint, url, policy, headers): - json_output = {'error': 'Success', 'response': {'exechistories': []}} - endpoint = url + '/v1/logging/exechistories' - payload_dict = { - "type":[1,2,6,7], - "checkpoint": checkpoint, - "policy": [policy] - } - payload = json.dumps(payload_dict) - with requests.request("POST", endpoint, headers=headers, data=payload, verify=False, stream=True) as response: - parser = ijson.items(response.raw, 'response.exechistories.item') - for item in parser: - key = (item.get('sha256'), item.get('hostname')) - if key not in json_output: - json_output['response']['exechistories'].append(item) - parse_text = json.loads(json.dumps(json_output)) - return parse_text - -def listPolicies(url): - endpoint = url + '/v1/group' - print(ct.colorText("[+] Grabbing All Policies", "cyan")) - payload = {} - headers = { - "X-APIKey": os.getenv('APIKEY') - } - response = requests.request("POST", endpoint, headers=headers, data=payload, verify=False) - parse_text = json.loads(response.text) - policiesnames = [] - policyids = [] - for index, list in enumerate(parse_text['response']['groups'], start=1): - print(ct.colorText(f"{index}. {list['name']}", "yellow")) - policiesnames.append(list['name']) - policyids.append(list['groupid']) - choice = input(ct.colorText("Select Policy Group: ", "white")) - choice = int(choice) - 1 - return choice, policiesnames, policyids - -def listAllowlists(url): - endpoint = url + '/v1/application' - print(ct.colorText("[+] Grabbing All Allowlists", "cyan")) - payload = {} - headers = { - "X-APIKey": os.getenv('APIKEY') - } - response = requests.request("POST", endpoint, headers=headers, data=payload, verify=False) - parse_text = json.loads(response.text) - policiesnames = [] - policyids = [] - for index, list in enumerate(parse_text['response']['applications'], start=1): - if index >= 38: - print(ct.colorText(f"{index}. {list['name']}", "yellow")) - policiesnames.append(list['name']) - policyids.append(list['applicationid']) - choice = int(input(ct.colorText("Select allowlist: ", "white"))) - if choice < 38: - print(ct.colorText("Please only choose an allowlist designed for this use - '38+'","red")) - elif choice >= 38: - choice = choice - 38 - return choice, policiesnames, policyids - #Need else and catch for upper bound - -def skipback(days): - """ - Generate a MongoDB ObjectId for a given number of days ago from today. - Adds 1 extra day to the input to look further back. - """ - adjusted_days = days + 10 - 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) \ No newline at end of file diff --git a/utils/configmanager.py b/utils/configmanager.py new file mode 100644 index 0000000..62c2bdf --- /dev/null +++ b/utils/configmanager.py @@ -0,0 +1,130 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import logging +import os +from pathlib import Path +import sys +from typing import Callable, Optional, TypeVar + +T = TypeVar("T") +logger = logging.getLogger(__name__) + +PROTECTED_KEYS = [ + "APPNAME", + "LOG_LEVEL", + "PATH_EXCLUSION_CONST", + "MIN_FILES_FOR_PATH", + "VT_THREAT_TOLERANCE", + "POLICY_MAP_ENF_AUD" +] + +_protected_config = {} + +def get_system_config_path() -> Path: + # Check inside bundled EXE directory first + bundled_dir = Path(getattr(sys, '_MEIPASS', '')) + bundled_path = bundled_dir / "system_config.json" + if bundled_path.exists(): + return bundled_path + + # Fallback to external location + return Path(__file__).parent.parent / "system_config.json" + +def load_protected_config() -> dict: + global _protected_config + try: + with open(get_system_config_path(), "r") as f: + system_config = json.load(f) + except FileNotFoundError: + logging.warning("⚠️ system_config.json not found. Using built-in defaults.") + system_config = { + "APPNAME": "AirlockTools", + "PATH_EXCLUSION_CONST": 4, + "MIN_FILES_FOR_PATH": 4, + "VT_THREAT_TOLERANCE": 4, + "POLICY_MAP_ENF_AUD": { + "enforced_id": "audit_id" + } + } + + _protected_config = {key: system_config[key] for key in PROTECTED_KEYS} + return _protected_config + +def get_protected_value(key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None) -> Optional[T]: + value = _protected_config.get(key) + if value is None: + logging.warning(f"Protected config key '{key}' not found.") + return default + try: + if isinstance(value, str): + value = value.strip("'\"") + return cast_type(value) + except (ValueError, TypeError): + logging.warning(f"Invalid value for protected key '{key}': {value}. Expected type {cast_type.__name__}.") + return default + +def get_protected_json(key: str, default: str = "{}") -> dict: + raw = _protected_config.get(key, default) + if isinstance(raw, dict): + return raw + 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: + logging.error(f"Failed to parse protected JSON key '{key}': {e}") + return json.loads(default) + + + + +def load_env_json(key: str, default: str): + 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: + logging.error(f"Failed to parse {key}: {e}") + return json.loads(default) + +def load_env(key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None) -> Optional[T]: + """ + Safely retrieves an environment variable and casts it to the desired type. + + Parameters: + key (str): The name of the environment variable. + cast_type (Callable[[str], T], optional): Function to cast the value. Defaults to str. + default (Optional[T], optional): Default value if the variable is not set or invalid. + + Returns: + Optional[T]: The casted value or the default. + """ + value = os.getenv(key) + if value is None: + logger.warning(f"Environment variable '{key}' not set.") + return default + try: + value = value.strip("'\"") # Strip surrounding quotes + return cast_type(value) + except (ValueError, TypeError): + logger.warning(f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}.") + return default \ No newline at end of file diff --git a/utils/getdeviceevents.py b/utils/getdeviceevents.py deleted file mode 100644 index fbb5d66..0000000 --- a/utils/getdeviceevents.py +++ /dev/null @@ -1,80 +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 . -import datetime -import requests -import json -import os -import utils.pretty as ct - -def devicehistory(url, outputjson: bool): - endpoint = url + '/v1/getexechistory' - print("\n") - print(ct.colorText("1. Today", "yellow")) - print(ct.colorText("2. Last 24 Hours", "yellow")) - print(ct.colorText("3. Past 7 Days", "yellow")) - print(ct.colorText("4. Past 30 Days", "yellow")) - print(ct.colorText("5. Custom Date Range","yellow")) - choice = input(ct.colorText("\nSelect Date Range: ", "white")) - today = datetime.date.today() - today = today.strftime("%Y-%m-%d") - if choice == '1': - date_selected = today - elif choice == '2': - date_selected = datetime.date.today() - datetime.timedelta(days=1) - date_selected = date_selected.strftime('%Y-%m-%d') - elif choice == '3': - date_selected = datetime.date.today() - datetime.timedelta(days=7) - date_selected = date_selected.strftime('%Y-%m-%d') - elif choice == '4': - date_selected = datetime.date.today() - datetime.timedelta(days=30) - date_selected = date_selected.strftime('%Y-%m-%d') - elif choice == "5": - print(ct.colorText("Please Input Dates as YYYY-MM-DD", "cyan")) - date_selected = input(ct.colorText("From: ", "white")) - today = input(ct.colorText("Date To: ", "white")) - print(ct.colorText("WARNING: Device Name is Case Sensitive", "red")) - device = input(ct.colorText("Enter Device Name: ", "white")) - payload_dict = { - "datefrom": date_selected, - "dateto": today, - "hostname": device - } - payload = json.dumps(payload_dict) - print(ct.colorText(payload, "green")) - headers = { - "X-APIKey": os.getenv('APIKEY') - } - - response = requests.request("POST", endpoint, headers=headers, data=payload, verify=False) - - if outputjson: - return response - - parse_text = json.loads(response.text) - - # Safely get exechistory - exechistory = parse_text.get('response', {}).get('exechistory') - - if isinstance(exechistory, list): - for block in exechistory: - print(ct.colorText(f"Command: {block.get('commandline', 'N/A')}", "green")) - print(ct.colorText(f"Date: {block.get('datetime', 'N/A')}", "green")) - print(ct.colorText(f"Filename: {block.get('filename', 'N/A')}", "green")) - print(ct.colorText(f"Policy Name: {block.get('policyname', 'N/A')}", "green")) - print(ct.colorText(f"Hostname: {block.get('hostname', 'N/A')}", "green")) - print(ct.colorText(f"Hash: {block.get('sha256', 'N/A')}", "green")) - print("\n") - else: - print(ct.colorText("No execution history found or data is not in expected format.", "red")) \ No newline at end of file diff --git a/utils/hashfunctions.py b/utils/hashfunctions.py deleted file mode 100644 index d617118..0000000 --- a/utils/hashfunctions.py +++ /dev/null @@ -1,375 +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 . -import gc -import json -import os -import pandas as pd -import requests -import utils.pathfunctions as pathf -import utils.hashfunctions as hashf -import utils.pretty as ct -from AirlockTools import tryToReadCSV - -def aggregateHashes(executions_json) -> pd.DataFrame: - """ - Takes the executions, aggregates all the data with sha256 as primary, then returns aggregated dataframe - """ - data = json.loads(executions_json) - df = pd.DataFrame(data["response"]["exechistories"]) - - if df.empty: - return df - print(df) - # Aggregate by sha256, deduplicate lists, and preserve order - agg_df = df.groupby("sha256").agg(lambda x: list(dict.fromkeys(x))).reset_index() - - # Add a column for the number of unique hostnames - agg_df["num_devices"] = agg_df["hostname"].apply(len) - - # Sort by num_devices in descending order - agg_df = agg_df.sort_values("num_devices", ascending=False) - - return agg_df - -def augmentAggregatedHashes(url, agg_df: pd.DataFrame) -> pd.DataFrame: - """ - Takes output of aggregatedHashes, queries API for those hashes, flattens response while keeping one row per hash, - aggregate applications and baselines into lists, then merges results back into agg_df to create a - """ - if 'sha256' not in agg_df.columns or agg_df.empty: - print("⚠️ 'sha256' column missing or DataFrame is empty. Skipping API query.") - return agg_df.copy() # Return as-is to avoid breaking downstream logic - - endpoint = url + '/v1/hash/query' - payload = { - "hashes": agg_df['sha256'].tolist() - } - - headers = {"X-APIKey": os.getenv('APIKEY')} - payload = json.dumps(payload) - - response = requests.post(endpoint, headers=headers, data=payload, verify=False) - data = response.json() - results = data.get("response", {}).get("results", []) - - rows = [] - for res in results: - row = {"sha256": res.get("sha256"), "result": res.get("result")} - - if "data" in res: - d = res["data"] - for key in ["filename", "filepath", "description", "filesize", "md5", - "productname", "productversion", "publisher", "createtime", "modtime", - "sha128", "sha384", "sha512", "datetime"]: - row[key] = d.get(key) - - row["applications"] = d.get("applications", []) - row["baselines"] = d.get("baselines", []) - - reputation = d.get("reputation", {}) - for k, v in reputation.items(): - row[f"reputation_{k}"] = v - - rows.append(row) - - df_api = pd.DataFrame(rows) - - if 'sha256' not in df_api.columns: - print("⚠️ API response missing 'sha256'. Skipping merge.") - return agg_df.copy() - - df = agg_df.merge(df_api, on="sha256", how="left") - - # Only include columns that exist to avoid KeyErrors - expected_columns = ['sha256', 'filename_x', 'description', 'productname', 'productversion', - 'publisher_y', 'publisher_x', 'netdomain', 'hostname', 'username', - 'reputation_lastseen', 'reputation_scannermatch', 'reputation_scannercount', - 'reputation_status', 'reputation_threatlevel', 'reputation_threatname', - 'reputation_timestamp', 'pprocess', 'gprocess', 'commandline'] - - available_columns = [col for col in expected_columns if col in df.columns] - aug_df = df[available_columns] - - return aug_df - -def categorizeHashes(first_policy, second_policy, df: pd.DataFrame, threat_tolerance: int, untrusted_publishers, pups: list): - if untrusted_publishers is None: untrusted_publishers = [] - if pups is None: pups = [] - - def reputationtool(row): - val = row["reputation_scannermatch"] - if pd.isna(val) or val == "N/A": - return row["publisher"] == "Not Signed" - try: - return int(val) > threat_tolerance - except (ValueError, TypeError): - return row["publisher"] == "Not Signed" - - df["reputation_flag"] = df.apply(reputationtool, axis=1) - - mask_needsreview = ( - ((df["publisher"] == "Not Signed") & df["reputation_flag"]) | - (df["reputation_status"] == "UNKNOWN") - ) - - mask_approved = ( - ( - (df["publisher"] != "Not Signed") & - ~df["publisher"].str.contains(pathf.regulator(untrusted_publishers), case=False, na=False) & - ~df["reputation_status"].isna() & - ~df["description"].str.contains(pathf.regulator(pups), case=False, na=False) - ) | - ( - (df["publisher"] == "Not Signed") & - ~df["reputation_flag"] & - ~df["publisher"].str.contains(pathf.regulator(untrusted_publishers), case=False, na=False) & - ~df["reputation_status"].isna() & - ~df["description"].str.contains(pathf.regulator(pups), case=False, na=False) - ) - ) - - needsreview_df = df[mask_needsreview] - approved_df = df[mask_approved] - unapproved_df = df[~(mask_needsreview | mask_approved)] - - needsreview_df.to_parquet(f"parquet\\hashes_rep_unknown_{first_policy}_{second_policy}.parquet", index=False) - approved_df.to_parquet(f"parquet\\hashes_rep_good_{first_policy}_{second_policy}.parquet", index=False) - unapproved_df.to_parquet(f"parquet\\hashes_rep_bad_{first_policy}_{second_policy}.parquet", index=False) - - del needsreview_df - del approved_df - del unapproved_df - gc.collect() - -def explode_and_deduplicate(df): - df['sha256'] = df['sha256'].str.split(',') - df = df.explode('sha256') - return df.drop_duplicates().reset_index(drop=True) - -def clean_sha256(df, column='sha256'): - """Discard quotes, brackets, and whitespace from sha256 values.""" - df[column] = df[column].astype(str).str.strip("'[]\" ") - return df - -def destinationHashes( - df_approved_paths: pd.DataFrame, - df_approved_hashes: pd.DataFrame, - df_hashes_auto_approved: pd.DataFrame, - df_hashes_manually_approved: pd.DataFrame, -): - # Deduplicate and explode all input DataFrames - df_approved_paths = explode_and_deduplicate(df_approved_paths) - df_approved_hashes = explode_and_deduplicate(df_approved_hashes) - df_hashes_auto_approved = explode_and_deduplicate(df_hashes_auto_approved) - df_hashes_manually_approved = explode_and_deduplicate(df_hashes_manually_approved) - - # Clean sha256 values in all relevant DataFrames - df_approved_hashes = clean_sha256(df_approved_hashes) - df_hashes_auto_approved = clean_sha256(df_hashes_auto_approved) - df_hashes_manually_approved = clean_sha256(df_hashes_manually_approved) - - # Create sets for faster lookup - auto_approved_sha256 = set(df_hashes_auto_approved['sha256'].values) - manually_approved_sha256 = set(df_hashes_manually_approved['sha256'].values) - - # Debug: Print unmatched hashes - unmatched = set(df_approved_hashes['sha256']) - (auto_approved_sha256 | manually_approved_sha256) - print(f"Unmatched hashes: {unmatched}") - - # Process df_approved_paths - df_paths = df_approved_paths.assign(destination='Path Exclusion') - df_paths = df_paths[['sha256', 'description', 'destination', 'grouped_directory', 'filename']] - - # Process df_approved_hashes - df_hashes = df_approved_hashes.copy() - df_hashes['destination'] = df_hashes['sha256'].apply( - lambda x: 'Parent Policy Baseline' if x in auto_approved_sha256 - else ('Child Policy Allowlist' if x in manually_approved_sha256 else None) - ) - df_hashes = df_hashes.dropna(subset=['destination']) - df_hashes = df_hashes.assign(grouped_directory=None) - - # Use 'filename_x' only if it exists, otherwise fallback to 'filename' - filename_col = 'filename_x' if 'filename_x' in df_hashes.columns else 'filename' - selected_cols = ['sha256', 'description', 'destination', 'grouped_directory', filename_col] - df_hashes = df_hashes[selected_cols] - - # Concatenate results - df_hashdestination = pd.concat([df_paths, df_hashes], ignore_index=True) - return df_hashdestination - -def combineHashAndHist(path, first_policy, second_policy): - - condensed_combo = pd.read_parquet(f"parquet\\condensed_executions_{first_policy}_{second_policy}.parquet") - df = pd.read_parquet(path) - - #Pull hash info for the entries in the needs approval table - df = pd.merge(condensed_combo, df, on='sha256', how='inner') - - #Rename Publisher, Keep and reorder columns we want - df = df.rename(columns={'publisher_x': 'publisher'}) - df = df[['sha256', 'publisher', 'description', 'filename', 'hostname', 'username', 'productname', 'productversion','reputation_lastseen', 'reputation_scannermatch', 'reputation_scannercount','reputation_status', 'reputation_threatlevel', 'reputation_threatname','reputation_timestamp', 'pprocess', 'gprocess', 'commandline']] - df = df.sort_values(by='filename') - - df.to_parquet(path, index=False) - del df - del condensed_combo - gc.collect() - -def combineHashes(url, first_policy, second_policy): - combined_hashes = pd.DataFrame(columns=['sha256', 'publisher']) - hashes = [] - try: - hash1 = pd.read_parquet(f"parquet\\execution_history_{first_policy}.parquet", columns=['sha256', 'publisher']) - pathf.inspect_parquet(f"parquet\\execution_history_{first_policy}.parquet") - if not hash1.empty: - hashes.append(hash1) - else: - print("⚠️ First dataframe is empty.") - except Exception as e: - print(f"❌ Error reading first Parquet file: {e}") - - try: - hash2 = pd.read_parquet(f"parquet\\execution_history_{second_policy}.parquet", columns=['sha256', 'publisher']) - pathf.inspect_parquet(f"parquet\\execution_history_{second_policy}.parquet") - if not hash2.empty: - hashes.append(hash2) - else: - print("⚠️ Second dataframe is empty.") - except Exception as e: - print(f"❌ Error reading second Parquet file: {e}") - - if hashes: - combined_hashes = pd.concat(hashes, ignore_index=True) - print(f"✅ Combined {len(combined_hashes)} hashes.") - else: - print("⚠️ No valid dataframes to combine.") - - combined_hashes = combined_hashes.drop_duplicates(subset=['sha256']) - augmented_combo = hashf.augmentAggregatedHashes(url, combined_hashes) - - numeric_reputation_cols = [ - 'reputation_scannermatch', - 'reputation_scannercount', - 'reputation_threatlevel' - ] - - for col in numeric_reputation_cols: - if col in augmented_combo.columns: - augmented_combo[col] = pd.to_numeric(augmented_combo[col].replace('N/A', pd.NA), errors='coerce') - - augmented_combo = augmented_combo.rename(columns={'publisher_x': 'publisher'}) - augmented_combo = augmented_combo[['sha256', 'publisher', 'description', 'productname', 'productversion', - 'reputation_lastseen', 'reputation_scannermatch', 'reputation_scannercount', - 'reputation_status', 'reputation_threatlevel', 'reputation_threatname', - 'reputation_timestamp']] - augmented_combo = augmented_combo.sort_values(by=['publisher', 'description', 'productname']) - augmented_combo.to_parquet(f"parquet\\combined_hashlist_{first_policy}_{second_policy}.parquet", index=False) - - del combined_hashes - del augmented_combo - gc.collect() - print(ct.colorText("Hash reputation info added to dataframe", "green")) - -def condenseExecutions(first_policy,second_policy): - exe1 = pd.DataFrame() - exe2 = pd.DataFrame() - condensed_combo = pd.DataFrame() - - try: - exe1 = pd.read_parquet(f"parquet\\execution_history_{first_policy}.parquet") - pathf.inspect_parquet(f"parquet\\execution_history_{first_policy}.parquet") - if not exe1.empty: - print() - else: - print("⚠️ First dataframe is empty.") - except Exception as e: - print(f"❌ Error reading first Parquet file: {e}") - - try: - exe2 = pd.read_parquet(f"parquet\\execution_history_{second_policy}.parquet") - pathf.inspect_parquet(f"parquet\\execution_history_{second_policy}.parquet") - if not exe2.empty: - print() - else: - print("⚠️ Second dataframe is empty.") - except Exception as e: - print(f"❌ Error reading second Parquet file: {e}") - - if not exe1.empty and not exe2.empty: - condensed_combo = pd.concat([exe1, exe2], ignore_index=True) - - print(f"✅ Combined {len(condensed_combo)} hashes.") - elif exe1.empty: - condensed_combo = exe2 - elif exe2.empty: - condensed_combo = exe1 - else: - print("⚠️ No valid dataframes to combine.") - - condensed_combo.to_parquet(f"parquet\\condensed_executions_{first_policy}_{second_policy}.parquet", index=False) - del condensed_combo - gc.collect() - -def divideSortedHashExecutions(first_policy,second_policy, pups): - - combineHashAndHist(f"parquet\\hashes_rep_unknown_{first_policy}_{second_policy}.parquet", first_policy, second_policy) - combineHashAndHist(f"parquet\\hashes_rep_good_{first_policy}_{second_policy}.parquet", first_policy, second_policy) - combineHashAndHist(f"parquet\\hashes_rep_bad_{first_policy}_{second_policy}.parquet", first_policy, second_policy) - - unknown = pd.read_parquet(f"parquet\\hashes_rep_unknown_{first_policy}_{second_policy}.parquet") - good = pd.read_parquet(f"parquet\\hashes_rep_good_{first_policy}_{second_policy}.parquet") - bad = pd.read_parquet(f"parquet\\hashes_rep_bad_{first_policy}_{second_policy}.parquet") - - # Build regex pattern once - pattern = pathf.regulator(pups) - - # Move matching rows from unknown and good to bad - bad = pd.concat([ - bad, - unknown[unknown["filename"].str.contains(pattern, na=False)], - good[good["filename"].str.contains(pattern, na=False)] - ], ignore_index=True) - - # Remove matching rows from unknown and good - unknown = unknown[~unknown["filename"].str.contains(pattern, na=False)] - good = good[~good["filename"].str.contains(pattern, na=False)] - - unknown.to_csv(f"needs_approved\\hashes_rep_unknown_{first_policy}_{second_policy}.csv",index=False) - good.to_csv(f"needs_approved\\hashes_rep_good_{first_policy}_{second_policy}.csv",index=False) - bad.to_csv(f"needs_approved\\hashes_rep_bad_{first_policy}_{second_policy}.csv",index=False) - - ct.style_dataframe_dark(unknown, f"needs_approved\\hashes_rep_unknown_{first_policy}_{second_policy}.html") - ct.style_dataframe_dark(good, f"needs_approved\\hashes_rep_good_{first_policy}_{second_policy}.html") - ct.style_dataframe_dark(bad, f"needs_approved\\hashes_rep_bad_{first_policy}_{second_policy}.html") - -def generatePreflights(first_policy, second_policy): - allhashes = pd.read_parquet(f"parquet\\all_approved_hashes_{first_policy}_{second_policy}.parquet") - - pathexclusions = tryToReadCSV(f"approved\\path_needs_approved_{first_policy}_{second_policy}.csv") - pathexclusions.to_parquet(f"parquet\\final_path_exclusions_{first_policy}_{second_policy}.parquet", index=False) - - allowbyhash = allhashes[~allhashes['sha256'].isin(pathexclusions['sha256'])] - - allowbyhash.to_parquet(f"parquet\\final_hash_approvals_{first_policy}_{second_policy}.parquet", index=False) - - allowbyhash.sort_values(by=["filename"]) - - ct.style_dataframe_dark(allowbyhash, f"preflight\\final_hash_approvals_{first_policy}_{second_policy}.html") - ct.style_dataframe_dark(pathexclusions, f"preflight\\final_path_exclusions_{first_policy}_{second_policy}.html") - - del allowbyhash - del pathexclusions - gc.collect() \ No newline at end of file diff --git a/utils/pathfunctions.py b/utils/pathfunctions.py deleted file mode 100644 index e1ddfbb..0000000 --- a/utils/pathfunctions.py +++ /dev/null @@ -1,190 +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 . -import ast -import gc -import os -import pandas as pd -import re -import utils.pathfunctions as pathf -import utils.pretty as ct -from AirlockTools import tryToReadCSV - - -def split_filepaths_grouped(df, col="filename", group_parts=4, min_parts=4): - def clean_split(path): - parts = os.path.normpath(path).split(os.sep) - # Remove leading empty strings caused by UNC paths - parts = [p for p in parts if p] - return parts - - df = df.copy() - split_paths = df[col].apply(clean_split) - - # Filter out paths with fewer than `min_parts` components - df = df[split_paths.apply(lambda parts: len(parts) >= min_parts)].copy() - split_paths = split_paths[df.index] # Update split_paths to match filtered df - - df["group_key"] = split_paths.apply(lambda parts: os.sep.join(parts[:group_parts])) - grouped = df.groupby("group_key") - new_rows = [] - - for _, group_df in grouped: - paths = group_df[col].tolist() - split_parts = [clean_split(p) for p in paths] - - def longest_common_prefix(paths): - if not paths: - return [] - prefix = paths[0] - for path in paths[1:]: - prefix = [a for a, b in zip(prefix, path) if a == b] - if not prefix: - break - return prefix - - common_prefix = longest_common_prefix(split_parts) - prefix_str = os.sep.join(common_prefix) - - for i, parts in enumerate(split_parts): - filename = parts[-1] - middle = os.sep.join(parts[len(common_prefix):-1]) if len(parts) > len(common_prefix) + 1 else "" - row = group_df.iloc[i].copy() - row["longestcfp"] = prefix_str - row["middle"] = middle - row["filename_only"] = filename - new_rows.append(row) - - return pd.DataFrame(new_rows).drop(columns=["group_key"]) - -def mask_from_csv(df, csv_path, filepath_col): - """ - Reads reviewed CSV of groups, keeps only files in approved groups. - """ - review_df = pd.read_csv(csv_path) - - def parse_paths(val): - if isinstance(val, str): - try: - # Try to parse as a list - parsed = ast.literal_eval(val) - # If it's not a list, wrap it - return parsed if isinstance(parsed, list) else [parsed] - except (ValueError, SyntaxError): - # If parsing fails, treat it as a single path - return [val] - return [val] - - review_df[filepath_col] = review_df[filepath_col].apply(parse_paths) - - # Flatten all approved file paths into a set for masking - approved_files = set() - for paths in review_df[filepath_col]: - approved_files.update(paths) - - # Keep only rows in df that are in approved_files - masked_df = df[df[filepath_col].isin(approved_files)].copy() - remainder = df[~df[filepath_col].isin(approved_files)].copy() - return remainder - -def filter_and_drop(approved, eligiblepaths, min_hashes): - """ - Filters eligiblepaths to rows where all hashes are in approved, - then drops rows with fewer than min_hashes hashes. - """ - approved_hashes = set(approved['sha256']) - - def all_hashes_approved(row): - return all(h in approved_hashes for h in row['sha256']) - - filtered = eligiblepaths[eligiblepaths.apply(all_hashes_approved, axis=1)] - filtered = filtered[filtered['sha256'].apply(len) >= min_hashes] - - return filtered - -def inspect_parquet(path): - try: - df = pd.read_parquet(path) - print(f"✅ Successfully read: {path}") - print(f"📄 Columns: {df.columns.tolist()}") - print(f"🔢 Rows: {len(df)}") - return df - except Exception as e: - print(f"❌ Error reading {path}: {e}") - return pd.DataFrame() - - -def regulator(paths, case_insensitive=True): - """ - Build a regex pattern that matches any of the given Windows path fragments. - """ - escaped = [re.escape(p) for p in paths] - pattern = "(?:" + "|".join(escaped) + ")" - if case_insensitive: - pattern = "(?i)" + pattern # Add inline case-insensitive flag - print(f"Regulator is providing: {pattern}") - return pattern - -def generatePathReview(first_policy, second_policy, badpathparts, min_files_for_path): - - if not os.path.exists(f"parquet\\all_approved_hashes_{first_policy}_{second_policy}.parquet"): - - df1 = tryToReadCSV(f"approved\\hashes_rep_unknown_{first_policy}_{second_policy}.csv") - df2 = tryToReadCSV(f"approved\\hashes_rep_good_{first_policy}_{second_policy}.csv") - - all_approved_hashes = pd.concat([df1 , df2], ignore_index=True).sort_values(by=['filename']) - - - - print(ct.colorText(f"Approved hash lists have been combined","green")) - - all_approved_hashes.to_parquet(f"parquet\\all_approved_hashes_{first_policy}_{second_policy}.parquet", index=False) - del all_approved_hashes - gc.collect() - - if not os.path.exists(f"parquet\\path_needs_approved_{first_policy}_{second_policy}.parquet"): - all_approved_hashes = pd.read_parquet(f"parquet\\all_approved_hashes_{first_policy}_{second_policy}.parquet") - print(ct.colorText(f"Beginning calculating longest common filepaths for path exceptions","green")) - - haslcp = pathf.split_filepaths_grouped(all_approved_hashes) - haslcp.drop_duplicates() - - forbidden = pathf.regulator(badpathparts, True) - forbidden_lcfp = haslcp["longestcfp"].str.contains(forbidden, na=False) - - - print(ct.colorText("Removing forbidden filepaths for path exceptions", "green")) - - # Make a real DataFrame copy before modifying - lcp_not_forbidden = haslcp[~forbidden_lcfp].copy() - - #For the review, drop down to only the columns we care, and then group by the commmon file path, consolidating and dropping dupes - lcp_not_forbidden_review = lcp_not_forbidden[['longestcfp', 'middle', 'filename_only', 'sha256']] - - # Count unique sha256 per longestcfp - unique_sha_counts = lcp_not_forbidden_review.groupby('longestcfp')['sha256'].nunique().reset_index() - unique_sha_counts.columns = ['longestcfp', 'unique_sha256_count'] - - # Merge the count back into the original DataFrame - lcp_not_forbidden_review = lcp_not_forbidden_review.merge(unique_sha_counts, on='longestcfp', how='left') - lcp_not_forbidden_review = lcp_not_forbidden_review[lcp_not_forbidden_review['unique_sha256_count'] >= min_files_for_path] - - lcp_not_forbidden_review.to_parquet(f"parquet\\path_needs_approved_{first_policy}_{second_policy}.parquet",index=False) - lcp_not_forbidden_review.to_csv(f"needs_approved\\path_needs_approved_{first_policy}_{second_policy}.csv",index=False) - ct.style_dataframe_dark(lcp_not_forbidden_review,f"needs_approved\\path_needs_approved_{first_policy}_{second_policy}.html", True) - - del lcp_not_forbidden - del unique_sha_counts - del lcp_not_forbidden_review - diff --git a/utils/policyfunctions.py b/utils/policyfunctions.py deleted file mode 100644 index ccacdec..0000000 --- a/utils/policyfunctions.py +++ /dev/null @@ -1,123 +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 . -import gc -import json -import os -import pandas as pd -import re -import requests -import utils.pretty as ct -import utils.allowlist - - - - -def addHash(policy, hash): - print(f"Adding the following hashes to {policy}:") - for p in hash: - print(p) - - -def addPath(policy, hash): - print(f"Adding the following Path Exclusions to {policy}:") - for p in hash: - print(p) - -def addHashReal(url, allowlistID, hashlist): - endpoint = url + '/v1/hash/application/add' - print(ct.colorText("[+] Grabbing All Categories", "cyan")) - payload = { - "applicationid" : allowlistID, - "hashes" : hashlist - } - headers = { - "X-APIKey": os.getenv('APIKEY') - } - payload = json.dumps(payload) - response = requests.request("POST", endpoint, headers=headers, data=payload, verify=False) - response.raise_for_status() # Raise an error for bad status codes - parse_text = json.loads(response.text) - print(parse_text) - - -def addPathReal(url, grouplistID, pathlist): - endpoint = url + '/v1/group/path/add' - print(ct.colorText("[+] Grabbing All Categories", "cyan")) - payload = { - "groupid" : grouplistID, - "path" : pathlist - } - headers = { - "X-APIKey": os.getenv('APIKEY') - } - print(payload) - payload = json.dumps(payload) - response = requests.request("POST", endpoint, headers=headers, data=payload, verify=False) - print(response.text) - -def getPolicyInfo(url, policy, days): - executionhist_policy = pd.DataFrame() - exehist = utils.allowlist.pullPolicyExechistories(url, policy, days, True) - data = json.loads(exehist) - executionhist_policy = pd.DataFrame(data["response"]["exechistories"]) - if not executionhist_policy.empty: - executionhist_policyxecutionhist_policy = executionhist_policy[['sha256', 'publisher', 'filename', 'hostname', 'username', 'pprocess', 'gprocess', 'commandline']] - executionhist_policy = executionhist_policy.drop_duplicates(subset=['sha256', 'filename', 'hostname']) - executionhist_policy = executionhist_policy.sort_values(by=['sha256', 'filename']) - executionhist_policy.to_parquet(f"parquet\\execution_history_{policy}.parquet", index=False) - print(ct.colorText(f"Staging of Execution history for policy: {policy} is complete", "green")) - del data - del exehist - gc.collect() - return executionhist_policy - -def sendToPolicy(url, first_policy, second_policy, destination_name, destination_id, allowlist_parent_name, allowlist_parent_id, allowlist_child_name, allowlist_child_id): - pathexclusions = pd.read_parquet(f"parquet\\final_path_exclusions_{first_policy}_{second_policy}.parquet") - allowbyhash = pd.read_parquet(f"parquet\\final_hash_approvals_{first_policy}_{second_policy}.parquet") - - ct.areYouSure() - confirmation = input(ct.colorText("Type 'I AGREE' to continue: ","white")) - - if confirmation.strip().upper() == "I AGREE": - print(ct.colorText("Proceeding with the code...", "yellow")) - print(ct.colorText(f"Adding path exclusions to {destination_name}", "yellow")) - pathexcludelist = pathexclusions['longestcfp'].unique().tolist() - - # Regex to match a Windows drive letter at the start (e.g., C:\) - drive_letter_pattern = re.compile(r'^[a-zA-Z]:\\') - - # Processed list - processed_paths = [ - (path if drive_letter_pattern.match(path) else f"\\\\{path}") + "**" - for path in pathexcludelist -] - addPath(url, destination_id,processed_paths) - - print(ct.colorText(f"Adding hashes to {allowlist_parent_name}", "yellow")) - - allowlist_parenthashlist = allowbyhash[allowbyhash['reputation_status'] == 'KNOWN']['sha256'].unique().tolist() - addHash(url, allowlist_parent_id,allowlist_parenthashlist) - - print(ct.colorText(f"Adding hashes to {allowlist_child_name}", "yellow")) - allowlist_childhashlist = allowbyhash[allowbyhash['reputation_status'] == 'UNKNOWN']['sha256'].unique().tolist() - addHash(url, allowlist_child_id, allowlist_childhashlist) - - ct.locked() - - exit() - - else: - print(ct.colorText("Operation aborted. You MUST EXPLICITLY AGREE to proceed.", "red")) - \ No newline at end of file diff --git a/utils/selector.py b/utils/selector.py new file mode 100644 index 0000000..714e197 --- /dev/null +++ b/utils/selector.py @@ -0,0 +1,334 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import logging +from typing import 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 = 4, + 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 = 4 + ) -> 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 = 4 + ) -> 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()] + label_func = lambda row: 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") + label_func = lambda row: " | ".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 [] \ No newline at end of file diff --git a/utils/setup.py b/utils/setup.py new file mode 100644 index 0000000..f7ba7f2 --- /dev/null +++ b/utils/setup.py @@ -0,0 +1,203 @@ +# Copyright (C) 2025 James Brotosky, Brandon Wickline +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import json +import logging +import logging.config +import logging.handlers +import os +from pathlib import Path +import platform +import sys + +from dotenv import load_dotenv, set_key + +from utils.configmanager import PROTECTED_KEYS, load_protected_config + + +def get_base_directory() -> Path: + system = platform.system() + home = Path.home() + if system == 'Windows': + return Path(os.getenv('APPDATA', home / 'AppData' / 'Roaming')) / "AirlockTools" + elif system == 'Darwin': + return home / 'Library' / 'Application Support' / "AirlockTools" + else: + return home / '.local' / 'share' / "AirlockTools" + + +def configure_logging(log_dir: Path, log_level: str = "DEBUG"): + log_file = log_dir / "airlocktools.log" + + config = { + "version": 1, # Required key for dictConfig format version + "disable_existing_loggers": False, # Keeps existing loggers active + "formatters": { + "detailed": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + # Includes timestamp, logger name, level, and message + }, + "simple": { + "format": "%(levelname)s - %(message)s" + # Minimal format for console output + }, + }, + "handlers": { + "file": { + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": str(log_file), + "when": "midnight", # Rotate logs at midnight + "interval": 1, # Every 1 day + "backupCount": 7, # Keep 7 days of logs + "encoding": "utf-8", # Ensure UTF-8 encoding + "level": "DEBUG", # Always log DEBUG and above + "formatter": "detailed", # Use detailed format + }, + "console": { + "class": "logging.StreamHandler", + "level": log_level.upper(), # Configurable log level + "formatter": "simple", # Use simple format + }, + }, + "root": { + "level": "DEBUG", # Root logger level + "handlers": ["file", "console"], # Attach both handlers + }, + } + + # Add Windows Event Log handler if on Windows + if platform.system() == "Windows": + try: + config["handlers"]["eventlog"] = { + "class": "logging.handlers.NTEventLogHandler", + "appname": "AirlockTools", # Event log source name + "level": "CRITICAL", # Only log critical errors + "formatter": "simple", # Use simple format + } + config["root"]["handlers"].append("eventlog") + except Exception as e: + logging.warning(f"Could not attach Windows Event Log handler: {e}") + + # Apply the logging configuration + logging.config.dictConfig(config) + 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": "AirlockTools", + "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 = { + "URL": "", + "LOG_LEVEL": "INFO" + } + 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(): + base_dir = get_base_directory() + dirs = { + 'config': base_dir / 'config', + 'cache': base_dir / 'cache', + 'logs': base_dir / 'logs', + } + + for name, path in dirs.items(): + path.mkdir(parents=True, exist_ok=True) + logging.debug(f"{name.capitalize()} directory ensured at: {path}") + + system_config = load_system_config() + configure_logging(dirs['logs'], system_config.get("LOG_LEVEL", "DEBUG")) + + env_path = base_dir / ".env" + if not env_path.exists(): + env_path.touch() + load_dotenv(dotenv_path=env_path, override=True) + + working_dir = Path(os.getenv("WORKING_DIR") or (base_dir / "data")) + working_dir.mkdir(parents=True, exist_ok=True) + set_key(env_path, "WORKING_DIR", str(working_dir)) + os.environ["WORKING_DIR"] = str(working_dir) + logging.debug(f"Working directory set to: {working_dir}") + + folders_structure = { + "Approved": [], + "Needs_Review": ["Review_First", "Review_Second", "HTML"], + "Preflight": ["HTML"], + "Archived": [] + } + + for folder_name, subfolders in folders_structure.items(): + folder_path = working_dir / folder_name + folder_path.mkdir(parents=True, exist_ok=True) + logging.debug(f"'{folder_name}' folder ensured at: {folder_path}") + for subfolder in subfolders: + subfolder_path = folder_path / subfolder + subfolder_path.mkdir(parents=True, exist_ok=True) + logging.debug(f" └─ '{subfolder}' subfolder created at: {subfolder_path}") + + user_config = load_user_config(dirs['config']) + 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) \ No newline at end of file diff --git a/utils/tui.py b/utils/tui.py new file mode 100644 index 0000000..36deeda --- /dev/null +++ b/utils/tui.py @@ -0,0 +1,489 @@ +import logging +import os +import sys + +import dotenv +from dotenv import set_key +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import ( + Button, + DirectoryTree, + Footer, + Header, + Static, + Tab, + Tabs, + Tree, +) + +from flows.otp import otp_activities_by_agent, otp_generate, otp_revoke +from flows.prepPolicy import menu_policy_enforce +from flows.quietAgent import findQuietAgents +from services.agenthandler import findAgents, moveAgents, toggleEnforcement +from services.API import AirlockAPIWrapper +from services.policyhandler import confirmUpdateAfromE +from utils.configmanager import load_env +from utils.setup import get_base_directory, load_user_config +from utils.utils import open_directory + +dotenv.load_dotenv() + +# --------------------------------------------------------------------------- +# GLOBAL STASH +# --------------------------------------------------------------------------- + +_PENDING_JOB = None + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# helper to persist TEXTUAL_THEME to *user* config and mirror to .env +# --------------------------------------------------------------------------- +def _persist_user_theme(theme_name: str) -> None: + """ + Store the chosen Textual theme in the user's config: + /config/user_config.json + and also mirror to /.env so load_env(...) sees it. + """ + base_dir = get_base_directory() + 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: + set_key(str(env_path), "TEXTUAL_THEME", theme_name) + except Exception as exc: # keep going even if .env write fails + logger.warning("Failed to mirror TEXTUAL_THEME to .env: %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) + + + + +# --------------------------------------------------------------------------- +# 1) SCREEN +# --------------------------------------------------------------------------- +class MainMenuScreen(Screen): + current_tab = reactive("") + + BUTTON_DEFS = { + "find": [ + ("🔍 - Device Search", "find_device_button"), + ("🔇 - Find Quiet Hosts", "find_quiet_button"), + ], + "move": [ + ("✅ - Move to local approval", "move_local_button"), + ("🔄 - Move to Audit/Enforcement", "move_audit_button"), + ("🔀 - Move - Other", "move_other_button"), + ], + "otp": [ + ("🔐 - Generate OTPs", "otp_generate_button"), + ("📊 - OTP Activities By Agent", "otp_activities_button"), + ("❌ - Revoke OTPs", "otp_revoke_button"), + ], + "policy": [ + ("🔒 - Prepare Policy For Enforcement", "policy_prep_button"), + ("🔄 - Update Audit Policies", "policy_audit_update_button"), + ], + } + + # textual themes to expose + THEME_BUTTONS = [ + ("textual-dark", "textual-dark"), + ("textual-light", "textual-light"), + ("nord", "nord"), + ("gruvbox", "gruvbox"), + ("catppuccin-mocha", "catppuccin-mocha"), + ("dracula", "dracula"), + ("tokyo-night", "tokyo-night"), + ("monokai", "monokai"), + ("flexoki", "flexoki"), + ("catppuccin-latte", "catppuccin-latte"), + ("solarized-light", "solarized-light"), + ] + + def __init__(self) -> None: + super().__init__() + self.extras = load_env("EXTRAS") + wd = load_env("WORKING_DIR") or os.getcwd() + if not os.path.isdir(wd): + wd = os.getcwd() + self.working_dir = wd + + + def _make_buttons_for(self, tab_id: str) -> Vertical: + defs = self.BUTTON_DEFS.get(tab_id, []) + buttons = [] + for label, btn_id in defs: + btn = Button(label, id=btn_id) + btn.styles.width = "100%" # Make button span full width of parent + buttons.append(btn) + return Vertical(*buttons) + + + + + def compose(self) -> ComposeResult: + yield Header(show_clock=True, icon="⚙") + + tabs = [ + Tab("Policy Tree", id="p_tree"), + Tab("Device Search", id="find"), + Tab("Move Agent", id="move"), + Tab("OTP", id="otp"), + Tab("Directory", id="dir"), + Tab("Settings", id="settings"), + ] + + if self.extras == "POLICYPREP": + tabs.insert(3, Tab("Policy Prep", id="policy")) + + yield Tabs(*tabs, id="tabs") + yield Vertical(id="content") + yield Footer() + + def on_mount(self) -> None: + self.switch_tab("find") + + # focus helpers + def _get_content_buttons(self) -> list[Button]: + content = self.query_one("#content", Vertical) + return list(content.query(Button)) + + def _focus_first_button(self) -> None: + buttons = self._get_content_buttons() + if buttons: + buttons[0].focus() + + def _focus_tabs(self) -> None: + tabs = self.query_one("#tabs", Tabs) + tabs.focus() + + def _focus_nearby_button(self, direction: int) -> None: + buttons = self._get_content_buttons() + if not buttons: + return + + try: + current = next(i for i, b in enumerate(buttons) if b.has_focus) + except StopIteration: + if direction > 0: + buttons[0].focus() + else: + buttons[-1].focus() + return + + if direction < 0 and current == 0: + self._focus_tabs() + return + + new_index = current + direction + if 0 <= new_index < len(buttons): + buttons[new_index].focus() + + + def switch_tab(self, tab_id: str) -> None: + self.current_tab = tab_id + content = self.query_one("#content", Vertical) + content.remove_children() + + if tab_id in self.BUTTON_DEFS: + content.mount(self._make_buttons_for(tab_id)) + self.call_later(self._focus_first_button) + elif tab_id == "dir": + content.mount(DirectoryTree(self.working_dir, id="dir_tree")) + elif tab_id == "p_tree": + layout = Horizontal() + content.mount(layout) + + # Left: Policy Tree + policy_tree = Tree("Policies", id="policy_tree") + policy_tree.styles.width = "2fr" + layout.mount(policy_tree) + + # Right: Details pane + details_pane = Static("Select a policy or device to view details", id="details-pane") + details_pane.styles.width = "3fr" + layout.mount(details_pane) + + # Build the tree + node_map = {} + + # Top-level policies + for _, policy in self.app.policies.iterrows(): + if policy["parent"] == "global-policy-settings": + node = policy_tree.root.add(label=policy["name"], data=policy.to_dict()) + node_map[policy["groupid"]] = node + + # Child policies + for _, policy in self.app.policies.iterrows(): + parent_id = policy["parent"] + if parent_id in node_map: + parent_node = node_map[parent_id] + node = parent_node.add(label=policy["name"], data=policy.to_dict()) + node_map[policy["groupid"]] = node + + # Devices under policies + for _, device in self.app.devices.iterrows(): + group_id = device["groupid"] + if group_id in node_map: + parent_node = node_map[group_id] + label = device["hostname"] # Keep tree clean + parent_node.add(label=label, data=device.to_dict()) + + + elif tab_id == "settings": + # Create and mount the horizontal container + horizontal_container = Horizontal(id="settings_grid") + horizontal_container.styles.layout = "horizontal" + horizontal_container.styles.height = "auto" + content.mount(Static("Theme Options")) + content.mount(horizontal_container) # Mount the horizontal container first + + # Create 3 columns + for i in range(1): + column = Vertical() + column.styles.width = "1fr" + column.styles.height = "auto" + horizontal_container.mount(column) # Mount each column + + for j in range(i, len(self.THEME_BUTTONS), 1): + if j < len(self.THEME_BUTTONS): + label, btn_id = self.THEME_BUTTONS[j] + button = Button(label, id=f"set_theme_{btn_id}", compact=True) + #button.styles.width = "100%" + column.mount(button) # Mount each button + + else: + content.mount(Static(f"Unknown tab: {tab_id}")) + + def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: + self.switch_tab(event.tab.id) + + def on_tree_node_selected(self, message: Tree.NodeSelected) -> None: + node = message.node + data = node.data + + details_pane = self.query_one("#details-pane", Static) + + if data: + details = "\n".join(f"{key}: {value}" for key, value in data.items()) + else: + details = f"Selected: {node.label}" + + details_pane.update(details) + + + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None: + path = event.path + logger.debug("Directory file selected: %s", path) + try: + open_directory(str(path)) + except Exception as exc: + logger.error("Failed to open %s: %s", path, exc) + self.app.bell() + + def on_button_pressed(self, event: Button.Pressed) -> None: + global _PENDING_JOB + button_id = event.button.id + logger.debug("Button pressed: %s", button_id) + + # theme selection → user config + if button_id.startswith("set_theme_"): + theme_name = button_id.replace("set_theme_", "") + _persist_user_theme(theme_name) + _PENDING_JOB = ("restart",) + self.app.exit() + return + + match button_id: + case "find_device_button": + _PENDING_JOB = ("legacy", findAgents, (self.app.api, False), {}) + case "find_quiet_button": + _PENDING_JOB = ("legacy", findQuietAgents, (self.app.api,), {}) + case "move_local_button": + _PENDING_JOB = ( + "legacy", + print, + ("Move to local approval (placeholder)",), + {}, + ) + case "move_audit_button": + _PENDING_JOB = ("legacy", toggleEnforcement, (self.app.api,), {}) + case "move_other_button": + _PENDING_JOB = ("legacy", moveAgents, (self.app.api,), {}) + case "otp_generate_button": + _PENDING_JOB = ("legacy", otp_generate, (self.app.api,), {}) + case "otp_activities_button": + _PENDING_JOB = ("legacy", otp_activities_by_agent, (self.app.api,), {}) + case "otp_revoke_button": + _PENDING_JOB = ("legacy", otp_revoke, (self.app.api,), {}) + case "policy_prep_button": + _PENDING_JOB = ("legacy", menu_policy_enforce, (self.app.api,), {}) + case "policy_audit_update_button": + _PENDING_JOB = ("legacy", confirmUpdateAfromE, (self.app.api,), {}) + case _: + self.app.bell() + logger.warning("Unknown button pressed: %s", button_id) + return + + logger.debug("Set _PENDING_JOB = %r", _PENDING_JOB) + self.app.exit() + + + + + + +# --------------------------------------------------------------------------- +# 2) APP +# --------------------------------------------------------------------------- +class AirlockTools(App): + CSS = """ + #logo { + width: 100%; + content-align: center middle; + text-align: center; + } + """ + + BINDINGS = [ + ("q", "quit", "Quit"), + ("d", "open_dir", "Open Directory"), + ] + + def __init__(self, api: AirlockAPIWrapper): + self._textual_theme = load_env("TEXTUAL_THEME") or "nord" + super().__init__() + self.api = api + wd = load_env("WORKING_DIR") or os.getcwd() + if not os.path.isdir(wd): + wd = os.getcwd() + self.working_dir = wd + self.policies = api.policy_find_all() + self.devices = api.agent_find_all() + + def on_mount(self) -> None: + self.theme = self._textual_theme + self.push_screen(MainMenuScreen()) + + def action_quit(self) -> None: + global _PENDING_JOB + _PENDING_JOB = None + self.exit() + + def action_open_dir(self) -> None: + screen = self.screen_stack[-1] + if isinstance(screen, MainMenuScreen): + if screen.current_tab != "dir": + screen.switch_tab("dir") + + + +# --------------------------------------------------------------------------- +# 3) TERMINAL + LEGACY +# --------------------------------------------------------------------------- +def _restore_terminal_for_legacy() -> None: + sys.stdout.write("\033[?1049l") + sys.stdout.write("\033[?25h") + sys.stdout.write("\033[0m") + sys.stdout.write("\033[?1000l\033[?1002l\033[?1003l\033[?1006l") + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + + if os.name == "nt": + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) + mode = ctypes.c_ulong() + if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + kernel32.SetConsoleMode(handle, mode.value | 0x0004) + except Exception as exc: + logger.debug("VT enable on Windows failed: %s", exc) + + +def _run_legacy_job(func, args, kwargs) -> None: + logger.debug("Running legacy job: %s", getattr(func, "__name__", func)) + _restore_terminal_for_legacy() + + try: + func(*args, **kwargs) + finally: + try: + input("\nPress Enter to return to the UI...") + except EOFError: + pass + + +# --------------------------------------------------------------------------- +# 4) PUBLIC ENTRYPOINT +# --------------------------------------------------------------------------- +def run_AirlockTools(api: AirlockAPIWrapper) -> None: + global _PENDING_JOB + + while True: + base_dir = get_base_directory() + env_path = base_dir / ".env" + dotenv.load_dotenv(dotenv_path=env_path, override=True) + + _PENDING_JOB = None + app = AirlockTools(api) + + try: + app.run() + except SystemExit as exc: + logger.debug("Caught SystemExit from Textual: %s", exc) + + job = _PENDING_JOB + logger.debug("After app.run(), _PENDING_JOB = %r", job) + + if not job: + break + + if job[0] == "legacy": + _, func, args, kwargs = job + _run_legacy_job(func, args, kwargs) + continue + + if job[0] == "restart": + # just loop again; fresh .env was already loaded at the top + continue + + break + + +# --------------------------------------------------------------------------- +# 5) DEV +# --------------------------------------------------------------------------- +if __name__ == "__main__": + api = AirlockAPIWrapper() + run_AirlockTools(api) diff --git a/utils/pretty.py b/utils/utils.py similarity index 54% rename from utils/pretty.py rename to utils/utils.py index aaba329..539a588 100644 --- a/utils/pretty.py +++ b/utils/utils.py @@ -12,9 +12,377 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import os -def colorText(text: str, color: str) -> str: + +import logging +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) + if user_input.strip() == "": + return user_input # Allow blank lines + if re.match(r'^[a-zA-Z0-9_\- .]+$', user_input.strip()): + return user_input + else: + print("Invalid input. Only letters, numbers, underscores, spaces, hyphens, and periods are allowed.") + + +def regulator(paths, case_insensitive=True): + """ + Build a regex pattern that matches any of the given Windows path fragments. + """ + escaped = [re.escape(p) for p in paths] + pattern = "(?:" + "|".join(escaped) + ")" + if case_insensitive: + pattern = "(?i)" + pattern # Add inline case-insensitive flag + print(f"Regulator is providing: {pattern}") + return pattern +def irtang(): + print( + colorText( + r""" + ███ + ████ ░████████ + █████████████ ███████████████ + █████████████████████ █████████████████████ + ███████████████████ ██████████████████████▓ + ███████████████████ ██████████████████████ + █████████████████████ ███████████████████████ + ████████████████████████████████████████████████████████ + █████████ ██ ██ █████████ + █████████ ██ ███ █ █████████ + █████████ ██ ████ █████ █████████████ + █████████ ██ ██████ █████████████ + ████████ ██ ███████ ████████████░ + ███████ ██ ██▓ ██████ ████████████ + ██████ ██ ████ █████ ███████████ + █████████████████████████████████████████████████ + ▒████████████████████ ██████████████████ + ███████████████████ ███████████████▒ + ███████████████ █████████████ + ██████████ ███████████ + ████████ + ████ +""", + "yellow", + ) + ) +def displayIntro(): + + print( + colorText( + r""" + _____ .__ .__ __ ___________ .__ + / _ \ |__|______| | ____ ____ | | __ \__ ___/___ ____ | | ______ + / /_\ \| \_ __ \ | / _ \_/ ___\| |/ / | | / _ \ / _ \| | / ___/ +/ | \ || | \/ |_( <_> ) \___| < | |( <_> | <_> ) |__\___ \ +\____|__ /__||__| |____/\____/ \___ >__|_ \ |____| \____/ \____/|____/____ > + \/ \/ \/ \/ +""", + "cyan", + ) + ) +def welcome(): + print( + colorText( + "=================================================================================", + "cyan", + ) + ) + print( + colorText( + "======================== Welcome to the Airlock API Tool ========================", + "cyan", + ) + ) + print( + colorText( + "=================================================================================", + "cyan", + ) + ) + +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", "green": "\033[92m", @@ -23,17 +391,17 @@ def colorText(text: str, color: str) -> str: "magenta": "\033[95m", "cyan": "\033[96m", "white": "\033[97m", - "reset": "\033[0m" + "reset": "\033[0m", } - return f"{colors.get(color, colors['reset'])}{text}{colors['reset']}" -def style_dataframe_dark(df, output_html_path=None, overwrite=True): + +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" + filename = output_html_path.replace(".html", "") if output_html_path else "Report" dark_css = """