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 @@
-[](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 = """