First Round Async. Much work left to do, dont trust results of hash categorization presently.
This commit is contained in:
+4
-4
@@ -3,12 +3,12 @@
|
|||||||
*.csv
|
*.csv
|
||||||
*__pycache__*
|
*__pycache__*
|
||||||
*.parquet
|
*.parquet
|
||||||
chunkinator.json
|
*chunkinator.json
|
||||||
jobs.json
|
jobs.json
|
||||||
*.xl*
|
*.xl*
|
||||||
*.exe
|
*.exe
|
||||||
securitytest.py
|
|
||||||
*.toml
|
*.toml
|
||||||
system_config.json
|
system_config.json
|
||||||
Devel_unused/
|
Development_Stubs/
|
||||||
AirlockTools_client*/
|
AirlockTools_client*/
|
||||||
|
.vscode/
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"python.REPL.enableREPLSmartSend": false
|
||||||
|
}
|
||||||
+21
-13
@@ -21,8 +21,13 @@
|
|||||||
#TODO Fix Requirements.txt
|
#TODO Fix Requirements.txt
|
||||||
#TODO Create Generic system_config.json for gitea
|
#TODO Create Generic system_config.json for gitea
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', ".."))
|
||||||
|
sys.path.append(project_root)
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
import dotenv
|
import dotenv
|
||||||
import urllib3
|
import urllib3
|
||||||
@@ -30,21 +35,26 @@ import urllib3
|
|||||||
import utils.menus as menus
|
import utils.menus as menus
|
||||||
from services.API import AirlockAPIWrapper
|
from services.API import AirlockAPIWrapper
|
||||||
from services.security import getAPI
|
from services.security import getAPI
|
||||||
|
from services.TaskQueue import AsyncTaskQueue
|
||||||
from utils.setup import setup
|
from utils.setup import setup
|
||||||
|
|
||||||
urllib3.disable_warnings(
|
urllib3.disable_warnings(
|
||||||
urllib3.exceptions.InsecureRequestWarning
|
urllib3.exceptions.InsecureRequestWarning
|
||||||
)
|
)
|
||||||
|
|
||||||
def main():
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
#Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored
|
#Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored
|
||||||
|
working_dir = await setup()
|
||||||
working_dir = setup()
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.debug("🔍 Logging test: this should appear in both console and file.")
|
logger.debug("🔍 Logging test: this should appear in both console and file.")
|
||||||
dotenv.load_dotenv(dotenv_path=working_dir / ".env")
|
dotenv.load_dotenv(dotenv_path=working_dir / ".env")
|
||||||
|
|
||||||
|
queue = AsyncTaskQueue(worker_count = 3)
|
||||||
|
await queue.start_workers()
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = os.getenv("URL")
|
url = os.getenv("URL")
|
||||||
username = os.getenv("USERNAME")
|
username = os.getenv("USERNAME")
|
||||||
@@ -61,15 +71,13 @@ def main():
|
|||||||
logger.error(f"Configuration error: {e}", exc_info=True)
|
logger.error(f"Configuration error: {e}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
if username:
|
||||||
api = AirlockAPIWrapper(
|
api = AirlockAPIWrapper(
|
||||||
base_url=str(os.getenv("URL")),
|
base_url=str(os.getenv("URL")),
|
||||||
api_key = getAPI(username, "AirlockTools"),
|
api_key = await getAPI(username, "AirlockTools"), # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
|
await menus.menu_main(api, queue)
|
||||||
|
|
||||||
menus.menu_main(api)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -25,11 +25,16 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import Development_Stubs.WIP.localApproval as la
|
||||||
import dotenv
|
import dotenv
|
||||||
import urllib3
|
import urllib3
|
||||||
|
|
||||||
import flows.localApproval as la
|
from Development_Stubs.WIP.Server.scheduler_async import (
|
||||||
from Server.scheduler_async import recurring_job, register_function, reload_jobs, start_scheduler
|
recurring_job,
|
||||||
|
register_function,
|
||||||
|
reload_jobs,
|
||||||
|
start_scheduler,
|
||||||
|
)
|
||||||
from services.API import AirlockAPIWrapper
|
from services.API import AirlockAPIWrapper
|
||||||
from services.policyhandler import updateAuditPoliciesFromEnforcementPolices
|
from services.policyhandler import updateAuditPoliciesFromEnforcementPolices
|
||||||
from services.security import getAPI
|
from services.security import getAPI
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
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.")
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
import 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
|
|
||||||
+32
-40
@@ -1,69 +1,61 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
import asyncio
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from services.agenthandler import selectAgents
|
from services.agenthandler import selectAgents
|
||||||
from services.API import AirlockAPIWrapper
|
from services.API import AirlockAPIWrapper
|
||||||
from utils.selector import Selector
|
from utils.Selector import Selector
|
||||||
from utils.utils import colorText, get_sanitized_input
|
from utils.utils import colorText, get_sanitized_input
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def generate(api: AirlockAPIWrapper):
|
||||||
|
|
||||||
|
|
||||||
def generate(api: AirlockAPIWrapper):
|
|
||||||
otp_dict = {}
|
otp_dict = {}
|
||||||
agents = selectAgents(api)
|
agents = await selectAgents(api)
|
||||||
print(colorText("Would you like to continue with these devices?","white"))
|
|
||||||
|
print(colorText("Would you like to continue with these devices?", "white"))
|
||||||
for agent in agents:
|
for agent in agents:
|
||||||
print(agent.hostname)
|
print(agent.hostname)
|
||||||
confirm = Selector.confirm()
|
|
||||||
|
confirm = await Selector.confirm()
|
||||||
if agents and confirm:
|
if agents and confirm:
|
||||||
requester = get_sanitized_input("Who is requesting the OTP: ")
|
requester = await get_sanitized_input("Who is requesting the OTP: ")
|
||||||
because = get_sanitized_input("Why/What work are they doing?: ")
|
because = await get_sanitized_input("Why/What work are they doing?: ")
|
||||||
|
|
||||||
purpose = f"Requester: {requester} - for : {because}"
|
purpose = f"Requester: {requester} - for : {because}"
|
||||||
possible_durations = [15, 60, 360, 1440, 10080]
|
possible_durations = [15, 60, 360, 1440, 10080]
|
||||||
|
|
||||||
print(colorText("Please select a duration in minutes: ", "white"))
|
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"))
|
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)
|
duration_selected = await Selector.select_int(possible_durations)
|
||||||
|
|
||||||
if duration_selected:
|
if duration_selected:
|
||||||
for agent in agents:
|
async def generate_otp(agent):
|
||||||
otp_code = api.otp_generate(agent.agentid, duration_selected, purpose)
|
otp_code = await api.otp_generate(agent.agentid, duration_selected, purpose) # pyright: ignore[reportArgumentType]
|
||||||
logger.info(f"Generated OTP for {agent.hostname}: {otp_code}")
|
logger.info(f"Generated OTP for {agent.hostname}: {otp_code}")
|
||||||
otp_dict[agent.hostname] = otp_code
|
return agent.hostname, otp_code
|
||||||
|
|
||||||
|
results = await asyncio.gather(*(generate_otp(agent) for agent in agents))
|
||||||
|
otp_dict = dict(results)
|
||||||
|
|
||||||
return otp_dict
|
return otp_dict
|
||||||
|
|
||||||
def otp_activities_by_agent(api: AirlockAPIWrapper):
|
|
||||||
agents = selectAgents(api)
|
async def otp_activities_by_agent(api: AirlockAPIWrapper):
|
||||||
|
agents = await selectAgents(api)
|
||||||
otp_dict = {}
|
otp_dict = {}
|
||||||
|
|
||||||
for agent in agents:
|
for agent in agents:
|
||||||
otp_info = api.otp_find_by_agent(agent.agentid)
|
otp_info = await api.otp_find_by_agent(agent.agentid)
|
||||||
otp_dict[agent.hostname] = otp_info
|
otp_dict[agent.hostname] = otp_info
|
||||||
|
|
||||||
return otp_dict
|
return otp_dict
|
||||||
|
|
||||||
def revoke(api: AirlockAPIWrapper):
|
async def revoke(api: AirlockAPIWrapper):
|
||||||
otp_dict = otp_activities_by_agent(api)
|
otp_dict = await otp_activities_by_agent(api)
|
||||||
list_to_revoke = [entry["otpid"] for entry in otp_dict]
|
list_to_revoke = [entry["otpid"] for entry in otp_dict.values() if entry]
|
||||||
|
|
||||||
if otp_dict and list_to_revoke:
|
if otp_dict and list_to_revoke:
|
||||||
for revokee in list_to_revoke:
|
async def revoke_otp(otpid):
|
||||||
api.otp_revoke(revokee)
|
await api.otp_revoke(otpid)
|
||||||
|
|
||||||
|
await asyncio.gather(*(revoke_otp(otpid) for otpid in list_to_revoke))
|
||||||
|
|||||||
+181
-185
@@ -13,10 +13,11 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
import dotenv
|
import dotenv
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -24,12 +25,12 @@ import pandas as pd
|
|||||||
from models.execution import ExecutionHistoryRecord, Hash
|
from models.execution import ExecutionHistoryRecord, Hash
|
||||||
from models.policy import Allowlist, Policy
|
from models.policy import Allowlist, Policy
|
||||||
from services.API import AirlockAPIWrapper
|
from services.API import AirlockAPIWrapper
|
||||||
|
from services.TaskQueue import AsyncTaskQueue, run_sync_task_in_thread
|
||||||
from utils.configmanager import get_protected_value, load_env, load_env_json
|
from utils.configmanager import get_protected_value, load_env, load_env_json
|
||||||
from utils.selector import Selector
|
from utils.Selector import Selector
|
||||||
from utils.utils import (
|
from utils.utils import (
|
||||||
colorText,
|
colorText,
|
||||||
formatHTML,
|
formatHTML,
|
||||||
import_to_dataframe,
|
|
||||||
regulator,
|
regulator,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,79 +38,86 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
dotenv.load_dotenv()
|
dotenv.load_dotenv()
|
||||||
|
|
||||||
|
async def selectPolicies(api: AirlockAPIWrapper, allow_multiple=True) -> List[Policy]:
|
||||||
|
df = await api.policy_find_all()
|
||||||
|
policies = [Policy(**row.to_dict()) for _, row in df.iterrows()]
|
||||||
|
|
||||||
|
|
||||||
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")
|
logger.debug("Prompting for Policies")
|
||||||
print(colorText("Please select policy/policies", "white"))
|
print(colorText("Please select policy/policies", "white"))
|
||||||
selected = Selector.select_objects(policies, allow_multiple, prompt_each=True)
|
|
||||||
|
selected = await Selector.select_objects(policies, allow_multiple, prompt_each=True)
|
||||||
|
|
||||||
if selected is None:
|
if selected is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Normalize to always return a list
|
logger.debug("Returning selected policies")
|
||||||
logger.debug("Returning {selected.dict}")
|
|
||||||
return selected if isinstance(selected, list) else [selected]
|
return selected if isinstance(selected, list) else [selected]
|
||||||
|
|
||||||
|
|
||||||
def selectAllowlists(api: AirlockAPIWrapper, policy = all, allow_multiple=True) -> List[Allowlist]:
|
async 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()]
|
if policy == "all":
|
||||||
else: allowlists = [Allowlist(**row.to_dict()) for _, row in api.policy_list_allowlists(policy[0].groupid).iterrows()]
|
df = await api.allowlist_find_all()
|
||||||
|
else:
|
||||||
|
df = await api.policy_list_allowlists(policy[0].groupid) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
allowlists = [Allowlist(**row.to_dict()) for _, row in df.iterrows()]
|
||||||
|
|
||||||
logger.debug("Prompting for Allowlist(s)")
|
logger.debug("Prompting for Allowlist(s)")
|
||||||
print(colorText("Please select allowlist(s)", "white"))
|
print(colorText("Please select allowlist(s)", "white"))
|
||||||
selected = Selector.select_objects(allowlists, allow_multiple, prompt_each=True)
|
|
||||||
|
selected = await Selector.select_objects(allowlists, allow_multiple, prompt_each=True)
|
||||||
|
|
||||||
if selected is None:
|
if selected is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Normalize to always return a list
|
|
||||||
logger.debug(f"Returning {selected}")
|
logger.debug(f"Returning {selected}")
|
||||||
return selected if isinstance(selected, list) else [selected]
|
return selected if isinstance(selected, list) else [selected]
|
||||||
|
|
||||||
|
|
||||||
def sortHashes(
|
async def sortHashes(
|
||||||
api: AirlockAPIWrapper,
|
api: AirlockAPIWrapper,
|
||||||
|
queue: AsyncTaskQueue,
|
||||||
selected_policies: List[Policy],
|
selected_policies: List[Policy],
|
||||||
type=[1, 2, 6, 7]
|
type=[1, 2, 6, 7],
|
||||||
|
history_days: Optional[int] = None
|
||||||
):
|
):
|
||||||
working_dir = load_env("WORKING_DIR")
|
if history_days is None:
|
||||||
history_days = Selector.select_value(
|
history_days = await Selector.select_value(
|
||||||
prompt="Enter how many days of history to pull (1–150): ",
|
prompt="Enter how many days of history to pull (1–150): ",
|
||||||
value_type=int,
|
value_type=int,
|
||||||
valid_range=(1, 150),
|
valid_range=(1, 150),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"{history_days} day selected for history")
|
logger.debug(f"{history_days} day selected for history")
|
||||||
|
|
||||||
if history_days is None:
|
if history_days is None:
|
||||||
logging.warning("No history range selected. Aborting.")
|
logging.warning("No history range selected. Aborting.")
|
||||||
return
|
return
|
||||||
|
|
||||||
executions = []
|
executions = []
|
||||||
hashes = []
|
hashes = []
|
||||||
|
working_dir = await load_env("WORKING_DIR")
|
||||||
|
|
||||||
# Pull execution histories for each policy
|
# Pull execution histories for each policy
|
||||||
|
policy_executions = await ExecutionHistoryRecord.from_policies(
|
||||||
policy_executions = ExecutionHistoryRecord.from_policies(
|
|
||||||
api, selected_policies, type_=type, history_days=history_days
|
api, selected_policies, type_=type, history_days=history_days
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"Policy_executions is {policy_executions}")
|
|
||||||
|
|
||||||
|
logger.debug(f"Policy_executions is {policy_executions}")
|
||||||
executions.extend(policy_executions)
|
executions.extend(policy_executions)
|
||||||
logger.debug(f"Executions contains {executions}")
|
logger.debug(f"Executions contains {executions}")
|
||||||
|
|
||||||
if executions:
|
if executions:
|
||||||
hashes = [Hash(sha256=row["sha256"], **row["data"]) for _, row in api.hash_query([record.sha256 for record in executions]).iterrows()
|
sha_list = [record.sha256 for record in executions]
|
||||||
|
hash_df = await api.hash_query(sha_list)
|
||||||
|
hashes = [
|
||||||
|
Hash(sha256=row["sha256"], **row["data"])
|
||||||
|
for _, row in hash_df.iterrows()
|
||||||
]
|
]
|
||||||
|
|
||||||
if hashes:
|
if hashes:
|
||||||
unique_hashes = Hash.deduplicate(hashes)
|
unique_hashes = Hash.deduplicate(hashes)
|
||||||
|
needs_review, approved, unapproved = await Hash.categorize_hashes(hashes=unique_hashes)
|
||||||
needs_review, approved, unapproved = Hash.categorize_hashes(
|
|
||||||
hashes=unique_hashes
|
|
||||||
)
|
|
||||||
|
|
||||||
categories = {
|
categories = {
|
||||||
"needs_review": needs_review,
|
"needs_review": needs_review,
|
||||||
@@ -118,157 +126,158 @@ def sortHashes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for label, category in categories.items():
|
for label, category in categories.items():
|
||||||
csv_path = f"{working_dir}\\Needs_Review\\Review_First\\{label}_executions.csv"
|
csv_path = f"{working_dir}/Needs_Review/Review_First/{selected_policies[0].name}_{label}_executions.csv"
|
||||||
html_path = f"{working_dir}\\Needs_Review\\HTML\\{label}.html"
|
html_path = f"{working_dir}/Needs_Review/HTML/{selected_policies[0].name}_{label}.html"
|
||||||
|
|
||||||
ExecutionHistoryRecord.enrich_with_hashes_and_export(
|
df = await ExecutionHistoryRecord.enrich_with_hashes(executions, category)
|
||||||
executions, category, f"{working_dir}\\Needs_Review\\Review_First", label=label
|
|
||||||
)
|
asyncio.create_task(queue.enqueue(
|
||||||
df = import_to_dataframe(csv_path)
|
f"DF TO CSV {selected_policies[0].name}_{label}",
|
||||||
formatHTML(df, html_path)
|
run_sync_task_in_thread,
|
||||||
|
df.to_csv,
|
||||||
|
csv_path,
|
||||||
|
index=False,
|
||||||
|
encoding='utf-8'
|
||||||
|
))
|
||||||
|
|
||||||
def buildPathsandPublishers(split):
|
|
||||||
working_dir = load_env("WORKING_DIR")
|
|
||||||
df1 = pd.DataFrame()
|
|
||||||
df2 = pd.DataFrame()
|
|
||||||
all_approved_hashes = pd.DataFrame()
|
|
||||||
path1 = f"{working_dir}\\Approved\\approved_executions.csv"
|
|
||||||
path2 = f"{working_dir}\\Approved\\needs_review_executions.csv"
|
|
||||||
|
|
||||||
if os.path.exists(path1):
|
asyncio.create_task(queue.enqueue(
|
||||||
df1 = pd.read_csv(path1)
|
f"DF TO HTML {selected_policies[0].name}_{label}",
|
||||||
else:
|
run_sync_task_in_thread,
|
||||||
logger.warning(f"File not found: {path1}")
|
formatHTML,
|
||||||
|
df,
|
||||||
|
html_path
|
||||||
|
))
|
||||||
|
|
||||||
if os.path.exists(path2):
|
print("sortHashes completed successfully.")
|
||||||
df2 = pd.read_csv(path2)
|
|
||||||
else:
|
|
||||||
logger.warning(f"File not found: {path2}")
|
async def buildPathsandPublishers(queue: AsyncTaskQueue, split):
|
||||||
|
working_dir = await load_env("WORKING_DIR")
|
||||||
|
path1 = f"{working_dir}/Approved/approved_executions.csv"
|
||||||
|
path2 = f"{working_dir}/Approved/needs_review_executions.csv"
|
||||||
|
|
||||||
|
df1 = await asyncio.to_thread(pd.read_csv, path1) if os.path.exists(path1) else pd.DataFrame()
|
||||||
|
if df1.empty:
|
||||||
|
logger.warning(f"File not found or empty: {path1}")
|
||||||
|
|
||||||
|
df2 = await asyncio.to_thread(pd.read_csv, path2) if os.path.exists(path2) else pd.DataFrame()
|
||||||
|
if df2.empty:
|
||||||
|
logger.warning(f"File not found or empty: {path2}")
|
||||||
|
|
||||||
if df1.empty and df2.empty:
|
if df1.empty and df2.empty:
|
||||||
logger.warning("Both DataFrames are empty. Skipping sort.")
|
logger.warning("Both DataFrames are empty. Skipping sort.")
|
||||||
all_approved_hashes = pd.DataFrame()
|
all_approved_hashes = pd.DataFrame()
|
||||||
logger.debug(all_approved_hashes.head)
|
logger.debug(all_approved_hashes.head())
|
||||||
|
return
|
||||||
|
|
||||||
|
all_approved_hashes = pd.concat([df1, df2], ignore_index=True)
|
||||||
|
if "filename_exec" in all_approved_hashes.columns:
|
||||||
|
all_approved_hashes = all_approved_hashes.sort_values(by="filename_exec")
|
||||||
else:
|
else:
|
||||||
all_approved_hashes = pd.concat([df1, df2], ignore_index=True)
|
logger.warning("'filename_exec' column not found in concatenated DataFrame.")
|
||||||
if "filename_exec" in all_approved_hashes.columns:
|
|
||||||
all_approved_hashes = all_approved_hashes.sort_values(by="filename_exec")
|
|
||||||
else:
|
|
||||||
logger.warning("Warning: 'filename_exec' column not found in concatenated DataFrame.")
|
|
||||||
|
|
||||||
if not all_approved_hashes.empty:
|
primary_path_exclusions = await calculatePath(all_approved_hashes, split)
|
||||||
primary_path_exclusions = calculatePath(
|
remaining_hashes = all_approved_hashes[~all_approved_hashes["sha256"].isin(primary_path_exclusions["sha256"])]
|
||||||
all_approved_hashes,
|
secondary_path_exclusions = await calculatePath(remaining_hashes, split)
|
||||||
split,
|
remaining_hashes = remaining_hashes[~remaining_hashes["sha256"].isin(secondary_path_exclusions["sha256"])]
|
||||||
)
|
|
||||||
remaining_hashes = all_approved_hashes[
|
|
||||||
~all_approved_hashes["sha256"].isin(primary_path_exclusions["sha256"])
|
|
||||||
]
|
|
||||||
secondary_path_exclusions = calculatePath(
|
|
||||||
remaining_hashes, split
|
|
||||||
)
|
|
||||||
remaining_hashes = remaining_hashes[
|
|
||||||
~remaining_hashes["sha256"].isin(secondary_path_exclusions["sha256"])
|
|
||||||
]
|
|
||||||
dataframes = {
|
|
||||||
"primary_Paths": primary_path_exclusions,
|
|
||||||
"secondary_Paths": secondary_path_exclusions,
|
|
||||||
"hashes_to_add": remaining_hashes,
|
|
||||||
}
|
|
||||||
logger.debug("Preparing to sort dataframes")
|
|
||||||
for name, df in dataframes.items():
|
|
||||||
logger.debug(f" DataFrame headers: {list(df.columns)}")
|
|
||||||
if name == "hashes_to_add": df.sort_values(by="filename_exec", inplace=True)
|
|
||||||
else: df.sort_values(by="longestcfp", inplace=True)
|
|
||||||
|
|
||||||
df.to_csv(f"{working_dir}\\Needs_Review\\Review_Second\\{name}.csv", index=False)
|
|
||||||
formatHTML(df, f"{working_dir}\\Needs_Review\\HTML\\{name}.html")
|
|
||||||
|
|
||||||
if not all_approved_hashes.empty:
|
dataframes = {
|
||||||
# Drop all not signed, only keep unique values
|
"primary_Paths": primary_path_exclusions,
|
||||||
publist = all_approved_hashes[
|
"secondary_Paths": secondary_path_exclusions,
|
||||||
all_approved_hashes["publisher_hash"] != "Not Signed"
|
"hashes_to_add": remaining_hashes,
|
||||||
].drop_duplicates(subset=["publisher_hash"])
|
}
|
||||||
# Remove Bad publisher if somehow they made it this far
|
|
||||||
pattern = regulator(load_env_json("BAD_PUBLISHERS","[]"))
|
|
||||||
publist = publist[~publist["publisher_hash"].str.contains(pattern, na=False)]
|
|
||||||
publist = publist[["publisher_hash"]]
|
|
||||||
publist.sort_values(by="publisher_hash", inplace=True)
|
|
||||||
publist.to_csv(f"{working_dir}\\Needs_Review\\Review_Second\\publishers.csv", index=False)
|
|
||||||
|
|
||||||
def buildPreflights():
|
|
||||||
working_dir = load_env("WORKING_DIR")
|
|
||||||
|
|
||||||
df1 = pd.DataFrame()
|
|
||||||
df2 = pd.DataFrame()
|
|
||||||
approved_hashes = pd.DataFrame()
|
|
||||||
approved_publishers = pd.DataFrame()
|
|
||||||
|
|
||||||
hash = f"{working_dir}\\Approved\\hashes_to_add.csv"
|
|
||||||
path1 = f"{working_dir}\\Approved\\primary_Paths.csv"
|
|
||||||
path2 = f"{working_dir}\\Approved\\secondary_Paths.csv"
|
|
||||||
publishers = f"{working_dir}\\Approved\\publishers.csv"
|
|
||||||
|
|
||||||
if os.path.exists(hash):
|
|
||||||
approved_hashes = pd.read_csv(hash)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning(f"File not found: {hash}")
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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():
|
for name, df in dataframes.items():
|
||||||
logger.debug(f" DataFrame headers: {list(df.columns)}")
|
logger.debug(f"DataFrame headers for {name}: {list(df.columns)}")
|
||||||
if name == "approved_paths":df.sort_values(by="longestcfp", inplace=True)
|
sort_column = "filename_exec" if name == "hashes_to_add" else "longestcfp"
|
||||||
elif name == "approved_hashes":df.sort_values(by="filename_exec", inplace=True)
|
df.sort_values(by=sort_column, inplace=True)
|
||||||
elif name == "approved_publishers" : df.sort_values(by="publisher_hash", inplace=True)
|
csv_path = f"{working_dir}/Needs_Review/Review_Second/{name}.csv"
|
||||||
|
html_path = f"{working_dir}/Needs_Review/HTML/{name}.html"
|
||||||
df.to_csv(f"{working_dir}\\Preflight\\{name}.csv", index=False)
|
await asyncio.to_thread(df.to_csv, csv_path, index=False)
|
||||||
formatHTML(df, f"{working_dir}\\Preflight\\HTML\\{name}.html")
|
await asyncio.to_thread(formatHTML, df, html_path)
|
||||||
|
|
||||||
def splitFilepathsGrouped(df, col="filename"):
|
publist = all_approved_hashes[all_approved_hashes["publisher_hash"] != "Not Signed"].drop_duplicates(subset=["publisher_hash"])
|
||||||
path_exclusion_constant = get_protected_value("PATH_EXCLUSION_CONST", cast_type= int)
|
pattern = regulator(await load_env_json("BAD_PUBLISHERS", "[]"))
|
||||||
min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type= int)
|
publist = publist[~publist["publisher_hash"].str.contains(pattern, na=False)]
|
||||||
|
publist = publist[["publisher_hash"]]
|
||||||
|
publist.sort_values(by="publisher_hash", inplace=True)
|
||||||
|
pub_csv_path = f"{working_dir}/Needs_Review/Review_Second/publishers.csv"
|
||||||
|
await asyncio.to_thread(publist.to_csv, pub_csv_path, index=False)
|
||||||
|
|
||||||
|
print("buildPathsandPublishers completed asynchronously.")
|
||||||
|
|
||||||
|
|
||||||
|
async def buildPreflights():
|
||||||
|
working_dir = await load_env("WORKING_DIR")
|
||||||
|
|
||||||
|
hash_path = f"{working_dir}/Approved/hashes_to_add.csv"
|
||||||
|
path1 = f"{working_dir}/Approved/primary_Paths.csv"
|
||||||
|
path2 = f"{working_dir}/Approved/secondary_Paths.csv"
|
||||||
|
publishers_path = f"{working_dir}/Approved/publishers.csv"
|
||||||
|
|
||||||
|
df1 = await asyncio.to_thread(pd.read_csv, path1) if os.path.exists(path1) else pd.DataFrame()
|
||||||
|
if df1.empty:
|
||||||
|
logger.warning(f"File not found or empty: {path1}")
|
||||||
|
|
||||||
|
df2 = await asyncio.to_thread(pd.read_csv, path2) if os.path.exists(path2) else pd.DataFrame()
|
||||||
|
if df2.empty:
|
||||||
|
logger.warning(f"File not found or empty: {path2}")
|
||||||
|
|
||||||
|
approved_hashes = await asyncio.to_thread(pd.read_csv, hash_path) if os.path.exists(hash_path) else pd.DataFrame()
|
||||||
|
if approved_hashes.empty:
|
||||||
|
logger.warning(f"File not found or empty: {hash_path}")
|
||||||
|
|
||||||
|
approved_publishers = await asyncio.to_thread(pd.read_csv, publishers_path) if os.path.exists(publishers_path) else pd.DataFrame()
|
||||||
|
if approved_publishers.empty:
|
||||||
|
logger.warning(f"File not found or empty: {publishers_path}")
|
||||||
|
|
||||||
|
approved_paths = pd.concat([df1, df2], ignore_index=True) if not (df1.empty and df2.empty) else pd.DataFrame()
|
||||||
|
|
||||||
|
dataframes = {
|
||||||
|
"approved_paths": approved_paths,
|
||||||
|
"approved_hashes": approved_hashes,
|
||||||
|
"approved_publishers": approved_publishers
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, df in dataframes.items():
|
||||||
|
logger.debug(f"DataFrame headers for {name}: {list(df.columns)}")
|
||||||
|
if name == "approved_paths":
|
||||||
|
df.sort_values(by="longestcfp", inplace=True)
|
||||||
|
elif name == "approved_hashes":
|
||||||
|
df.sort_values(by="filename_exec", inplace=True)
|
||||||
|
elif name == "approved_publishers":
|
||||||
|
df.sort_values(by="publisher_hash", inplace=True)
|
||||||
|
|
||||||
|
csv_path = f"{working_dir}/Preflight/{name}.csv"
|
||||||
|
html_path = f"{working_dir}/Preflight/HTML/{name}.html"
|
||||||
|
|
||||||
|
await asyncio.to_thread(df.to_csv, csv_path, index=False)
|
||||||
|
await asyncio.to_thread(formatHTML, df, html_path)
|
||||||
|
|
||||||
|
print("buildPreflights completed asynchronously.")
|
||||||
|
|
||||||
|
|
||||||
|
async def splitFilepathsGrouped(df, col="filename"):
|
||||||
|
path_task = asyncio.create_task(get_protected_value("PATH_EXCLUSION_CONST", int))
|
||||||
|
min_files_task = asyncio.create_task(get_protected_value("MIN_FILES_FOR_PATH", int))
|
||||||
|
|
||||||
|
path_exclusion_constant = await path_task
|
||||||
|
min_files_for_path = await min_files_task
|
||||||
|
|
||||||
def clean_split(path):
|
def clean_split(path):
|
||||||
if not isinstance(path, (str, bytes, os.PathLike)):
|
if not isinstance(path, (str, bytes, os.PathLike)):
|
||||||
return []
|
return []
|
||||||
parts = os.path.normpath(path).split(os.sep)
|
parts = os.path.normpath(path).split(os.sep)
|
||||||
parts = [p for p in parts if p] # Remove empty strings
|
return [p for p in parts if p]
|
||||||
return parts
|
|
||||||
|
|
||||||
# Diagnostic: log any non-string entries
|
|
||||||
non_string_entries = df[~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike)))]
|
non_string_entries = df[~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike)))]
|
||||||
if not non_string_entries.empty:
|
if not non_string_entries.empty:
|
||||||
print(f"[WARNING] Non-string entries found in column '{col}':")
|
logger.warning(f"Non-string entries found in column '{col}':")
|
||||||
print(non_string_entries)
|
logger.debug(non_string_entries)
|
||||||
|
|
||||||
df = df.copy()
|
df = df.copy()
|
||||||
split_paths = df[col].apply(clean_split)
|
split_paths = df[col].apply(clean_split)
|
||||||
|
|
||||||
# Filter out paths with fewer than `min_files_for_path` components
|
|
||||||
df = df[split_paths.apply(lambda parts: len(parts) >= min_files_for_path)].copy()
|
df = df[split_paths.apply(lambda parts: len(parts) >= min_files_for_path)].copy()
|
||||||
split_paths = split_paths[df.index]
|
split_paths = split_paths[df.index]
|
||||||
|
|
||||||
@@ -295,11 +304,7 @@ def splitFilepathsGrouped(df, col="filename"):
|
|||||||
|
|
||||||
for i, parts in enumerate(split_parts):
|
for i, parts in enumerate(split_parts):
|
||||||
filename = parts[-1]
|
filename = parts[-1]
|
||||||
middle = (
|
middle = os.sep.join(parts[len(common_prefix):-1]) if len(parts) > len(common_prefix) + 1 else ""
|
||||||
os.sep.join(parts[len(common_prefix):-1])
|
|
||||||
if len(parts) > len(common_prefix) + 1
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
row = group_df.iloc[i].copy()
|
row = group_df.iloc[i].copy()
|
||||||
row["longestcfp"] = prefix_str
|
row["longestcfp"] = prefix_str
|
||||||
row["middle"] = middle
|
row["middle"] = middle
|
||||||
@@ -309,19 +314,16 @@ def splitFilepathsGrouped(df, col="filename"):
|
|||||||
|
|
||||||
return pd.DataFrame(new_rows).drop(columns=["group_key"])
|
return pd.DataFrame(new_rows).drop(columns=["group_key"])
|
||||||
|
|
||||||
def calculatePath(approved_hashes, 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", "[]")
|
async def calculatePath(approved_hashes, split):
|
||||||
min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type = int)
|
dfs_by_policy = [group for _, group in approved_hashes.groupby("policy")] if split else [approved_hashes]
|
||||||
|
badpathparts = await asyncio.to_thread(load_env_json, "BAD_PATH_PARTS", "[]")
|
||||||
|
min_files_for_path = await asyncio.to_thread(get_protected_value, "MIN_FILES_FOR_PATH", int)
|
||||||
|
|
||||||
processed_dfs = []
|
processed_dfs = []
|
||||||
|
|
||||||
for df in dfs_by_policy:
|
for df in dfs_by_policy:
|
||||||
haslcp = splitFilepathsGrouped(df, "filename_exec")
|
haslcp = await splitFilepathsGrouped(df, "filename_exec")
|
||||||
haslcp = haslcp.drop_duplicates()
|
haslcp = haslcp.drop_duplicates()
|
||||||
|
|
||||||
forbidden = regulator(badpathparts, True)
|
forbidden = regulator(badpathparts, True)
|
||||||
@@ -329,18 +331,12 @@ def calculatePath(approved_hashes, split):
|
|||||||
|
|
||||||
logger.debug("Removing forbidden filepaths for path exceptions")
|
logger.debug("Removing forbidden filepaths for path exceptions")
|
||||||
print(colorText("Removing forbidden filepaths for path exceptions", "green"))
|
print(colorText("Removing forbidden filepaths for path exceptions", "green"))
|
||||||
|
|
||||||
lcp_not_forbidden = haslcp[~forbidden_lcfp].copy()
|
lcp_not_forbidden = haslcp[~forbidden_lcfp].copy()
|
||||||
|
|
||||||
lcp_not_forbidden_review = lcp_not_forbidden[
|
lcp_not_forbidden_review = lcp_not_forbidden[[
|
||||||
[
|
"policyname", "longestcfp", "middle", "filename_only", "file_extension", "sha256"
|
||||||
"policyname",
|
]]
|
||||||
"longestcfp",
|
|
||||||
"middle",
|
|
||||||
"filename_only",
|
|
||||||
"file_extension",
|
|
||||||
"sha256",
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
unique_sha_counts = (
|
unique_sha_counts = (
|
||||||
lcp_not_forbidden_review.groupby("longestcfp")["sha256"].nunique().reset_index()
|
lcp_not_forbidden_review.groupby("longestcfp")["sha256"].nunique().reset_index()
|
||||||
@@ -353,8 +349,8 @@ def calculatePath(approved_hashes, split):
|
|||||||
lcp_not_forbidden_review = lcp_not_forbidden_review[
|
lcp_not_forbidden_review = lcp_not_forbidden_review[
|
||||||
lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path
|
lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path
|
||||||
]
|
]
|
||||||
|
|
||||||
processed_dfs.append(lcp_not_forbidden_review)
|
processed_dfs.append(lcp_not_forbidden_review)
|
||||||
|
|
||||||
pathExclusions = pd.concat(processed_dfs, ignore_index=True)
|
pathExclusions = pd.concat(processed_dfs, ignore_index=True)
|
||||||
|
return pathExclusions
|
||||||
return pathExclusions
|
|
||||||
+14
-54
@@ -1,124 +1,84 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import dotenv
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from flows.prepPolicy import selectPolicies
|
from flows.prepPolicy import selectPolicies
|
||||||
from services.API import AirlockAPIWrapper
|
from services.API import AirlockAPIWrapper
|
||||||
from services.policyhandler import getPolicyInfo
|
from services.PolicyHandler import getPolicyInfo
|
||||||
from utils.selector import Selector
|
from utils.Selector import Selector
|
||||||
from utils.utils import colorText, load_env
|
from utils.utils import colorText, load_env
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
async def findQuietAgents(api: AirlockAPIWrapper):
|
||||||
|
|
||||||
|
|
||||||
def findQuietAgents(api: AirlockAPIWrapper):
|
|
||||||
working_dir = load_env("WORKING_DIR")
|
working_dir = load_env("WORKING_DIR")
|
||||||
# Get policy selection and agent list
|
selected_policy = await selectPolicies(api, False)
|
||||||
selected_policy = selectPolicies(api, False)
|
|
||||||
if selected_policy:
|
|
||||||
agents = api.agents_find_by_group(selected_policy[0].groupid)
|
|
||||||
|
|
||||||
# Prompt user for history range
|
if selected_policy:
|
||||||
history_days = Selector.select_value(
|
agents = await api.agents_find_by_group(selected_policy[0].groupid)
|
||||||
|
|
||||||
|
history_days = await Selector.select_value(
|
||||||
prompt="Enter how many days of history to pull (1–150): ",
|
prompt="Enter how many days of history to pull (1–150): ",
|
||||||
value_type=int,
|
value_type=int,
|
||||||
valid_range=(1, 150),
|
valid_range=(1, 150),
|
||||||
)
|
)
|
||||||
required_quiet = Selector.select_value(
|
required_quiet = await Selector.select_value(
|
||||||
prompt="Enter how many days without an untrusted execution before these are considered ready for enforcement? (1–365): ",
|
prompt="Enter how many days without an untrusted execution before these are considered ready for enforcement? (1–150): ",
|
||||||
value_type=int,
|
value_type=int,
|
||||||
valid_range=(1, 150),
|
valid_range=(1, 150),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get execution history as a DataFrame
|
policy_exec_history = await getPolicyInfo(
|
||||||
policy_exec_history = getPolicyInfo(
|
|
||||||
api, selected_policy[0], [1, 2, 6, 7], history_days
|
api, selected_policy[0], [1, 2, 6, 7], history_days
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if policy_exec_history.empty:
|
if policy_exec_history.empty:
|
||||||
logging.info("No execution history found for the selected policy and time range.")
|
logging.info("No execution history found for the selected policy and time range.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
# Convert 'datetime' column to timezone-aware datetime objects
|
|
||||||
policy_exec_history["datetime"] = pd.to_datetime(
|
policy_exec_history["datetime"] = pd.to_datetime(
|
||||||
policy_exec_history["datetime"], format="%Y-%m-%dT%H:%M:%SZ", utc=True
|
policy_exec_history["datetime"], format="%Y-%m-%dT%H:%M:%SZ", utc=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get current UTC time
|
|
||||||
now = datetime.datetime.now(datetime.timezone.utc)
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
# Calculate days ago
|
|
||||||
policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply(
|
policy_exec_history["days_ago"] = policy_exec_history["datetime"].apply(
|
||||||
lambda dt: (now - dt).days
|
lambda dt: (now - dt).days
|
||||||
)
|
)
|
||||||
|
|
||||||
# Count total executions per hostname
|
|
||||||
hostname_counts = policy_exec_history["hostname"].value_counts()
|
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)
|
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(
|
most_recent_exec = policy_exec_history.sort_values(by="days_ago").drop_duplicates(
|
||||||
subset="hostname", keep="first"
|
subset="hostname", keep="first"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Map most recent execution age to agents
|
|
||||||
agents["days_since"] = agents["hostname"].map(
|
agents["days_since"] = agents["hostname"].map(
|
||||||
most_recent_exec.set_index("hostname")["days_ago"]
|
most_recent_exec.set_index("hostname")["days_ago"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for enforcement readiness
|
|
||||||
agents["required_quiet"] = required_quiet
|
agents["required_quiet"] = required_quiet
|
||||||
agents["enforce_ready"] = agents["days_since"].apply(
|
agents["enforce_ready"] = agents["days_since"].apply(
|
||||||
lambda x: True if pd.isna(x) or x > required_quiet else False
|
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])
|
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"
|
filename = f"{working_dir}\\{selected_policy[0].name}_agents_last_{history_days}_days.csv"
|
||||||
logging.debug(f"Saving CSV to {filename}")
|
logging.debug(f"Saving CSV to {filename}")
|
||||||
print(colorText(f"Saving CSV to {filename}", "green"))
|
print(colorText(f"Saving CSV to {filename}", "green"))
|
||||||
agents.to_csv(filename, index=False)
|
agents.to_csv(filename, index=False)
|
||||||
|
|
||||||
# Summary statistics
|
|
||||||
total_agents = len(agents)
|
total_agents = len(agents)
|
||||||
ready_agents = agents["enforce_ready"].sum()
|
ready_agents = agents["enforce_ready"].sum()
|
||||||
not_ready_agents = total_agents - ready_agents
|
not_ready_agents = total_agents - ready_agents
|
||||||
ready_percentage = (ready_agents / total_agents) * 100
|
ready_percentage = (ready_agents / total_agents) * 100
|
||||||
|
|
||||||
# Print results
|
|
||||||
|
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"Total agents: {total_agents}\n"
|
f"Total agents: {total_agents}\n"
|
||||||
f"Agents marked as 'enforce_ready': {ready_agents}\n"
|
f"Agents marked as 'enforce_ready': {ready_agents}\n"
|
||||||
f"Agents not ready: {not_ready_agents}\n"
|
f"Agents not ready: {not_ready_agents}\n"
|
||||||
f"Percentage ready for enforcement: {ready_percentage:.2f}%"
|
f"Percentage ready for enforcement: {ready_percentage:.2f}%"
|
||||||
)
|
)
|
||||||
logger.debug(message)
|
logger.info(message)
|
||||||
colorText(message,"green")
|
colorText(message, "green")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
MAJOR WORK IN PROGRESS
|
||||||
|
|
||||||
|
from flows.prepPolicy import (
|
||||||
|
buildPathsandPublishers,
|
||||||
|
buildPreflights,
|
||||||
|
selectAllowlists,
|
||||||
|
selectPolicies,
|
||||||
|
sortHashes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#Pull History for last 24 hours, Make a list of unique policy names that had exectutions
|
||||||
|
|
||||||
|
sortHashes(api,selected_policies, type=[1, 2, 6, 7], history_days=150)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
buildPathsandPublishers(False)
|
||||||
|
|
||||||
|
buildPreflights()
|
||||||
|
else:
|
||||||
|
print("File not found. Please make sure it's saved correctly and try again.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pathexclusions = pd.read_csv(f"{working_dir}\\Preflight\\approved_paths.csv")
|
||||||
|
hashes = pd.read_csv(f"{working_dir}\\Preflight\\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)
|
||||||
|
]
|
||||||
|
|
||||||
|
print(processed_paths)
|
||||||
|
print(colorText("These publishers would added", "yellow"))
|
||||||
|
|
||||||
|
if os.path.exists(f"{working_dir}\\Preflight\\approved_publishers.csv"):
|
||||||
|
publishers = pd.read_csv(f"{working_dir}\\Preflight\\approved_hashes.csv")
|
||||||
|
if publishers.empty:
|
||||||
|
print(colorText("The publishers list is empty.", "red"))
|
||||||
|
else:
|
||||||
|
processed_publishers = (
|
||||||
|
publishers[publishers["publisher_hash"] != "Not Signed"]
|
||||||
|
["publisher_hash"]
|
||||||
|
.drop_duplicates()
|
||||||
|
.tolist()
|
||||||
|
)
|
||||||
|
print(processed_publishers)
|
||||||
|
|
||||||
|
print(colorText("These hashes would be added to:", "yellow"))
|
||||||
|
print(destination_allowlist)
|
||||||
|
|
||||||
|
processed_hashes = hashes["sha256"].unique().tolist()
|
||||||
|
print(processed_hashes)
|
||||||
|
|
||||||
|
if processed_paths and processed_hashes:
|
||||||
|
tested = True
|
||||||
|
else:
|
||||||
|
# Log which condition(s) failed
|
||||||
|
missing_items = []
|
||||||
|
if not os.path.exists(f"{working_dir}\\Preflight\\approved_paths.csv"):
|
||||||
|
missing_items.append("approved_paths.csv not found")
|
||||||
|
if not os.path.exists(f"{working_dir}\\Preflight\\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":
|
||||||
|
areYouSure()
|
||||||
|
confirmation = get_sanitized_input("Type 'I AGREE' to continue: ")
|
||||||
|
if (
|
||||||
|
tested
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
logger.error("Confirmation block failed. Reasons:")
|
||||||
|
if not tested:
|
||||||
|
logger.error(" - Preflight checks were not completed successfully (`tested` is False).")
|
||||||
|
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())
|
||||||
|
|
||||||
|
"""
|
||||||
+65
-217
@@ -1,18 +1,4 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
import asyncio
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -22,46 +8,20 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import dotenv
|
import aiofiles
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from services.policyhandler import pullPolicyExechistories
|
from services.PolicyHandler import pullPolicyExechistories
|
||||||
from utils.configmanager import get_protected_value, load_env_json
|
from utils.configmanager import get_protected_value, load_env_json
|
||||||
from utils.utils import colorText, regulator
|
from utils.utils import regulator
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
|
||||||
|
|
||||||
|
|
||||||
class Hash:
|
class Hash:
|
||||||
"""
|
def __init__(self, sha256, applications=None, baselines=None, blocklists=None, createtime=None,
|
||||||
Hash model representing Hash data
|
datetime=None, description=None, filename=None, filepath=None, filesize=None,
|
||||||
"""
|
md5=None, modtime=None, origname=None, productname=None, productversion=None,
|
||||||
|
publisher=None, reputation=None, sha128=None, sha384=None, sha512=None):
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
sha256,
|
|
||||||
applications=None,
|
|
||||||
baselines=None,
|
|
||||||
blocklists=None,
|
|
||||||
createtime=None,
|
|
||||||
datetime=None,
|
|
||||||
description=None,
|
|
||||||
filename=None,
|
|
||||||
filepath=None,
|
|
||||||
filesize=None,
|
|
||||||
md5=None,
|
|
||||||
modtime=None,
|
|
||||||
origname=None,
|
|
||||||
productname=None,
|
|
||||||
productversion=None,
|
|
||||||
publisher=None,
|
|
||||||
reputation=None,
|
|
||||||
sha128=None,
|
|
||||||
sha384=None,
|
|
||||||
sha512=None,
|
|
||||||
):
|
|
||||||
self.sha256 = sha256
|
self.sha256 = sha256
|
||||||
self.applications = applications
|
self.applications = applications
|
||||||
self.baselines = baselines
|
self.baselines = baselines
|
||||||
@@ -88,35 +48,23 @@ class Hash:
|
|||||||
return f"<Hash({attrs})>"
|
return f"<Hash({attrs})>"
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
if isinstance(other, Hash):
|
return isinstance(other, Hash) and self.sha256 == other.sha256
|
||||||
return self.sha256 == other.sha256
|
|
||||||
return False
|
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return hash(self.sha256)
|
return hash(self.sha256)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Returns a dictionary representation of the hash."""
|
|
||||||
return self.__dict__
|
return self.__dict__
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def safe_int(value, default=0):
|
def safe_int(value, default=0):
|
||||||
"""Safely convert a value to int, returning default on failure."""
|
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def deduplicate(cls, hash_list):
|
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()
|
seen = set()
|
||||||
deduped = []
|
deduped = []
|
||||||
for h in hash_list:
|
for h in hash_list:
|
||||||
@@ -126,14 +74,11 @@ class Hash:
|
|||||||
return deduped
|
return deduped
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def categorize_hashes(cls, hashes):
|
async def categorize_hashes(cls, hashes):
|
||||||
threat_tolerance = get_protected_value("VT_THREAT_TOLERANCE", cast_type=int)
|
threat_tolerance = await get_protected_value("VT_THREAT_TOLERANCE", cast_type=int)
|
||||||
bad_publishers_pattern = regulator(load_env_json("BAD_PUBLISHERS", "[]"))
|
bad_publishers_pattern = regulator(await load_env_json("BAD_PUBLISHERS", "[]"))
|
||||||
pups_pattern = regulator(load_env_json("PUPS", "[]"))
|
pups_pattern = regulator(await load_env_json("PUPS", "[]"))
|
||||||
|
needs_review, approved, unapproved = [], [], []
|
||||||
needs_review = []
|
|
||||||
approved = []
|
|
||||||
unapproved = []
|
|
||||||
|
|
||||||
for hash_obj in hashes:
|
for hash_obj in hashes:
|
||||||
publisher = hash_obj.publisher or ""
|
publisher = hash_obj.publisher or ""
|
||||||
@@ -141,57 +86,32 @@ class Hash:
|
|||||||
reputation = hash_obj.reputation if isinstance(hash_obj.reputation, dict) else {}
|
reputation = hash_obj.reputation if isinstance(hash_obj.reputation, dict) else {}
|
||||||
scannermatch = reputation.get("scannermatch")
|
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):
|
if re.search(bad_publishers_pattern, publisher, re.IGNORECASE):
|
||||||
logger.debug("Unapproved: Publisher matches bad publisher pattern.")
|
|
||||||
unapproved.append(hash_obj)
|
unapproved.append(hash_obj)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if re.search(pups_pattern, description, re.IGNORECASE):
|
if re.search(pups_pattern, description, re.IGNORECASE):
|
||||||
logger.debug("Unapproved: Description matches PUP pattern.")
|
|
||||||
unapproved.append(hash_obj)
|
unapproved.append(hash_obj)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2. Approved: signed
|
|
||||||
if publisher != "Not Signed":
|
if publisher != "Not Signed":
|
||||||
logger.debug("Approved: File is signed and not flagged.")
|
|
||||||
approved.append(hash_obj)
|
approved.append(hash_obj)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 3. Approved or Unapproved based on threat level
|
|
||||||
try:
|
try:
|
||||||
score = int(scannermatch) # pyright: ignore[reportArgumentType]
|
score = int(scannermatch) # pyright: ignore[reportArgumentType]
|
||||||
logger.debug(f"Parsed scannermatch score: {score}")
|
if score > threat_tolerance: # type: ignore
|
||||||
if score > threat_tolerance: # pyright: ignore[reportOperatorIssue]
|
|
||||||
logger.debug("Unapproved: Unsigned file with high threat score.")
|
|
||||||
unapproved.append(hash_obj)
|
unapproved.append(hash_obj)
|
||||||
else:
|
else:
|
||||||
logger.debug("Approved: Unsigned file with low threat score.")
|
|
||||||
approved.append(hash_obj)
|
approved.append(hash_obj)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
logger.debug("Needs Review: Scannermatch score is missing or invalid.")
|
|
||||||
needs_review.append(hash_obj)
|
needs_review.append(hash_obj)
|
||||||
|
|
||||||
|
|
||||||
logger.debug(f"Final counts — Needs Review: {len(needs_review)}, Approved: {len(approved)}, Unapproved: {len(unapproved)}")
|
|
||||||
return needs_review, approved, unapproved
|
return needs_review, approved, unapproved
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def export_to_csv(cls, hash_list, directory_path):
|
async 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"
|
filename = "hashes_export.csv"
|
||||||
frame = inspect.currentframe()
|
frame = inspect.currentframe()
|
||||||
if frame is not None and frame.f_back is not None:
|
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 frame.f_back.f_locals.items():
|
||||||
for var_name, var_val in callers_local_vars:
|
|
||||||
if var_val is hash_list:
|
if var_val is hash_list:
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"{var_name}_{timestamp}.csv"
|
filename = f"{var_name}_{timestamp}.csv"
|
||||||
@@ -203,52 +123,16 @@ class Hash:
|
|||||||
os.makedirs(directory_path, exist_ok=True)
|
os.makedirs(directory_path, exist_ok=True)
|
||||||
file_path = os.path.join(directory_path, filename)
|
file_path = os.path.join(directory_path, filename)
|
||||||
|
|
||||||
df = pd.DataFrame([h.to_dict() for h in hash_list])
|
df = await asyncio.to_thread(pd.DataFrame, [h.to_dict() for h in hash_list])
|
||||||
df.to_csv(file_path, index=False)
|
await asyncio.to_thread(df.to_csv, file_path, index=False)
|
||||||
|
|
||||||
logger.info(f"CSV file saved to: {file_path}")
|
async with aiofiles.open(file_path, mode='r') as f:
|
||||||
|
preview = await f.read()
|
||||||
|
print(f"CSV file saved to: {file_path}\nPreview:\n{preview[:500]}")
|
||||||
"""
|
|
||||||
#Example - Convert Dataframe returned by hash query into hash objects
|
|
||||||
hash_objects = []
|
|
||||||
for _, row in df.iterrows():
|
|
||||||
try:
|
|
||||||
parsed_data = ast.literal_eval(row['data'])
|
|
||||||
hash_obj = Hash(sha256=row['sha256'], **parsed_data)
|
|
||||||
hash_objects.append(hash_obj)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error parsing row: {e}")
|
|
||||||
|
|
||||||
# Display the created Hash objects
|
|
||||||
for obj in hash_objects:
|
|
||||||
print(obj)
|
|
||||||
|
|
||||||
|
|
||||||
# Categorize hashes
|
|
||||||
needs_review, approved, unapproved = Hash.categorize_hashes(
|
|
||||||
hashes=hash_objects,
|
|
||||||
threat_tolerance=3,
|
|
||||||
untrusted_pattern=untrusted_pattern,
|
|
||||||
pups_pattern=pups_pattern
|
|
||||||
)
|
|
||||||
|
|
||||||
# Deduplicate
|
|
||||||
deduped_hashes = Hash.deduplicate(hash_list)
|
|
||||||
|
|
||||||
# Specify the directory where you want to save the CSV
|
|
||||||
output_directory = "C:/Users/Brandon/Documents/HashExports"
|
|
||||||
|
|
||||||
# Call the export method
|
|
||||||
Hash.export_to_csv(hashes_for_export, output_directory)
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExecutionHistoryRecord:
|
class ExecutionHistoryRecord:
|
||||||
# Mandatory fields
|
|
||||||
username: str
|
username: str
|
||||||
hostname: str
|
hostname: str
|
||||||
netdomain: str
|
netdomain: str
|
||||||
@@ -260,8 +144,6 @@ class ExecutionHistoryRecord:
|
|||||||
publisher: str
|
publisher: str
|
||||||
sha256: str
|
sha256: str
|
||||||
datetime: str
|
datetime: str
|
||||||
|
|
||||||
# Optional fields
|
|
||||||
type: Optional[int] = None
|
type: Optional[int] = None
|
||||||
pprocess: Optional[str] = None
|
pprocess: Optional[str] = None
|
||||||
gprocess: Optional[str] = None
|
gprocess: Optional[str] = None
|
||||||
@@ -273,62 +155,69 @@ class ExecutionHistoryRecord:
|
|||||||
localip: Optional[str] = None
|
localip: Optional[str] = None
|
||||||
extid: Optional[str] = None
|
extid: Optional[str] = None
|
||||||
extname: Optional[str] = None
|
extname: Optional[str] = None
|
||||||
exttype: Optional[int] = None # 1 = CRX Chromium Extension, 2 = XPI Firefox Extension
|
exttype: Optional[int] = None
|
||||||
extbrowser: Optional[int] = None # 1 = Chrome, 2 = Firefox, 3 = Edge
|
extbrowser: Optional[int] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def from_policies(cls, api, selected_policies, type_: list, history_days: int) -> List["ExecutionHistoryRecord"]:
|
||||||
|
async def fetch_and_parse(policy):
|
||||||
|
execs = await pullPolicyExechistories(api, policy, type_, history_days, True)
|
||||||
|
if not execs:
|
||||||
|
return []
|
||||||
|
data = json.loads(execs)
|
||||||
|
exechistories = data.get("response", {}).get("exechistories", [])
|
||||||
|
if not exechistories:
|
||||||
|
return []
|
||||||
|
df = await asyncio.to_thread(pd.DataFrame, exechistories)
|
||||||
|
df = await asyncio.to_thread(df.drop_duplicates, subset=["sha256", "filename", "hostname"])
|
||||||
|
df = await asyncio.to_thread(df.sort_values, by=["sha256", "filename"])
|
||||||
|
return [cls.from_dict(row.to_dict()) for _, row in df.iterrows()]
|
||||||
|
|
||||||
|
tasks = [fetch_and_parse(policy) for policy in selected_policies]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return [record for sublist in results for record in sublist]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def enrich_with_hashes_and_export(
|
async def enrich_with_hashes(executions: list, hashes: list):
|
||||||
executions: list, hashes: list, directory_path: str, label: str = "enriched"
|
|
||||||
):
|
|
||||||
exec_df = pd.DataFrame([e.__dict__ for e in executions])
|
|
||||||
hash_df = pd.DataFrame([h.to_dict() for h in hashes])
|
|
||||||
|
|
||||||
logger.debug(f"Execution DataFrame columns: {exec_df.columns}")
|
exec_task = asyncio.to_thread(pd.DataFrame, [e.__dict__ for e in executions])
|
||||||
logger.debug(f"Hash DataFrame columns: {hash_df.columns}")
|
hash_task = asyncio.to_thread(pd.DataFrame, [h.to_dict() for h in hashes])
|
||||||
|
|
||||||
|
exec_df, hash_df = await asyncio.gather(exec_task, hash_task)
|
||||||
|
|
||||||
if hash_df.empty:
|
if hash_df.empty:
|
||||||
logger.warning(f"hash_df is empty for label: {label}. Skipping merge.")
|
|
||||||
merged_df = exec_df.copy()
|
merged_df = exec_df.copy()
|
||||||
|
logger.debug("Hash dataframe appears empty")
|
||||||
else:
|
else:
|
||||||
merged_df = pd.merge(
|
merged_df = await asyncio.to_thread(
|
||||||
|
pd.merge,
|
||||||
exec_df,
|
exec_df,
|
||||||
hash_df,
|
hash_df,
|
||||||
on="sha256",
|
on="sha256",
|
||||||
how="left", # Preserve all executions, enrich where possible
|
how="left",
|
||||||
suffixes=("_exec", "_hash")
|
suffixes=("_exec", "_hash")
|
||||||
)
|
)
|
||||||
merged_df.sort_values(by="filename_exec", inplace=True)
|
|
||||||
logger.info(f"Merged {len(merged_df)} rows. Non-null hash matches: {merged_df['sha256'].notna().sum()}")
|
|
||||||
|
|
||||||
filename = f"{label}_executions.csv"
|
# Log available columns for debugging
|
||||||
os.makedirs(directory_path, exist_ok=True)
|
logger.debug(f"Merged DataFrame columns: {merged_df.columns.tolist()}")
|
||||||
file_path = os.path.join(directory_path, filename)
|
|
||||||
merged_df.to_csv(file_path, index=False)
|
|
||||||
|
|
||||||
logger.info(f"CSV file saved to: {file_path}")
|
|
||||||
|
|
||||||
|
# Only sort if the column exists
|
||||||
|
if "filename_exec" in merged_df.columns:
|
||||||
|
merged_df = await asyncio.to_thread(merged_df.sort_values, by="filename_exec")
|
||||||
|
else:
|
||||||
|
merged_df = await asyncio.to_thread(merged_df.sort_values, by="filename")
|
||||||
|
|
||||||
|
return merged_df
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict):
|
def from_dict(cls, data: dict):
|
||||||
mandatory_fields = [
|
mandatory_fields = [
|
||||||
"username",
|
"username", "hostname", "netdomain", "filename", "ppolicy",
|
||||||
"hostname",
|
"policyname", "policyver", "commandline", "publisher", "sha256", "datetime"
|
||||||
"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
|
|
||||||
]
|
]
|
||||||
|
missing_fields = [field for field in mandatory_fields if field not in data or data[field] is None]
|
||||||
if missing_fields:
|
if missing_fields:
|
||||||
raise ValueError(f"Missing mandatory fields: {missing_fields}")
|
raise ValueError(f"Missing mandatory fields: {missing_fields}")
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
username=data["username"],
|
username=data["username"],
|
||||||
hostname=data["hostname"],
|
hostname=data["hostname"],
|
||||||
@@ -354,45 +243,4 @@ class ExecutionHistoryRecord:
|
|||||||
extname=data.get("extname"),
|
extname=data.get("extname"),
|
||||||
exttype=data.get("exttype"),
|
exttype=data.get("exttype"),
|
||||||
extbrowser=data.get("extbrowser"),
|
extbrowser=data.get("extbrowser"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_policies(
|
|
||||||
cls, api, selected_policies, type_: list, history_days: int
|
|
||||||
) -> List["ExecutionHistoryRecord"]:
|
|
||||||
executions = []
|
|
||||||
for policy in selected_policies:
|
|
||||||
execs = pullPolicyExechistories(
|
|
||||||
api, policy, type_, history_days, True
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
attrs = ", ".join(f"{key}={repr(value)}" for key, value in self.__dict__.items())
|
|
||||||
return f"<Execution({attrs})>"
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
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")
|
|
||||||
"""
|
|
||||||
+89
-188
@@ -1,274 +1,175 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import requests
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class AirlockAPIWrapper:
|
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):
|
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.base_url = base_url.rstrip("/")
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.headers = {"X-APIKey": self.api_key}
|
self.headers = {"X-APIKey": self.api_key}
|
||||||
|
|
||||||
def _post(self, endpoint: str, payload: Optional[dict] = None) -> dict:
|
async 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}"
|
url = f"{self.base_url}{endpoint}"
|
||||||
data = json.dumps(payload or {})
|
data = json.dumps(payload or {})
|
||||||
try:
|
timeout = httpx.Timeout(300.0)
|
||||||
logger.debug(f"POST Request to {url} with payload: {payload}")
|
async with httpx.AsyncClient(verify=False, timeout=timeout) as client:
|
||||||
response = requests.post(url, headers=self.headers, data=data, verify=False)
|
try:
|
||||||
response.raise_for_status()
|
logger.debug(f"POST Request to {url} with payload: {payload}")
|
||||||
logger.debug(f"Response received from {url}")
|
response = await client.post(url, headers=self.headers, data=data) # pyright: ignore[reportArgumentType]
|
||||||
return response.json()
|
response.raise_for_status()
|
||||||
except requests.exceptions.RequestException as e:
|
logger.debug(f"Response received from {url}")
|
||||||
logger.error(f"API request failed: {e}")
|
return response.json()
|
||||||
raise
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"API request failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
# Allowlist Management
|
# Allowlist Management
|
||||||
def allowlist_find_all(self) -> pd.DataFrame:
|
async def allowlist_find_all(self) -> pd.DataFrame:
|
||||||
"""
|
result = await self._post("/v1/application", {})
|
||||||
Retrieve all applications in the allowlist.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
- pd.DataFrame: DataFrame containing allowlisted applications.
|
|
||||||
"""
|
|
||||||
result = self._post("/v1/application", {})
|
|
||||||
return pd.DataFrame(result["response"]["applications"])
|
return pd.DataFrame(result["response"]["applications"])
|
||||||
|
|
||||||
# Agent Management
|
# Agent Management
|
||||||
def agent_find_all(self) -> pd.DataFrame:
|
async def agent_find_all(self) -> pd.DataFrame:
|
||||||
"""Retrieve all agents."""
|
result = await self._post("/v1/agent/find", {})
|
||||||
result = self._post("/v1/agent/find", {})
|
|
||||||
return pd.DataFrame(result["response"]["agents"])
|
return pd.DataFrame(result["response"]["agents"])
|
||||||
|
|
||||||
def agent_find_by_hostname(self, hostname: str) -> pd.DataFrame:
|
async def agent_find_by_hostname(self, hostname: str) -> pd.DataFrame:
|
||||||
"""Find agents by hostname."""
|
|
||||||
payload = {"hostname": hostname}
|
payload = {"hostname": hostname}
|
||||||
result = self._post("/v1/agent/find", payload)
|
result = await self._post("/v1/agent/find", payload)
|
||||||
return pd.DataFrame(result["response"]["agents"])
|
return pd.DataFrame(result["response"]["agents"])
|
||||||
|
|
||||||
def agent_find_by_id(self, agentid: str) -> pd.DataFrame:
|
async def agent_find_by_id(self, agentid: str) -> pd.DataFrame:
|
||||||
"""Find agents by agent ID."""
|
|
||||||
payload = {"agentid": agentid}
|
payload = {"agentid": agentid}
|
||||||
result = self._post("/v1/agent/find", payload)
|
result = await self._post("/v1/agent/find", payload)
|
||||||
return pd.DataFrame(result["response"]["agents"])
|
return pd.DataFrame(result["response"]["agents"])
|
||||||
|
|
||||||
def agent_find_by_status(self, status: int) -> pd.DataFrame:
|
async def agent_find_by_status(self, status: int) -> pd.DataFrame:
|
||||||
"""Find agents by status (0 = Offline, 1 = Online, 3 = Safemode)."""
|
|
||||||
payload = {"status": status}
|
payload = {"status": status}
|
||||||
result = self._post("/v1/agent/find", payload)
|
result = await self._post("/v1/agent/find", payload)
|
||||||
return pd.DataFrame(result["response"]["agents"])
|
return pd.DataFrame(result["response"]["agents"])
|
||||||
|
|
||||||
def agent_find_by_username(self, username: str) -> pd.DataFrame:
|
async def agent_find_by_username(self, username: str) -> pd.DataFrame:
|
||||||
"""Find agents by username."""
|
|
||||||
payload = {"username": username}
|
payload = {"username": username}
|
||||||
result = self._post("/v1/agent/find", payload)
|
result = await self._post("/v1/agent/find", payload)
|
||||||
return pd.DataFrame(result["response"]["agents"])
|
return pd.DataFrame(result["response"]["agents"])
|
||||||
|
|
||||||
def agent_move(self, agentid: str, groupid: str) -> dict:
|
async def agent_move(self, agentid: str, groupid: str) -> dict:
|
||||||
"""Move an agent to a different group."""
|
|
||||||
payload = {"agentid": agentid, "groupid": groupid}
|
payload = {"agentid": agentid, "groupid": groupid}
|
||||||
return self._post("/v1/agent/move", payload)
|
return await self._post("/v1/agent/move", payload)
|
||||||
|
|
||||||
def agents_find_by_group(self, groupid: str) -> pd.DataFrame:
|
async def agents_find_by_group(self, groupid: str) -> pd.DataFrame:
|
||||||
"""Find agents by group ID."""
|
|
||||||
payload = {"groupid": groupid}
|
payload = {"groupid": groupid}
|
||||||
result = self._post("/v1/agent/find", payload)
|
result = await self._post("/v1/agent/find", payload)
|
||||||
return pd.DataFrame(result["response"]["agents"])
|
return pd.DataFrame(result["response"]["agents"])
|
||||||
|
|
||||||
# Hash Management
|
# Hash Management
|
||||||
def hash_add_to_allowlist(self, applicationid: str, hashes: List[str]) -> dict:
|
async 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}
|
payload = {"applicationid": applicationid, "hashes": hashes}
|
||||||
return self._post("/v1/hash/application/add", payload)
|
return await self._post("/v1/hash/application/add", payload)
|
||||||
|
|
||||||
def hash_query(self, hashes: List[str]) -> pd.DataFrame:
|
async def hash_query(self, hashes: List[str]) -> pd.DataFrame:
|
||||||
"""Query information about specific hashes."""
|
|
||||||
payload = {"hashes": hashes}
|
payload = {"hashes": hashes}
|
||||||
result = self._post("/v1/hash/query", payload)
|
result = await self._post("/v1/hash/query", payload)
|
||||||
return pd.DataFrame(result["response"]["results"])
|
return pd.DataFrame(result["response"]["results"])
|
||||||
|
|
||||||
# OTP Management
|
# OTP Management
|
||||||
def otp_find_active(self) -> pd.DataFrame:
|
async def otp_find_active(self) -> pd.DataFrame:
|
||||||
"""Find active OTPs."""
|
|
||||||
payload = {"status": "1"}
|
payload = {"status": "1"}
|
||||||
result = self._post("/v1/otp/usage", payload)
|
result = await self._post("/v1/otp/usage", payload)
|
||||||
return pd.DataFrame(result["response"]["otpusage"])
|
return pd.DataFrame(result["response"]["otpusage"])
|
||||||
|
|
||||||
def otp_find_awaiting(self) -> pd.DataFrame:
|
async def otp_find_awaiting(self) -> pd.DataFrame:
|
||||||
"""Find OTPs that are awaiting activation."""
|
|
||||||
payload = {"status": "0"}
|
payload = {"status": "0"}
|
||||||
result = self._post("/v1/otp/usage", payload)
|
result = await self._post("/v1/otp/usage", payload)
|
||||||
return pd.DataFrame(result["response"]["otpusage"])
|
return pd.DataFrame(result["response"]["otpusage"])
|
||||||
|
|
||||||
def otp_find_by_agent(self, agentid) -> pd.DataFrame:
|
async def otp_find_by_agent(self, agentid) -> pd.DataFrame:
|
||||||
"""Find OTP by agent."""
|
|
||||||
payload = {"agentid": agentid}
|
payload = {"agentid": agentid}
|
||||||
result = self._post("/v1/otp/usage", payload)
|
result = await self._post("/v1/otp/usage", payload)
|
||||||
return pd.DataFrame(result["response"]["otpusage"])
|
return pd.DataFrame(result["response"]["otpusage"])
|
||||||
|
|
||||||
def otp_generate(self, agentid: str, duration: int, purpose: str) -> str:
|
async def otp_generate(self, agentid: str, duration: int, purpose: str) -> str:
|
||||||
"""Generate a new OTP for an agent."""
|
|
||||||
payload = {
|
payload = {
|
||||||
"duration": str(duration),
|
"duration": str(duration),
|
||||||
"agentid": str(agentid),
|
"agentid": str(agentid),
|
||||||
"purpose": purpose,
|
"purpose": purpose,
|
||||||
}
|
}
|
||||||
result = self._post("/v1/otp/retrieve", payload)
|
result = await self._post("/v1/otp/retrieve", payload)
|
||||||
return result["response"]["otpcode"]
|
return result["response"]["otpcode"]
|
||||||
|
|
||||||
def otp_get_activities(self, otpid: str) -> pd.DataFrame:
|
async def otp_get_activities(self, otpid: str) -> pd.DataFrame:
|
||||||
"""Retrieve activities associated with a specific OTP."""
|
|
||||||
payload = {"otpid": otpid}
|
payload = {"otpid": otpid}
|
||||||
result = self._post("/v1/otp/activities", payload)
|
result = await self._post("/v1/otp/activities", payload)
|
||||||
return pd.DataFrame(result["response"]["otpactivities"])
|
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)
|
|
||||||
|
|
||||||
|
async def otp_revoke(self, otpid: str) -> dict:
|
||||||
|
payload = {"otpid": otpid}
|
||||||
|
return await self._post("/v1/otp/revoke", payload)
|
||||||
|
|
||||||
|
async def otp_validate(self, otpcode: str) -> dict:
|
||||||
|
payload = {"otpcode": otpcode}
|
||||||
|
return await self._post("/v1/otp/validate", payload)
|
||||||
|
|
||||||
# Policy Management
|
# Policy Management
|
||||||
def policy_add_path_exclusions(self, groupid: str, paths: List[str]) -> dict:
|
async def policy_add_path_exclusions(self, groupid: str, paths: List[str]) -> dict:
|
||||||
"""Add path exclusions to a policy group."""
|
|
||||||
payload = {"groupid": groupid, "path": paths}
|
payload = {"groupid": groupid, "path": paths}
|
||||||
return self._post("/v1/group/path/add", payload)
|
return await self._post("/v1/group/path/add", payload)
|
||||||
|
|
||||||
def policy_add_publishers(self, groupid: str, publishers: List[str]) -> dict:
|
async def policy_add_publishers(self, groupid: str, publishers: List[str]) -> dict:
|
||||||
"""Add publishers to a policy group."""
|
|
||||||
payload = {"groupid": groupid, "publisher": publishers}
|
payload = {"groupid": groupid, "publisher": publishers}
|
||||||
return self._post("/v1/group/publisher/add", payload)
|
return await self._post("/v1/group/publisher/add", payload)
|
||||||
|
|
||||||
def policy_clone(self, source_groupid: str, target_groupid: str) -> dict:
|
async 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}
|
payload = {"groupid": source_groupid, "targetgroupid": target_groupid}
|
||||||
return self._post("/v1/group/assign", payload)
|
return await self._post("/v1/group/assign", payload)
|
||||||
|
|
||||||
def policy_find_all(self) -> pd.DataFrame:
|
async def policy_find_all(self) -> pd.DataFrame:
|
||||||
"""Retrieve all policy groups."""
|
result = await self._post("/v1/group")
|
||||||
result = self._post("/v1/group")
|
|
||||||
return pd.DataFrame(result["response"]["groups"])
|
return pd.DataFrame(result["response"]["groups"])
|
||||||
|
|
||||||
def policy_list_agents(self, groupid: str) -> pd.DataFrame:
|
async def policy_list_agents(self, groupid: str) -> pd.DataFrame:
|
||||||
"""List agents assigned to a specific policy group."""
|
|
||||||
payload = {"groupid": groupid}
|
payload = {"groupid": groupid}
|
||||||
result = self._post("/v1/group/agents", payload)
|
result = await self._post("/v1/group/agents", payload)
|
||||||
return pd.DataFrame(result["response"]["agents"])
|
return pd.DataFrame(result["response"]["agents"])
|
||||||
|
|
||||||
def policy_list_allowlists(self, groupid: str) -> pd.DataFrame:
|
async def policy_list_allowlists(self, groupid: str) -> pd.DataFrame:
|
||||||
"""List allowlists assigned to a specific policy group."""
|
|
||||||
payload = {"groupid": groupid}
|
payload = {"groupid": groupid}
|
||||||
result = self._post("/v1/group/policies", payload)
|
result = await self._post("/v1/group/policies", payload)
|
||||||
return pd.DataFrame(result["response"]["applications"])
|
return pd.DataFrame(result["response"]["applications"])
|
||||||
|
|
||||||
def policy_set_auditmode(self, groupid: str, auditmode: str) -> dict:
|
async 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}
|
payload = {"groupid": groupid, "auditmode": auditmode}
|
||||||
return self._post("/v1/group/settings/auditmode", payload)
|
return await self._post("/v1/group/settings/auditmode", payload)
|
||||||
|
|
||||||
|
async def policy_set_script_custom(
|
||||||
|
self,
|
||||||
|
groupid: str,
|
||||||
|
script_custom: int,
|
||||||
|
scripts_audit: Optional[List[str]] = None,
|
||||||
|
scripts_disabled: Optional[List[str]] = None,
|
||||||
|
scripts_respect: Optional[List[str]] = None
|
||||||
|
) -> dict:
|
||||||
|
payload = {
|
||||||
|
"groupid": groupid,
|
||||||
|
"script_custom": script_custom,
|
||||||
|
"scripts_audit": scripts_audit or [],
|
||||||
|
"scripts_disabled": scripts_disabled or [],
|
||||||
|
"scripts_respect": scripts_respect or []
|
||||||
|
}
|
||||||
|
return await self._post("/v1/group/settings/script_custom", payload)
|
||||||
|
|
||||||
# Execution History
|
# Execution History
|
||||||
def history_logging(self, type: List[str], checkpoint: str, policy: List[str]) -> str:
|
async def history_logging(self, type: List[str], checkpoint: str, policy: List[str]) -> List[Dict]:
|
||||||
"""Retrieve execution history logs."""
|
|
||||||
payload = {"type": type, "checkpoint": checkpoint, "policy": policy}
|
payload = {"type": type, "checkpoint": checkpoint, "policy": policy}
|
||||||
result = self._post("/v1/logging/exechistories", payload)
|
result = await self._post("/v1/logging/exechistories", payload)
|
||||||
return result["response"]["exechistories"]
|
return result["response"]["exechistories"]
|
||||||
|
|
||||||
def history_execution(self, today: str, date_selected: str, agent_name: str) -> List[Dict]:
|
async 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}
|
payload = {"datefrom": date_selected, "dateto": today, "hostname": agent_name}
|
||||||
result = self._post("/v1/getexechistory", payload)
|
result = await self._post("/v1/getexechistory", payload)
|
||||||
return result["response"]["exechistory"]
|
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)
|
|
||||||
"""
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncTaskQueue:
|
||||||
|
def __init__(self, worker_count: int = 3):
|
||||||
|
self.queue = asyncio.Queue()
|
||||||
|
self.worker_count = worker_count
|
||||||
|
self.workers = []
|
||||||
|
self._stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
async def start_workers(self):
|
||||||
|
"""Start the worker pool."""
|
||||||
|
logger.debug(f"Starting {self.worker_count} workers...")
|
||||||
|
for i in range(self.worker_count):
|
||||||
|
worker = asyncio.create_task(self.worker_loop(f"Worker-{i+1}"))
|
||||||
|
self.workers.append(worker)
|
||||||
|
logger.debug("All workers started.")
|
||||||
|
|
||||||
|
async def stop_workers(self):
|
||||||
|
"""Stop the worker pool and wait for all tasks to complete."""
|
||||||
|
logger.debug("Stopping workers...")
|
||||||
|
self._stop_event.set() # Signal workers to stop
|
||||||
|
await self.queue.join() # Wait for all tasks to be processed
|
||||||
|
for worker in self.workers:
|
||||||
|
worker.cancel()
|
||||||
|
await asyncio.gather(*self.workers, return_exceptions=True)
|
||||||
|
logger.debug("All workers stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
async def worker_loop(self, name: str):
|
||||||
|
"""Worker loop: Process tasks from the queue."""
|
||||||
|
logger.debug(f"{name} started.")
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
task = None
|
||||||
|
try:
|
||||||
|
task = await self.queue.get()
|
||||||
|
logger.info(f"{name} processing: {task['name']}")
|
||||||
|
await task['func'](*task['args'], **task['kwargs'])
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.debug(f"{name} received cancellation.")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in {name}: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
if task is not None:
|
||||||
|
self.queue.task_done()
|
||||||
|
logger.debug(f"{name} exited.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue(self, name: str, func: Callable, *args: Any, **kwargs: Any):
|
||||||
|
"""Add a task to the queue."""
|
||||||
|
logger.debug(f"Enqueuing task: {name}")
|
||||||
|
await self.queue.put({'name': name, 'func': func, 'args': args, 'kwargs': kwargs})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def run_sync_task_in_thread(func: Callable, *args: Any, **kwargs: Any):
|
||||||
|
"""Run a synchronous function in a separate thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: The synchronous function to run.
|
||||||
|
*args: Arguments to pass to the function.
|
||||||
|
"""
|
||||||
|
await asyncio.to_thread(func, *args, **kwargs)
|
||||||
|
|
||||||
|
"""
|
||||||
|
from asyncTaskQueue import AsyncTaskQueue, run_sync_task_in_thread
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Example async task
|
||||||
|
async def your_async_function(name: str, duration: int):
|
||||||
|
print(f"{name} started, will sleep for {duration} seconds")
|
||||||
|
await asyncio.sleep(duration)
|
||||||
|
print(f"{name} finished")
|
||||||
|
|
||||||
|
# Example sync task
|
||||||
|
def your_sync_function(name: str, duration: int):
|
||||||
|
print(f"{name} started, will sleep for {duration} seconds")
|
||||||
|
import time
|
||||||
|
time.sleep(duration)
|
||||||
|
print(f"{name} finished")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
queue = AsyncTaskQueue(worker_count=2)
|
||||||
|
await queue.start_workers()
|
||||||
|
|
||||||
|
# Enqueue async tasks directly
|
||||||
|
await queue.enqueue("AsyncTask1", your_async_function, "AsyncTask1", 2)
|
||||||
|
await queue.enqueue("AsyncTask2", your_async_function, "AsyncTask2", 1)
|
||||||
|
|
||||||
|
# Enqueue sync tasks using the wrapper
|
||||||
|
await queue.enqueue("SyncTask1", run_sync_task_in_thread, your_sync_function, "SyncTask1", 2)
|
||||||
|
|
||||||
|
# Let tasks run for a while
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
# Stop workers
|
||||||
|
await queue.stop_workers()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
+70
-104
@@ -1,19 +1,4 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
import asyncio
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -27,16 +12,16 @@ import pandas as pd
|
|||||||
from models.agent import Agent
|
from models.agent import Agent
|
||||||
from models.policy import Policy
|
from models.policy import Policy
|
||||||
from services.API import AirlockAPIWrapper
|
from services.API import AirlockAPIWrapper
|
||||||
|
from services.TaskQueue import AsyncTaskQueue, run_sync_task_in_thread
|
||||||
from utils.configmanager import get_protected_json, load_env
|
from utils.configmanager import get_protected_json, load_env
|
||||||
from utils.selector import Selector
|
from utils.Selector import Selector
|
||||||
from utils.utils import colorText, get_sanitized_input
|
from utils.utils import colorText, get_sanitized_input
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def devicehistory(api: AirlockAPIWrapper, outputjson: bool):
|
||||||
def devicehistory(api: AirlockAPIWrapper, outputjson: bool):
|
agents = await selectAgents(api)
|
||||||
agents = selectAgents(api)
|
history_days = await Selector.select_value(
|
||||||
history_days = Selector.select_value(
|
|
||||||
prompt="Enter how many days of history to pull (1–150): ",
|
prompt="Enter how many days of history to pull (1–150): ",
|
||||||
value_type=int,
|
value_type=int,
|
||||||
valid_range=(1, 150),
|
valid_range=(1, 150),
|
||||||
@@ -48,63 +33,68 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool):
|
|||||||
|
|
||||||
historical_date = (datetime.now() - timedelta(days=history_days)).strftime("%Y-%m-%d")
|
historical_date = (datetime.now() - timedelta(days=history_days)).strftime("%Y-%m-%d")
|
||||||
today = datetime.now().strftime("%Y-%m-%d")
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
all_history = []
|
all_history = []
|
||||||
|
|
||||||
for agent in agents:
|
async def fetch_history(agent):
|
||||||
try:
|
try:
|
||||||
exechistory = api.history_execution(today, historical_date, agent.hostname)
|
exechistory = await api.history_execution(today, historical_date, agent.hostname)
|
||||||
|
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"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(colorText(f"❌ Error retrieving history for {agent.hostname}: {e}", "red"))
|
print(colorText(f"❌ Error retrieving history for {agent.hostname}: {e}", "red"))
|
||||||
continue
|
|
||||||
|
|
||||||
if isinstance(exechistory, list):
|
await asyncio.gather(*(fetch_history(agent) for agent in agents))
|
||||||
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:
|
if outputjson:
|
||||||
print(json.dumps(all_history, indent=2))
|
print(json.dumps(all_history, indent=2))
|
||||||
|
|
||||||
|
async def findAllAgents(api: AirlockAPIWrapper):
|
||||||
|
policies_df = await api.policy_find_all()
|
||||||
|
agents_df = await api.agent_find_all()
|
||||||
|
|
||||||
def findAllAgents(api):
|
policies = [Policy(**row["data"]) for _, row in policies_df.iterrows()]
|
||||||
# Step 1: Load data from API
|
agents = [Agent(**row["data"]) for _, row in agents_df.iterrows()]
|
||||||
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}
|
groupid_to_name = {policy.groupid: policy.name for policy in policies}
|
||||||
|
|
||||||
# Step 3: Enrich agents
|
queue = AsyncTaskQueue()
|
||||||
for agent in agents:
|
await queue.start_workers()
|
||||||
|
|
||||||
|
async def enrich_agent(agent):
|
||||||
agent.enrich(groupid_to_name)
|
agent.enrich(groupid_to_name)
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
await queue.enqueue(f"enrich_{agent.hostname}", enrich_agent, agent)
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await queue.stop_workers()
|
||||||
|
|
||||||
return agents
|
return agents
|
||||||
|
|
||||||
def findAgents(api, return_dataframe):
|
async def findAgents(api: AirlockAPIWrapper, return_dataframe: bool):
|
||||||
agents = selectAgents(api)
|
agents = await selectAgents(api)
|
||||||
working_dir = load_env("WORKING_DIR")
|
working_dir = await load_env("WORKING_DIR")
|
||||||
|
|
||||||
if not agents:
|
if not agents:
|
||||||
logging.warning("No agents or policies found.")
|
logging.warning("No agents or policies found.")
|
||||||
print("No agents matched the criteria.")
|
print("No agents matched the criteria.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Convert enriched agents to DataFrame
|
|
||||||
agent_dicts = [asdict(agent) for agent in agents]
|
agent_dicts = [asdict(agent) for agent in agents]
|
||||||
agent_df = pd.DataFrame(agent_dicts)
|
agent_df = pd.DataFrame(agent_dicts)
|
||||||
|
|
||||||
@@ -112,30 +102,24 @@ def findAgents(api, return_dataframe):
|
|||||||
logging.debug("Returning DataFrame to caller.")
|
logging.debug("Returning DataFrame to caller.")
|
||||||
return agent_df
|
return agent_df
|
||||||
|
|
||||||
# Otherwise, print and optionally export
|
|
||||||
print(agent_df)
|
print(agent_df)
|
||||||
logging.debug("Displayed DataFrame to console.")
|
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()
|
user_input = await get_sanitized_input("\nWould you like to export the results to a CSV file? (y/n): ")
|
||||||
|
user_input = user_input.strip().lower()
|
||||||
if user_input == 'y':
|
if user_input == 'y':
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
filename = f"agentsearch_{timestamp}.csv"
|
filename = f"agentsearch_{timestamp}.csv"
|
||||||
file_path = os.path.join(working_dir, filename)
|
file_path = os.path.join(working_dir, filename)
|
||||||
|
|
||||||
agent_df.to_csv(file_path, index=False)
|
await run_sync_task_in_thread(agent_df.to_csv, file_path, index=False)
|
||||||
logging.info(f"Exported DataFrame to {file_path}")
|
|
||||||
|
|
||||||
print(
|
logging.info(f"Exported DataFrame to {file_path}")
|
||||||
colorText(
|
print(colorText(f"\n✅ Matched devices exported to: {working_dir}\\{filename}", "green"))
|
||||||
f"\n✅ Matched devices exported to: {working_dir}\\{filename}",
|
|
||||||
"green",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logging.debug("User declined to export the DataFrame.")
|
logging.debug("User declined to export the DataFrame.")
|
||||||
|
|
||||||
|
async def selectAgents(api: AirlockAPIWrapper) -> List[Agent]:
|
||||||
def selectAgents(api: AirlockAPIWrapper) -> List[Agent]:
|
|
||||||
print(colorText("🔍 Device Search", "cyan"))
|
print(colorText("🔍 Device Search", "cyan"))
|
||||||
print(colorText("Enter the device hostnames you'd like to search for, one per line.", "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.\n", "cyan"))
|
print(colorText("When you're done, press Enter twice.\n", "cyan"))
|
||||||
@@ -144,50 +128,44 @@ def selectAgents(api: AirlockAPIWrapper) -> List[Agent]:
|
|||||||
print(colorText("UTN00000", "cyan"))
|
print(colorText("UTN00000", "cyan"))
|
||||||
print(colorText("i-hSuperSecretServer", "cyan"))
|
print(colorText("i-hSuperSecretServer", "cyan"))
|
||||||
print(colorText("u-hVenderBroke\n", "cyan"))
|
print(colorText("u-hVenderBroke\n", "cyan"))
|
||||||
|
|
||||||
print(colorText("Paste or type your device names below:", "white"))
|
print(colorText("Paste or type your device names below:", "white"))
|
||||||
policies = [Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()]
|
|
||||||
|
policies_df = await api.policy_find_all()
|
||||||
|
policies = [Policy(**row.to_dict()) for _, row in policies_df.iterrows()]
|
||||||
|
|
||||||
device_input_lines = []
|
device_input_lines = []
|
||||||
empty_line_count = 0
|
empty_line_count = 0
|
||||||
|
|
||||||
# Regex to validate each line
|
|
||||||
valid_line_pattern = re.compile(r'^[a-zA-Z0-9_\- ]+$')
|
valid_line_pattern = re.compile(r'^[a-zA-Z0-9_\- ]+$')
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
line = get_sanitized_input("")
|
line = await get_sanitized_input("")
|
||||||
stripped_line = line.strip()
|
stripped_line = line.strip()
|
||||||
|
|
||||||
if stripped_line == "":
|
if stripped_line == "":
|
||||||
empty_line_count += 1
|
empty_line_count += 1
|
||||||
if empty_line_count == 2:
|
if empty_line_count == 2:
|
||||||
break
|
break
|
||||||
continue # Don't validate empty lines
|
continue
|
||||||
else:
|
else:
|
||||||
empty_line_count = 0
|
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"))
|
||||||
|
|
||||||
# Validate only non-empty lines
|
|
||||||
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"))
|
|
||||||
|
|
||||||
device_names = [name for name in device_input_lines if name]
|
device_names = [name for name in device_input_lines if name]
|
||||||
if not device_names:
|
if not device_names:
|
||||||
logger.debug("No device names entered")
|
logger.debug("No device names entered")
|
||||||
print(colorText("⚠️ No device names entered.", "red"))
|
print(colorText("⚠️ No device names entered.", "red"))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Build regex pattern to match hostnames
|
pattern = "\n".join(map(re.escape, device_names))
|
||||||
pattern = "|".join(map(re.escape, device_names))
|
|
||||||
regex = re.compile(pattern, re.IGNORECASE)
|
regex = re.compile(pattern, re.IGNORECASE)
|
||||||
|
|
||||||
# Fetch agents
|
agents_df = await api.agent_find_all()
|
||||||
agents = [Agent(**row.to_dict()) for _, row in api.agent_find_all().iterrows()]
|
agents = [Agent(**row.to_dict()) for _, row in agents_df.iterrows()]
|
||||||
matched_agents = [agent for agent in agents if regex.search(agent.hostname)]
|
matched_agents = [agent for agent in agents if regex.search(agent.hostname)]
|
||||||
matched_agents.sort(key=lambda agent: agent.hostname.lower())
|
matched_agents.sort(key=lambda agent: agent.hostname.lower())
|
||||||
|
|
||||||
# Show unmatched
|
|
||||||
unmatched = [name for name in device_names if not any(regex.search(agent.hostname) for agent in agents)]
|
unmatched = [name for name in device_names if not any(regex.search(agent.hostname) for agent in agents)]
|
||||||
if unmatched:
|
if unmatched:
|
||||||
logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}")
|
logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}")
|
||||||
@@ -197,30 +175,17 @@ def selectAgents(api: AirlockAPIWrapper) -> List[Agent]:
|
|||||||
logger.debug("❌ No matching devices found.")
|
logger.debug("❌ No matching devices found.")
|
||||||
print(colorText("❌ No matching devices found.", "red"))
|
print(colorText("❌ No matching devices found.", "red"))
|
||||||
else:
|
else:
|
||||||
logger.debug(f"✅ Found {len(matched_agents)} matching device(s).")
|
logger.debug(f"✅ Found {len(matched_agents)} matching device(s).")
|
||||||
print(colorText(f"✅ Found {len(matched_agents)} matching device(s).", "green"))
|
print(colorText(f"✅ Found {len(matched_agents)} matching device(s).", "green"))
|
||||||
|
|
||||||
# Enrich each agent using its class method
|
|
||||||
for agent in matched_agents:
|
for agent in matched_agents:
|
||||||
agent.enrich_with_policies(policies)
|
agent.enrich_with_policies(policies)
|
||||||
|
|
||||||
return matched_agents
|
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", "{}")
|
|
||||||
|
|
||||||
|
async def moveAgentToRelatedPolicy(api: AirlockAPIWrapper, agent: Agent, mode: str = "audit"):
|
||||||
|
policy_relationship_map = await get_protected_json("POLICY_MAP_ENF_AUD", "{}")
|
||||||
|
#TODO - Have this return the policy name it was moved to instead of the groupid
|
||||||
if mode == "audit":
|
if mode == "audit":
|
||||||
if agent.groupid in policy_relationship_map:
|
if agent.groupid in policy_relationship_map:
|
||||||
target_policy = policy_relationship_map[agent.groupid]
|
target_policy = policy_relationship_map[agent.groupid]
|
||||||
@@ -231,7 +196,6 @@ def moveAgentToRelatedPolicy(
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"Error: No corresponding audit policy found for groupid: {agent.groupid}.")
|
logger.warning(f"Error: No corresponding audit policy found for groupid: {agent.groupid}.")
|
||||||
return
|
return
|
||||||
|
|
||||||
elif mode == "enforcement":
|
elif mode == "enforcement":
|
||||||
inverse_map = {v: k for k, v in policy_relationship_map.items()}
|
inverse_map = {v: k for k, v in policy_relationship_map.items()}
|
||||||
if agent.groupid in inverse_map:
|
if agent.groupid in inverse_map:
|
||||||
@@ -242,9 +206,11 @@ def moveAgentToRelatedPolicy(
|
|||||||
else:
|
else:
|
||||||
logger.warning(f"Error: No corresponding enforcement policy found for groupid: {agent.groupid}.")
|
logger.warning(f"Error: No corresponding enforcement policy found for groupid: {agent.groupid}.")
|
||||||
return
|
return
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"Unknown mode '{mode}'. Use 'audit' or 'enforcement'.")
|
logger.error(f"Unknown mode '{mode}'. Use 'audit' or 'enforcement'.")
|
||||||
return
|
return
|
||||||
|
|
||||||
api.agent_move(agent.agentid, target_policy)
|
result = await api.agent_move(agent.agentid, target_policy)
|
||||||
|
if result == {'error': 'Success'}: logger.info(f"{agent.hostname} has been moved to {target_policy}")
|
||||||
|
else: logger.debug(result)
|
||||||
|
|
||||||
+75
-169
@@ -1,29 +1,15 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
import asyncio
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import gc
|
import gc
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import uuid
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import tqdm
|
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
|
from tqdm.asyncio import tqdm_asyncio
|
||||||
|
|
||||||
from models.policy import Policy
|
from models.policy import Policy
|
||||||
from services.API import AirlockAPIWrapper
|
from services.API import AirlockAPIWrapper
|
||||||
@@ -35,181 +21,101 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def pullPolicyExechistories(
|
async def pullPolicyExechistories(api: AirlockAPIWrapper, policy: Policy, type, days, outputjson):
|
||||||
api: AirlockAPIWrapper,
|
file_path = f"{get_base_directory()}\\cache\\chunkinator_{policy.name}_{uuid.uuid4().hex}.json"
|
||||||
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))
|
checkpoint = str(skipback(days))
|
||||||
json_output = {"error": "Success", "response": {"exechistories": []}}
|
json_output = {"error": "Success", "response": {"exechistories": []}}
|
||||||
|
|
||||||
with tqdm.tqdm(
|
if not os.path.exists(file_path):
|
||||||
file=sys.stdout,
|
async with aiofiles.open(file_path, "w") as file:
|
||||||
leave=True,
|
await file.write(json.dumps(json_output))
|
||||||
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
|
filebar = tqdm_asyncio(total=10000, desc=f"Checkpoint Progress: {checkpoint}", colour="blue")
|
||||||
if not isinstance(histories, list) or not all(
|
pbar = tqdm_asyncio(total=100, desc=f"Total of {policy.name} Complete: ")
|
||||||
isinstance(h, dict) for h in histories
|
|
||||||
):
|
|
||||||
logger.error(
|
|
||||||
"Unexpected response format from API. Expected list of dictionaries."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
filebar.total = len(histories)
|
while True:
|
||||||
|
histories = await api.history_logging(type=type, checkpoint=checkpoint, policy=[policy.name])
|
||||||
|
if not histories:
|
||||||
|
break
|
||||||
|
|
||||||
if not histories:
|
for index, history_item in enumerate(histories):
|
||||||
break
|
if "checkpoint" not in history_item or "datetime" not in history_item:
|
||||||
|
continue
|
||||||
|
|
||||||
for index, history_item in enumerate(histories):
|
if index == len(histories) - 1:
|
||||||
if (
|
checkpoint = history_item["checkpoint"]
|
||||||
"checkpoint" not in history_item
|
filebar.set_description(f"Checkpoint Progress: {checkpoint}")
|
||||||
or "datetime" not in history_item
|
break
|
||||||
):
|
|
||||||
continue # Skip malformed entries
|
|
||||||
|
|
||||||
# Update checkpoint on last item
|
try:
|
||||||
if index == len(histories) - 1:
|
history_date = datetime.datetime.strptime(
|
||||||
checkpoint = history_item["checkpoint"] # pyright: ignore[reportArgumentType]
|
history_item["datetime"].replace(" +0000 UTC", ""),
|
||||||
filebar.desc = f"Checkpoint Progress: {checkpoint}"
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
break
|
).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
if datetime.date.today() - datetime.timedelta(days=days) <= history_date:
|
||||||
history_date = datetime.datetime.strptime(
|
json_output["response"]["exechistories"].append(history_item)
|
||||||
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 (
|
filebar.update(1)
|
||||||
datetime.date.today() - datetime.timedelta(days=days)
|
await asyncio.sleep(0)
|
||||||
) <= history_date:
|
|
||||||
json_output["response"]["exechistories"].append(history_item)
|
|
||||||
|
|
||||||
filebar.update(1)
|
# Deduplication
|
||||||
filebar.refresh()
|
seen = {}
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
async with aiofiles.open(file_path, "r") as file:
|
||||||
|
content = await file.read()
|
||||||
|
existing_data = json.loads(content)
|
||||||
|
combined = existing_data["response"]["exechistories"] + json_output["response"]["exechistories"]
|
||||||
|
else:
|
||||||
|
combined = json_output["response"]["exechistories"]
|
||||||
|
|
||||||
# Deduplicate entries
|
for entry in combined:
|
||||||
seen = {}
|
key = (entry.get("sha256"), entry.get("filename"), entry.get("hostname"))
|
||||||
if os.path.exists(file_path):
|
seen[key] = entry
|
||||||
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:
|
deduplicated = list(seen.values())
|
||||||
key = (
|
async with aiofiles.open(file_path, "w") as file:
|
||||||
entry.get("sha256"),
|
await file.write(json.dumps({"error": "Success", "response": {"exechistories": deduplicated}}))
|
||||||
entry.get("filename"),
|
|
||||||
entry.get("hostname"),
|
|
||||||
)
|
|
||||||
seen[key] = entry
|
|
||||||
|
|
||||||
deduplicated = list(seen.values())
|
json_output["response"]["exechistories"].clear()
|
||||||
with open(file_path, "w") as file:
|
|
||||||
json.dump(
|
|
||||||
{
|
|
||||||
"error": "Success",
|
|
||||||
"response": {"exechistories": deduplicated},
|
|
||||||
},
|
|
||||||
file,
|
|
||||||
)
|
|
||||||
|
|
||||||
json_output["response"]["exechistories"].clear()
|
try:
|
||||||
|
last_date = datetime.datetime.strptime(
|
||||||
|
history_item["datetime"].replace(" +0000 UTC", ""), # pyright: ignore[reportPossiblyUnboundVariable]
|
||||||
|
"%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(f"Total of {policy.name} Complete: ")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Update progress bar based on last valid item
|
filebar.n = 1
|
||||||
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
|
async with aiofiles.open(file_path, "r") as file:
|
||||||
|
final_output = await file.read()
|
||||||
# Final output
|
|
||||||
with open(file_path, "r") as file:
|
|
||||||
final_output = json.load(file)
|
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
return json.dumps(final_output) if outputjson else None
|
return final_output if outputjson else None
|
||||||
|
|
||||||
|
|
||||||
def getPolicyInfo(api: AirlockAPIWrapper, policy, type, days):
|
async def getPolicyInfo(api, policy, type, days):
|
||||||
executionhist_policy = pd.DataFrame()
|
executionhist_policy = pd.DataFrame()
|
||||||
exehist = pullPolicyExechistories(api, policy, type, days, True)
|
exehist = await pullPolicyExechistories(api, policy, type, days, True)
|
||||||
if exehist is not None:
|
if exehist is not None:
|
||||||
data = json.loads(exehist)
|
data = json.loads(exehist)
|
||||||
executionhist_policy = pd.DataFrame(data["response"]["exechistories"])
|
executionhist_policy = pd.DataFrame(data["response"]["exechistories"])
|
||||||
if not executionhist_policy.empty:
|
if not executionhist_policy.empty:
|
||||||
executionhist_policy = executionhist_policy[
|
executionhist_policy = executionhist_policy[
|
||||||
[
|
["datetime", "sha256", "publisher", "filename", "hostname", "username", "pprocess", "gprocess", "commandline"]
|
||||||
"datetime",
|
|
||||||
"sha256",
|
|
||||||
"publisher",
|
|
||||||
"filename",
|
|
||||||
"hostname",
|
|
||||||
"username",
|
|
||||||
"pprocess",
|
|
||||||
"gprocess",
|
|
||||||
"commandline",
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
executionhist_policy["policy"] = policy # Add policy column here
|
executionhist_policy["policy"] = policy.name
|
||||||
executionhist_policy = executionhist_policy.drop_duplicates(
|
executionhist_policy = executionhist_policy.drop_duplicates(subset=["sha256", "filename", "hostname"])
|
||||||
subset=["sha256", "filename", "hostname"]
|
executionhist_policy = executionhist_policy.sort_values(by=["sha256", "filename"])
|
||||||
)
|
print(colorText(f"Staging of Execution history for policy: {policy.name} is complete", "green"))
|
||||||
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 data
|
||||||
del exehist
|
del exehist
|
||||||
gc.collect()
|
gc.collect()
|
||||||
@@ -230,8 +136,8 @@ def skipback(days):
|
|||||||
return ObjectId(objectid_hex)
|
return ObjectId(objectid_hex)
|
||||||
|
|
||||||
|
|
||||||
def updateAuditPoliciesFromEnforcementPolices(api: AirlockAPIWrapper):
|
async def updateAuditPoliciesFromEnforcementPolices(api):
|
||||||
policy_relationship_map = get_protected_json("POLICY_MAP_ENF_AUD", "{}")
|
policy_relationship_map = await get_protected_json("POLICY_MAP_ENF_AUD", "{}")
|
||||||
for enforcement_policy, audit_policy in policy_relationship_map.items():
|
for enforcement_policy, audit_policy in policy_relationship_map.items():
|
||||||
api.policy_clone(enforcement_policy, audit_policy)
|
await api.policy_clone(enforcement_policy, audit_policy)
|
||||||
api.policy_set_auditmode(audit_policy, "1")
|
await api.policy_set_auditmode(audit_policy, "1")
|
||||||
+22
-38
@@ -1,18 +1,3 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -25,10 +10,12 @@ from cryptography.hazmat.primitives import hashes
|
|||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
|
||||||
|
from utils.utils import colorText
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
KDF_ITERATIONS = 200_000
|
KDF_ITERATIONS = 200_000
|
||||||
SALT_SIZE = 16 # 128-bit Salt
|
SALT_SIZE = 16 # 128-bit Salt
|
||||||
NONCE_SIZE = 12 # AES-GCM
|
NONCE_SIZE = 12 # AES-GCM
|
||||||
KEY_SIZE = 32 # AES-256
|
KEY_SIZE = 32 # AES-256
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +41,7 @@ def configure_keyring_backend():
|
|||||||
raise EnvironmentError(f"Unsupported OS: {system}")
|
raise EnvironmentError(f"Unsupported OS: {system}")
|
||||||
|
|
||||||
|
|
||||||
def store_api_key(service: str, username: str, api_key: str, password: str):
|
async def store_api_key(service: str, username: str, api_key: str, password: str):
|
||||||
configure_keyring_backend()
|
configure_keyring_backend()
|
||||||
salt = os.urandom(SALT_SIZE)
|
salt = os.urandom(SALT_SIZE)
|
||||||
key = _derive_key(password.encode(), salt)
|
key = _derive_key(password.encode(), salt)
|
||||||
@@ -66,7 +53,7 @@ def store_api_key(service: str, username: str, api_key: str, password: str):
|
|||||||
keyring.set_password(service, username, b64)
|
keyring.set_password(service, username, b64)
|
||||||
|
|
||||||
|
|
||||||
def retrieve_api_key(service: str, username: str, password: str) -> str:
|
async def retrieve_api_key(service: str, username: str, password: str) -> str:
|
||||||
configure_keyring_backend()
|
configure_keyring_backend()
|
||||||
b64 = keyring.get_password(service, username)
|
b64 = keyring.get_password(service, username)
|
||||||
if b64 is None:
|
if b64 is None:
|
||||||
@@ -81,41 +68,38 @@ def retrieve_api_key(service: str, username: str, password: str) -> str:
|
|||||||
return pt.decode()
|
return pt.decode()
|
||||||
|
|
||||||
|
|
||||||
def api_key_exists(service: str, username: str) -> bool:
|
async def api_key_exists(service: str, username: str) -> bool:
|
||||||
configure_keyring_backend()
|
configure_keyring_backend()
|
||||||
return keyring.get_password(service, username) is not None
|
return keyring.get_password(service, username) is not None
|
||||||
|
|
||||||
|
|
||||||
def check_password_complexity(password: str) -> bool:
|
def check_password_complexity(password: str) -> bool:
|
||||||
if len(password) < 12:
|
return (
|
||||||
return False
|
len(password) >= 12
|
||||||
if not re.search(r"[A-Z]", password):
|
and bool(re.search(r"[A-Z]", password))
|
||||||
return False
|
and bool(re.search(r"[a-z]", password))
|
||||||
if not re.search(r"[a-z]", password):
|
and bool(re.search(r"[0-9]", password))
|
||||||
return False
|
and bool(re.search(r"[^A-Za-z0-9]", password))
|
||||||
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):
|
async def getAPI(USERNAME, SERVICE_NAME):
|
||||||
logging.debug(
|
logging.debug(
|
||||||
f"Checking for stored API key for user '{USERNAME}' in service '{SERVICE_NAME}'..."
|
f"Checking for stored API key for user '{USERNAME}' in service '{SERVICE_NAME}'..."
|
||||||
)
|
)
|
||||||
|
|
||||||
if api_key_exists(SERVICE_NAME, USERNAME):
|
if await api_key_exists(SERVICE_NAME, USERNAME):
|
||||||
for attempt in range(1, 4):
|
for attempt in range(1, 4):
|
||||||
password = getpass(f"Attempt {attempt}/3 - Enter password to unlock your API key: ")
|
password = getpass(f"Attempt {attempt}/3 - Enter password to unlock your API key: ")
|
||||||
try:
|
try:
|
||||||
apikey = retrieve_api_key(SERVICE_NAME, USERNAME, password)
|
apikey = await retrieve_api_key(SERVICE_NAME, USERNAME, password)
|
||||||
logging.debug("API key successfully retrieved.")
|
logging.debug("API key successfully retrieved.")
|
||||||
return apikey
|
return apikey
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Attempt {attempt} failed: {str(e)}")
|
logging.warning(f"Attempt {attempt} failed: {str(e)}")
|
||||||
logging.error("Failed to retrieve API key after 3 incorrect attempts.")
|
logging.error("Failed to retrieve API key after 3 incorrect attempts.")
|
||||||
raise ValueError("Failed to retrieve API key after 3 incorrect attempts.")
|
print(colorText("❌ Authentication failed. Exiting.", "red"))
|
||||||
|
exit(1)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"No API key found for user '{USERNAME}' in service '{SERVICE_NAME}'.")
|
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()
|
api_key = getpass(f"🗝️ No API key found. Please enter your API key for '{SERVICE_NAME}': ").strip()
|
||||||
@@ -131,7 +115,7 @@ def getAPI(USERNAME, SERVICE_NAME):
|
|||||||
|
|
||||||
if check_password_complexity(password):
|
if check_password_complexity(password):
|
||||||
try:
|
try:
|
||||||
store_api_key(SERVICE_NAME, USERNAME, api_key, password)
|
await store_api_key(SERVICE_NAME, USERNAME, api_key, password)
|
||||||
logging.info("API key stored securely.")
|
logging.info("API key stored securely.")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -145,8 +129,8 @@ class APIKeyManager:
|
|||||||
_api_key = None
|
_api_key = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, service: str, username: str, password: str):
|
async def load(cls, service: str, username: str, password: str):
|
||||||
cls._api_key = retrieve_api_key(service, username, password)
|
cls._api_key = await retrieve_api_key(service, username, password)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls) -> str:
|
def get(cls) -> str:
|
||||||
|
|||||||
+14
-27
@@ -5,6 +5,8 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional, TypeVar
|
from typing import Callable, Optional, TypeVar
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -19,21 +21,20 @@ PROTECTED_KEYS = [
|
|||||||
|
|
||||||
_protected_config = {}
|
_protected_config = {}
|
||||||
|
|
||||||
def get_system_config_path() -> Path:
|
async def get_system_config_path() -> Path:
|
||||||
# Check inside bundled EXE directory first
|
|
||||||
bundled_dir = Path(getattr(sys, '_MEIPASS', ''))
|
bundled_dir = Path(getattr(sys, '_MEIPASS', ''))
|
||||||
bundled_path = bundled_dir / "system_config.json"
|
bundled_path = bundled_dir / "system_config.json"
|
||||||
if bundled_path.exists():
|
if bundled_path.exists():
|
||||||
return bundled_path
|
return bundled_path
|
||||||
|
|
||||||
# Fallback to external location
|
|
||||||
return Path(__file__).parent.parent / "system_config.json"
|
return Path(__file__).parent.parent / "system_config.json"
|
||||||
|
|
||||||
def load_protected_config() -> dict:
|
async def load_protected_config() -> dict:
|
||||||
global _protected_config
|
global _protected_config
|
||||||
try:
|
try:
|
||||||
with open(get_system_config_path(), "r") as f:
|
config_path = await get_system_config_path()
|
||||||
system_config = json.load(f)
|
async with aiofiles.open(config_path, "r") as f:
|
||||||
|
content = await f.read()
|
||||||
|
system_config = json.loads(content)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logging.warning("⚠️ system_config.json not found. Using built-in defaults.")
|
logging.warning("⚠️ system_config.json not found. Using built-in defaults.")
|
||||||
system_config = {
|
system_config = {
|
||||||
@@ -46,10 +47,10 @@ def load_protected_config() -> dict:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_protected_config = {key: system_config[key] for key in PROTECTED_KEYS}
|
_protected_config = {key: system_config[key] for key in PROTECTED_KEYS if key in system_config}
|
||||||
return _protected_config
|
return _protected_config
|
||||||
|
|
||||||
def get_protected_value(key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None) -> Optional[T]:
|
async def get_protected_value(key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None) -> Optional[T]:
|
||||||
value = _protected_config.get(key)
|
value = _protected_config.get(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
logging.warning(f"Protected config key '{key}' not found.")
|
logging.warning(f"Protected config key '{key}' not found.")
|
||||||
@@ -62,7 +63,7 @@ def get_protected_value(key: str, cast_type: Callable[[str], T] = str, default:
|
|||||||
logging.warning(f"Invalid value for protected key '{key}': {value}. Expected type {cast_type.__name__}.")
|
logging.warning(f"Invalid value for protected key '{key}': {value}. Expected type {cast_type.__name__}.")
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def get_protected_json(key: str, default: str = "{}") -> dict:
|
async def get_protected_json(key: str, default: str = "{}") -> dict:
|
||||||
raw = _protected_config.get(key, default)
|
raw = _protected_config.get(key, default)
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
return raw
|
return raw
|
||||||
@@ -75,11 +76,8 @@ def get_protected_json(key: str, default: str = "{}") -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to parse protected JSON key '{key}': {e}")
|
logging.error(f"Failed to parse protected JSON key '{key}': {e}")
|
||||||
return json.loads(default)
|
return json.loads(default)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_env_json(key: str, default: str):
|
||||||
|
|
||||||
def load_env_json(key: str, default: str):
|
|
||||||
raw = os.getenv(key, default)
|
raw = os.getenv(key, default)
|
||||||
try:
|
try:
|
||||||
return json.loads(raw)
|
return json.loads(raw)
|
||||||
@@ -91,24 +89,13 @@ def load_env_json(key: str, default: str):
|
|||||||
logging.error(f"Failed to parse {key}: {e}")
|
logging.error(f"Failed to parse {key}: {e}")
|
||||||
return json.loads(default)
|
return json.loads(default)
|
||||||
|
|
||||||
def load_env(key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None) -> Optional[T]:
|
async 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)
|
value = os.getenv(key)
|
||||||
if value is None:
|
if value is None:
|
||||||
logger.warning(f"Environment variable '{key}' not set.")
|
logger.warning(f"Environment variable '{key}' not set.")
|
||||||
return default
|
return default
|
||||||
try:
|
try:
|
||||||
value = value.strip("'\"") # Strip surrounding quotes
|
value = value.strip("'\"")
|
||||||
return cast_type(value)
|
return cast_type(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
logger.warning(f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}.")
|
logger.warning(f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}.")
|
||||||
|
|||||||
+50
-48
@@ -20,7 +20,7 @@ import re
|
|||||||
import dotenv
|
import dotenv
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
import services.policyhandler as policyh
|
import services.PolicyHandler as policyh
|
||||||
from flows.otp import generate, otp_activities_by_agent, revoke
|
from flows.otp import generate, otp_activities_by_agent, revoke
|
||||||
from flows.prepPolicy import (
|
from flows.prepPolicy import (
|
||||||
buildPathsandPublishers,
|
buildPathsandPublishers,
|
||||||
@@ -32,8 +32,9 @@ from flows.prepPolicy import (
|
|||||||
from flows.quietAgent import findQuietAgents
|
from flows.quietAgent import findQuietAgents
|
||||||
from services.agenthandler import findAgents, moveAgentToRelatedPolicy, selectAgents
|
from services.agenthandler import findAgents, moveAgentToRelatedPolicy, selectAgents
|
||||||
from services.API import AirlockAPIWrapper
|
from services.API import AirlockAPIWrapper
|
||||||
|
from services.TaskQueue import AsyncTaskQueue
|
||||||
from utils.configmanager import load_env
|
from utils.configmanager import load_env
|
||||||
from utils.selector import Selector
|
from utils.Selector import Selector
|
||||||
from utils.utils import (
|
from utils.utils import (
|
||||||
areYouSure,
|
areYouSure,
|
||||||
colorText,
|
colorText,
|
||||||
@@ -47,9 +48,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
dotenv.load_dotenv()
|
dotenv.load_dotenv()
|
||||||
|
|
||||||
def menu_main(api: AirlockAPIWrapper):
|
async def menu_main(api: AirlockAPIWrapper, queue: AsyncTaskQueue):
|
||||||
working_dir = load_env("WORKING_DIR")
|
working_dir = await load_env("WORKING_DIR")
|
||||||
extras = load_env("EXTRAS")
|
extras = await load_env("EXTRAS")
|
||||||
while True:
|
while True:
|
||||||
displayIntro()
|
displayIntro()
|
||||||
# Add Settings, and give option to change working dir
|
# Add Settings, and give option to change working dir
|
||||||
@@ -63,34 +64,34 @@ def menu_main(api: AirlockAPIWrapper):
|
|||||||
print(colorText("S. 🛠️ - Settings", "yellow"))
|
print(colorText("S. 🛠️ - Settings", "yellow"))
|
||||||
print(colorText("Q. 🔚 - Quit", "yellow"))
|
print(colorText("Q. 🔚 - Quit", "yellow"))
|
||||||
|
|
||||||
choice = get_sanitized_input("\nEnter Menu Item: ")
|
choice = await get_sanitized_input("\nEnter Menu Item: ")
|
||||||
if choice == "1":
|
if choice == "1":
|
||||||
print("This Feature is still in development")
|
print("This Feature is still in development")
|
||||||
get_sanitized_input("Press enter to continue")
|
await get_sanitized_input("Press enter to continue")
|
||||||
elif choice == "2":
|
elif choice == "2":
|
||||||
menu_otp(api)
|
await menu_otp(api)
|
||||||
elif choice == "3":
|
elif choice == "3":
|
||||||
choices = ["audit", "enforcement"]
|
choices = ["audit", "enforcement"]
|
||||||
print(colorText("Move devices to which state?:", "yellow"))
|
print(colorText("Move devices to which state?:", "yellow"))
|
||||||
direction = Selector.select_string(choices, False, False)
|
direction = await Selector.select_string(choices, False, False)
|
||||||
devices = selectAgents(api)
|
devices = await selectAgents(api)
|
||||||
print(colorText("Would you like to continue with these devices?","white"))
|
print(colorText("Would you like to continue with these devices?","white"))
|
||||||
for device in devices:
|
for device in devices:
|
||||||
print(device.hostname)
|
print(device.hostname)
|
||||||
confirm = Selector.confirm()
|
confirm =await Selector.confirm()
|
||||||
if direction and devices and confirm:
|
if direction and devices and confirm:
|
||||||
for device in devices:
|
for device in devices:
|
||||||
moveAgentToRelatedPolicy(api,device, direction[0])
|
await moveAgentToRelatedPolicy(api,device, direction)
|
||||||
elif choice == "4":
|
elif choice == "4":
|
||||||
findAgents(api,False)
|
await findAgents(api,False)
|
||||||
elif choice == "5":
|
elif choice == "5":
|
||||||
findQuietAgents(api)
|
await findQuietAgents(api)
|
||||||
elif choice == "6":
|
elif choice == "6":
|
||||||
if extras == "POLICYPREP": menu_policymanagment(api)
|
if extras == "POLICYPREP": await menu_policymanagment(api, queue)
|
||||||
elif choice.upper() == "F":
|
elif choice.upper() == "F":
|
||||||
open_directory(working_dir)
|
await open_directory(working_dir)
|
||||||
elif choice.upper() == "S":
|
elif choice.upper() == "S":
|
||||||
menu_settings()
|
await menu_settings()
|
||||||
elif choice.upper() == "Q":
|
elif choice.upper() == "Q":
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -98,7 +99,7 @@ def menu_main(api: AirlockAPIWrapper):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def menu_policy_enforce(api: AirlockAPIWrapper):
|
async def menu_policy_enforce(api: AirlockAPIWrapper, queue: AsyncTaskQueue):
|
||||||
selected_policies = []
|
selected_policies = []
|
||||||
destination_policy = []
|
destination_policy = []
|
||||||
destination_allowlist = []
|
destination_allowlist = []
|
||||||
@@ -106,34 +107,35 @@ def menu_policy_enforce(api: AirlockAPIWrapper):
|
|||||||
processed_hashes = []
|
processed_hashes = []
|
||||||
processed_publishers = []
|
processed_publishers = []
|
||||||
tested = False
|
tested = False
|
||||||
working_dir = load_env("WORKING_DIR")
|
working_dir = await load_env("WORKING_DIR")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
printEnforceChecklist(selected_policies, destination_policy, destination_allowlist)
|
printEnforceChecklist(selected_policies, destination_policy, destination_allowlist)
|
||||||
choice = get_sanitized_input("\nEnter your choice: ")
|
choice = await get_sanitized_input("\nEnter your choice: ")
|
||||||
|
|
||||||
if choice == "1":
|
if choice == "1":
|
||||||
selected_policies = selectPolicies(api,True)
|
selected_policies = await selectPolicies(api,True)
|
||||||
|
|
||||||
elif choice == "2":
|
elif choice == "2":
|
||||||
print(colorText("Please choose destination_name Policy for Path Exclusions", "white"))
|
print(colorText("Please choose destination_name Policy for Path Exclusions", "white"))
|
||||||
|
|
||||||
destination_policy = selectPolicies(api, False)
|
destination_policy = await selectPolicies(api, False)
|
||||||
|
|
||||||
print(colorText("Please choose Allowlist for Hashes", "white"))
|
print(colorText("Please choose Allowlist for Hashes", "white"))
|
||||||
|
|
||||||
destination_allowlist = selectAllowlists(api, destination_policy, False)
|
destination_allowlist = await selectAllowlists(api, destination_policy, False) # pyright: ignore[reportArgumentType]
|
||||||
|
|
||||||
elif choice == "3":
|
elif choice == "3":
|
||||||
sortHashes(
|
await sortHashes(
|
||||||
api,
|
api,
|
||||||
|
queue,
|
||||||
selected_policies,
|
selected_policies,
|
||||||
type=[1, 2, 6, 7],
|
type=[1, 2, 6, 7],
|
||||||
)
|
)
|
||||||
|
|
||||||
elif choice == "4":
|
elif choice == "4":
|
||||||
if os.path.exists(f"{working_dir}\\Needs_Review\\Review_First\\approved_executions.csv"):
|
if os.path.exists(f"{working_dir}\\Needs_Review\\Review_First\\approved_executions.csv"):
|
||||||
buildPathsandPublishers(False)
|
await buildPathsandPublishers(False)
|
||||||
else:
|
else:
|
||||||
print("File not found. Please make sure it's saved correctly and try again.")
|
print("File not found. Please make sure it's saved correctly and try again.")
|
||||||
|
|
||||||
@@ -141,7 +143,7 @@ def menu_policy_enforce(api: AirlockAPIWrapper):
|
|||||||
if os.path.exists(f"{working_dir}\\Approved\\hashes_to_add.csv") and os.path.exists(
|
if os.path.exists(f"{working_dir}\\Approved\\hashes_to_add.csv") and os.path.exists(
|
||||||
f"{working_dir}\\Approved\\primary_Paths.csv"
|
f"{working_dir}\\Approved\\primary_Paths.csv"
|
||||||
):
|
):
|
||||||
buildPreflights()
|
await buildPreflights()
|
||||||
else:
|
else:
|
||||||
print("File not found. Please make sure it's saved correctly and try again.")
|
print("File not found. Please make sure it's saved correctly and try again.")
|
||||||
|
|
||||||
@@ -209,7 +211,7 @@ def menu_policy_enforce(api: AirlockAPIWrapper):
|
|||||||
|
|
||||||
elif choice == "7":
|
elif choice == "7":
|
||||||
areYouSure()
|
areYouSure()
|
||||||
confirmation = get_sanitized_input("Type 'I AGREE' to continue: ")
|
confirmation = await get_sanitized_input("Type 'I AGREE' to continue: ")
|
||||||
if (
|
if (
|
||||||
tested
|
tested
|
||||||
and destination_policy
|
and destination_policy
|
||||||
@@ -217,10 +219,10 @@ def menu_policy_enforce(api: AirlockAPIWrapper):
|
|||||||
and confirmation.strip() == "I AGREE"
|
and confirmation.strip() == "I AGREE"
|
||||||
):
|
):
|
||||||
print(colorText("Proceeding with the code...", "yellow"))
|
print(colorText("Proceeding with the code...", "yellow"))
|
||||||
api.hash_add_to_allowlist(destination_allowlist[0].applicationid, processed_hashes)
|
await api.hash_add_to_allowlist(destination_allowlist[0].applicationid, processed_hashes)
|
||||||
api.policy_add_path_exclusions(destination_policy[0].groupid, processed_paths)
|
await api.policy_add_path_exclusions(destination_policy[0].groupid, processed_paths)
|
||||||
if processed_publishers:
|
if processed_publishers:
|
||||||
api.policy_add_publishers(destination_policy[0].groupid, processed_publishers)
|
await api.policy_add_publishers(destination_policy[0].groupid, processed_publishers)
|
||||||
else:
|
else:
|
||||||
logger.error("Confirmation block failed. Reasons:")
|
logger.error("Confirmation block failed. Reasons:")
|
||||||
if not tested:
|
if not tested:
|
||||||
@@ -233,9 +235,9 @@ def menu_policy_enforce(api: AirlockAPIWrapper):
|
|||||||
logger.error(" - User did not confirm with 'I AGREE'. Received: '%s'", confirmation.strip())
|
logger.error(" - User did not confirm with 'I AGREE'. Received: '%s'", confirmation.strip())
|
||||||
|
|
||||||
elif choice.upper() == "F":
|
elif choice.upper() == "F":
|
||||||
open_directory(working_dir)
|
await open_directory(working_dir)
|
||||||
elif choice.upper() == "S":
|
elif choice.upper() == "S":
|
||||||
menu_settings()
|
await menu_settings()
|
||||||
elif choice.upper == "B":
|
elif choice.upper == "B":
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -244,7 +246,7 @@ def menu_policy_enforce(api: AirlockAPIWrapper):
|
|||||||
print(colorText("Invalid choice. Please try again.", "red"))
|
print(colorText("Invalid choice. Please try again.", "red"))
|
||||||
|
|
||||||
|
|
||||||
def menu_otp(api: AirlockAPIWrapper):
|
async def menu_otp(api: AirlockAPIWrapper):
|
||||||
working_dir = load_env("WORKING_DIR")
|
working_dir = load_env("WORKING_DIR")
|
||||||
while True:
|
while True:
|
||||||
|
|
||||||
@@ -256,23 +258,23 @@ def menu_otp(api: AirlockAPIWrapper):
|
|||||||
print(colorText("S. 🛠️ - Settings", "yellow"))
|
print(colorText("S. 🛠️ - Settings", "yellow"))
|
||||||
print(colorText("B. 🔙 - Back", "yellow"))
|
print(colorText("B. 🔙 - Back", "yellow"))
|
||||||
|
|
||||||
choice = get_sanitized_input("Enter your choice: ")
|
choice = await get_sanitized_input("Enter your choice: ")
|
||||||
|
|
||||||
if choice == "1":
|
if choice == "1":
|
||||||
otp_list = generate(api)
|
otp_list = await generate(api)
|
||||||
print(colorText(otp_list,"green"))
|
print(colorText(otp_list,"green"))
|
||||||
elif choice == "2":
|
elif choice == "2":
|
||||||
otp_activities_by_agent(api)
|
await otp_activities_by_agent(api)
|
||||||
elif choice == "3":
|
elif choice == "3":
|
||||||
revoke(api)
|
await revoke(api)
|
||||||
elif choice.upper() == "F":
|
elif choice.upper() == "F":
|
||||||
open_directory(working_dir)
|
await open_directory(working_dir)
|
||||||
elif choice.upper() == "S":
|
elif choice.upper() == "S":
|
||||||
menu_settings()
|
await menu_settings()
|
||||||
elif choice.upper() == "B":
|
elif choice.upper() == "B":
|
||||||
break
|
break
|
||||||
|
|
||||||
def menu_policymanagment(api: AirlockAPIWrapper):
|
async def menu_policymanagment(api: AirlockAPIWrapper, queue: AsyncTaskQueue):
|
||||||
working_dir = load_env("WORKING_DIR")
|
working_dir = load_env("WORKING_DIR")
|
||||||
while True:
|
while True:
|
||||||
print(colorText("1. 🔒 - Prepare Policy For Enforcement", "yellow"))
|
print(colorText("1. 🔒 - Prepare Policy For Enforcement", "yellow"))
|
||||||
@@ -280,31 +282,31 @@ def menu_policymanagment(api: AirlockAPIWrapper):
|
|||||||
print(colorText("F. 📂 - Open Working Directory", "yellow"))
|
print(colorText("F. 📂 - Open Working Directory", "yellow"))
|
||||||
print(colorText("S. 🛠️ - Settings", "yellow"))
|
print(colorText("S. 🛠️ - Settings", "yellow"))
|
||||||
print(colorText("B. 🔙 - Back", "yellow"))
|
print(colorText("B. 🔙 - Back", "yellow"))
|
||||||
choice = get_sanitized_input("\n Enter Menu Item: ")
|
choice = await get_sanitized_input("\n Enter Menu Item: ")
|
||||||
|
|
||||||
if choice == "1":
|
if choice == "1":
|
||||||
menu_policy_enforce(api)
|
await menu_policy_enforce(api, queue)
|
||||||
elif choice == "2":
|
elif choice == "2":
|
||||||
areYouSure()
|
areYouSure()
|
||||||
confirmation = get_sanitized_input("Type 'I AGREE' to continue: ")
|
confirmation = await get_sanitized_input("Type 'I AGREE' to continue: ")
|
||||||
if confirmation.strip() == "I AGREE":
|
if confirmation.strip() == "I AGREE":
|
||||||
policyh.updateAuditPoliciesFromEnforcementPolices(api)
|
await policyh.updateAuditPoliciesFromEnforcementPolices(api)
|
||||||
|
|
||||||
elif choice.upper() == "F":
|
elif choice.upper() == "F":
|
||||||
open_directory(working_dir)
|
await open_directory(working_dir)
|
||||||
elif choice.upper() == "S":
|
elif choice.upper() == "S":
|
||||||
menu_settings()
|
await menu_settings()
|
||||||
elif choice.upper() == "B":
|
elif choice.upper() == "B":
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
print(colorText("Invalid choice. Please try again.", "red"))
|
print(colorText("Invalid choice. Please try again.", "red"))
|
||||||
def menu_settings():
|
async def menu_settings():
|
||||||
while True:
|
while True:
|
||||||
print(colorText("\n--- 🛠️ Settings Submenu 🛠️ ---", "cyan"))
|
print(colorText("\n--- 🛠️ Settings Submenu 🛠️ ---", "cyan"))
|
||||||
print(colorText("This Feature is still in development", "cyan"))
|
print(colorText("This Feature is still in development", "cyan"))
|
||||||
# print(colorText("2. Sub-option B","cyan"))
|
# print(colorText("2. Sub-option B","cyan"))
|
||||||
print(colorText("B. 🔙 - Back", "yellow"))
|
print(colorText("B. 🔙 - Back", "yellow"))
|
||||||
choice = get_sanitized_input("Enter your choice: ")
|
choice = await get_sanitized_input("Enter your choice: ")
|
||||||
|
|
||||||
if choice == "1":
|
if choice == "1":
|
||||||
pass #TODO ADD CHANGE WORKDIR CODE
|
pass #TODO ADD CHANGE WORKDIR CODE
|
||||||
|
|||||||
+20
-54
@@ -1,33 +1,16 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Callable, List, Optional, Union
|
from typing import Any, Callable, List, Optional, Union
|
||||||
|
|
||||||
from utils.utils import get_sanitized_input
|
from utils.utils import get_sanitized_input # new async version
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Selector:
|
class Selector:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _display_choices(
|
def _display_choices(
|
||||||
items: List[Any],
|
items: List[Any],
|
||||||
label_func: Callable[[Any], str],
|
label_func: Callable[[Any], str],
|
||||||
num_columns: int = 4,
|
num_columns: int = 3,
|
||||||
header: str = "Available Choices:"
|
header: str = "Available Choices:"
|
||||||
) -> None:
|
) -> None:
|
||||||
sorted_items = sorted(items, key=lambda item: label_func(item).lower())
|
sorted_items = sorted(items, key=lambda item: label_func(item).lower())
|
||||||
@@ -43,7 +26,7 @@ class Selector:
|
|||||||
print(line)
|
print(line)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _select_from_list(
|
async def _select_from_list(
|
||||||
items: List[Any],
|
items: List[Any],
|
||||||
label_func: Callable[[Any], str],
|
label_func: Callable[[Any], str],
|
||||||
allow_multiple: bool = False,
|
allow_multiple: bool = False,
|
||||||
@@ -60,7 +43,7 @@ class Selector:
|
|||||||
|
|
||||||
if allow_multiple:
|
if allow_multiple:
|
||||||
while True:
|
while True:
|
||||||
choice = get_sanitized_input("Select an item by number (or Q to finish): ").strip().lower()
|
choice = (await get_sanitized_input("Select an item by number (or Q to finish): ")).strip().lower()
|
||||||
if choice == "q":
|
if choice == "q":
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
@@ -80,7 +63,7 @@ class Selector:
|
|||||||
return selected if selected else None
|
return selected if selected else None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
choice = int(get_sanitized_input("Select one item by number: "))
|
choice = int(await get_sanitized_input("Select one item by number: "))
|
||||||
if 1 <= choice <= len(sorted_items):
|
if 1 <= choice <= len(sorted_items):
|
||||||
selected_item = sorted_items[choice - 1]
|
selected_item = sorted_items[choice - 1]
|
||||||
logger.info(f"Selected: {label_func(selected_item)}")
|
logger.info(f"Selected: {label_func(selected_item)}")
|
||||||
@@ -92,12 +75,8 @@ class Selector:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def select_objects(
|
async def select_objects(objects: List[Any], allow_multiple: bool = False, prompt_each: bool = False) -> Union[Optional[Any], List[Any]]:
|
||||||
objects: List[Any],
|
return await Selector._select_from_list(
|
||||||
allow_multiple: bool = False,
|
|
||||||
prompt_each: bool = False
|
|
||||||
) -> Union[Optional[Any], List[Any]]:
|
|
||||||
return Selector._select_from_list(
|
|
||||||
objects,
|
objects,
|
||||||
label_func=lambda obj: getattr(obj, "name", str(obj)),
|
label_func=lambda obj: getattr(obj, "name", str(obj)),
|
||||||
allow_multiple=allow_multiple,
|
allow_multiple=allow_multiple,
|
||||||
@@ -106,12 +85,8 @@ class Selector:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def select_string(
|
async def select_string(options: List[str], allow_multiple: bool = False, prompt_each: bool = False) -> Union[Optional[str], List[str]]:
|
||||||
options: List[str],
|
return await Selector._select_from_list(
|
||||||
allow_multiple: bool = False,
|
|
||||||
prompt_each: bool = False
|
|
||||||
) -> Union[Optional[str], List[str]]:
|
|
||||||
return Selector._select_from_list(
|
|
||||||
options,
|
options,
|
||||||
label_func=str,
|
label_func=str,
|
||||||
allow_multiple=allow_multiple,
|
allow_multiple=allow_multiple,
|
||||||
@@ -120,12 +95,8 @@ class Selector:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def select_int(
|
async def select_int(options: List[int], allow_multiple: bool = False, prompt_each: bool = False) -> Union[Optional[int], List[int]]:
|
||||||
options: List[int],
|
return await Selector._select_from_list(
|
||||||
allow_multiple: bool = False,
|
|
||||||
prompt_each: bool = False
|
|
||||||
) -> Union[Optional[int], List[int]]:
|
|
||||||
return Selector._select_from_list(
|
|
||||||
options,
|
options,
|
||||||
label_func=lambda x: str(x),
|
label_func=lambda x: str(x),
|
||||||
allow_multiple=allow_multiple,
|
allow_multiple=allow_multiple,
|
||||||
@@ -134,16 +105,11 @@ class Selector:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def select_value(
|
async def select_value(prompt: str, value_type: type = int, valid_range: Optional[tuple] = None, allow_quit: bool = False) -> Optional[Any]:
|
||||||
prompt: str,
|
|
||||||
value_type: type = int,
|
|
||||||
valid_range: Optional[tuple] = None,
|
|
||||||
allow_quit: bool = False
|
|
||||||
) -> Optional[Any]:
|
|
||||||
while True:
|
while True:
|
||||||
user_input = get_sanitized_input(prompt).strip().lower()
|
user_input = (await get_sanitized_input(prompt)).strip().lower()
|
||||||
if allow_quit and user_input == "q":
|
if allow_quit and user_input == "q":
|
||||||
logger.info("User opted to quit value selection.")
|
logger.debug("User opted to quit value selection.")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
value = value_type(user_input)
|
value = value_type(user_input)
|
||||||
@@ -152,20 +118,20 @@ class Selector:
|
|||||||
if not (min_val <= value <= max_val):
|
if not (min_val <= value <= max_val):
|
||||||
logger.warning(f"Value out of range ({min_val}–{max_val}).")
|
logger.warning(f"Value out of range ({min_val}–{max_val}).")
|
||||||
continue
|
continue
|
||||||
logger.info(f"User selected value: {value}")
|
logger.debug(f"User selected value: {value}")
|
||||||
return value
|
return value
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Invalid input. Expected a {value_type.__name__}.")
|
logger.warning(f"Invalid input. Expected a {value_type.__name__}.")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def confirm(prompt: str = "Are you sure? (Y/N): ") -> bool:
|
async def confirm(prompt: str = "Are you sure? (Y/N): ") -> bool:
|
||||||
while True:
|
while True:
|
||||||
response = get_sanitized_input(prompt).strip().lower()
|
response = (await get_sanitized_input(prompt)).strip().lower()
|
||||||
if response in ["y", "yes"]:
|
if response in ["y", "yes"]:
|
||||||
logger.info("User confirmed action.")
|
logger.debug("User confirmed action.")
|
||||||
return True
|
return True
|
||||||
elif response in ["n", "no"]:
|
elif response in ["n", "no"]:
|
||||||
logger.info("User declined action.")
|
logger.debug("User declined action.")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logger.warning("Invalid confirmation input. Expected 'Y' or 'N'.")
|
logger.warning("Invalid confirmation input. Expected 'Y' or 'N'.")
|
||||||
|
|||||||
+10
-24
@@ -1,17 +1,3 @@
|
|||||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published
|
|
||||||
# by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -41,7 +27,11 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"):
|
|||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
logger.setLevel(getattr(logging, log_level.upper(), logging.DEBUG))
|
logger.setLevel(getattr(logging, log_level.upper(), logging.DEBUG))
|
||||||
|
|
||||||
# 🔧 Clear existing handlers
|
|
||||||
|
httpx_logger = logging.getLogger("httpx")
|
||||||
|
httpx_logger.setLevel(getattr(logging, log_level.upper(), logging.DEBUG))
|
||||||
|
|
||||||
|
|
||||||
for handler in logger.handlers[:]:
|
for handler in logger.handlers[:]:
|
||||||
logger.removeHandler(handler)
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
@@ -104,14 +94,14 @@ def load_user_config(config_dir: Path) -> dict:
|
|||||||
def write_config_to_env(config: dict, env_path: Path):
|
def write_config_to_env(config: dict, env_path: Path):
|
||||||
for key, value in config.items():
|
for key, value in config.items():
|
||||||
if key in PROTECTED_KEYS:
|
if key in PROTECTED_KEYS:
|
||||||
continue # Skip protected keys
|
continue
|
||||||
try:
|
try:
|
||||||
serialized = json.dumps(value) if isinstance(value, (list, dict)) else str(value)
|
serialized = json.dumps(value) if isinstance(value, (list, dict)) else str(value)
|
||||||
set_key(env_path, key, serialized)
|
set_key(env_path, key, serialized)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(f"Failed to write {key} to .env: {e}")
|
logging.warning(f"Failed to write {key} to .env: {e}")
|
||||||
|
|
||||||
def setup() -> Path:
|
async def setup() -> Path:
|
||||||
base_dir = get_base_directory()
|
base_dir = get_base_directory()
|
||||||
dirs = {
|
dirs = {
|
||||||
'config': base_dir / 'config',
|
'config': base_dir / 'config',
|
||||||
@@ -129,6 +119,7 @@ def setup() -> Path:
|
|||||||
env_path = base_dir / ".env"
|
env_path = base_dir / ".env"
|
||||||
if not env_path.exists():
|
if not env_path.exists():
|
||||||
env_path.touch()
|
env_path.touch()
|
||||||
|
|
||||||
load_dotenv(dotenv_path=env_path, override=True)
|
load_dotenv(dotenv_path=env_path, override=True)
|
||||||
|
|
||||||
working_dir = Path(os.getenv("WORKING_DIR") or (base_dir / "data"))
|
working_dir = Path(os.getenv("WORKING_DIR") or (base_dir / "data"))
|
||||||
@@ -155,14 +146,10 @@ def setup() -> Path:
|
|||||||
|
|
||||||
user_config = load_user_config(dirs['config'])
|
user_config = load_user_config(dirs['config'])
|
||||||
merged_config = {**system_config, **user_config}
|
merged_config = {**system_config, **user_config}
|
||||||
|
protected_config = await load_protected_config()
|
||||||
protected_config = load_protected_config()
|
|
||||||
merged_config.update(protected_config)
|
merged_config.update(protected_config)
|
||||||
|
|
||||||
# ✅ URL resolution order: system_config → .env → user prompt
|
url = system_config.get("URL") or os.getenv("URL")
|
||||||
url = system_config.get("URL")
|
|
||||||
if not url:
|
|
||||||
url = os.getenv("URL")
|
|
||||||
if not url:
|
if not url:
|
||||||
url = input("🌐 Enter the service URL (e.g., https://example.com/api): ").strip()
|
url = input("🌐 Enter the service URL (e.g., https://example.com/api): ").strip()
|
||||||
merged_config["URL"] = url
|
merged_config["URL"] = url
|
||||||
@@ -171,5 +158,4 @@ def setup() -> Path:
|
|||||||
logging.debug(f"Service URL set to: {url}")
|
logging.debug(f"Service URL set to: {url}")
|
||||||
|
|
||||||
write_config_to_env(merged_config, env_path)
|
write_config_to_env(merged_config, env_path)
|
||||||
|
|
||||||
return working_dir
|
return working_dir
|
||||||
+10
-12
@@ -14,6 +14,7 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
@@ -99,15 +100,15 @@ def choose_file(initial_directory=None, required_substring=None):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def get_sanitized_input(prompt: str) -> str:
|
||||||
def get_sanitized_input(prompt: str) -> str:
|
|
||||||
while True:
|
while True:
|
||||||
user_input = input(prompt)
|
user_input = await asyncio.to_thread(input, prompt)
|
||||||
if user_input.strip() == "":
|
if user_input.strip() == "":
|
||||||
return user_input # Allow blank lines
|
return user_input
|
||||||
if re.match(r'^[a-zA-Z0-9_\- .]+$', user_input.strip()):
|
if re.match(r'^[a-zA-Z0-9_ .-]+$', user_input.strip()):
|
||||||
return user_input
|
return user_input
|
||||||
else:
|
else:
|
||||||
|
logger.debug("User entered invalid input")
|
||||||
print("Invalid input. Only letters, numbers, underscores, spaces, hyphens, and periods are allowed.")
|
print("Invalid input. Only letters, numbers, underscores, spaces, hyphens, and periods are allowed.")
|
||||||
|
|
||||||
|
|
||||||
@@ -695,14 +696,11 @@ def formatHTML(df, output_html_path=None, overwrite=True):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def open_directory(path):
|
async def open_directory(path):
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
|
|
||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
os.startfile(path)
|
await asyncio.to_thread(os.startfile, path)
|
||||||
elif system == "Linux":
|
elif system == "Linux":
|
||||||
subprocess.run(["xdg-open", path])
|
await asyncio.to_thread(subprocess.run, ["xdg-open", path])
|
||||||
else:
|
else:
|
||||||
raise OSError(f"Unsupported operating system: {system}")
|
raise OSError(f"Unsupported operating system: {system}")
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user