RustImplementation #25

Merged
mysticmomba merged 13 commits from RustImplementation into master 2025-11-07 11:39:51 -05:00
23 changed files with 1126 additions and 634 deletions
Showing only changes of commit c062532dd6 - Show all commits
+8 -13
View File
@@ -24,32 +24,29 @@
import logging
import os
import tempfile
import dotenv
import urllib3
from services.API import AirlockAPIWrapper
from services.security import getAPI
from utils.setup import get_base_directory, setup
from utils.TUI import run_AirlockTools
from utils.TUI import run_Loxide
from utils.utils import irtang
urllib3.disable_warnings(
urllib3.exceptions.InsecureRequestWarning
)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def main():
if "NUITKA_ONEFILE_PARENT" in os.environ:
splash_filename = os.path.join(
tempfile.gettempdir(),
f"onefile_{int(os.environ['NUITKA_ONEFILE_PARENT'])}_splash_feedback.tmp"
f"onefile_{int(os.environ['NUITKA_ONEFILE_PARENT'])}_splash_feedback.tmp",
)
if os.path.exists(splash_filename):
os.unlink(splash_filename)
irtang()
# Determine working directory, setup directory, configure logging, sent env, get API and URL if not already stored
setup()
@@ -73,17 +70,15 @@ def main():
logger.error(f"Configuration error: {e}", exc_info=True)
raise
api_key = getAPI(username, "AirlockTools")
api_key = getAPI(username, "Loxide")
if api_key is None:
raise ValueError("API key for AirlockTools is missing.")
raise ValueError("API key for Loxide is missing.")
api = AirlockAPIWrapper(
base_url=str(os.getenv("URL")),
api_key=api_key,
)
run_AirlockTools(api)
run_Loxide(api)
if __name__ == "__main__":
+12 -9
View File
@@ -14,7 +14,6 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# TODO Add CSV injection prevention
# TODO Continue OTP and Local approval rewrites
# TODO Explore pywin32
@@ -29,15 +28,19 @@ import dotenv
import urllib3
import flows.localApproval as la
from Server.scheduler_async import recurring_job, register_function, reload_jobs, start_scheduler
from Server.scheduler_async import (
recurring_job,
register_function,
reload_jobs,
start_scheduler,
)
from services.API import AirlockAPIWrapper
from services.policyhandler import updateAuditPoliciesFromEnforcementPolices
from services.security import getAPI
from utils.setup import setup
urllib3.disable_warnings(
urllib3.exceptions.InsecureRequestWarning
)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def main():
@@ -70,21 +73,21 @@ def main():
api_key=getAPI(username, "AirlockTools"),
)
logger.info("Running non-interactively to start monitoring Airlock Changes")
register_function("monitorLA", la.scheduleAddingLAHashes)
register_function("updateAuditPolicies", updateAuditPoliciesFromEnforcementPolices)
if not os.path.exists("scheduling\\jobs.json"):
recurring_job("monitorLA", "monitorLA", interval=50, unit="seconds", args=[api])
recurring_job("updateAuditPolicies", "updateAudit", interval=5, unit="minutes", args=[api])
recurring_job(
"updateAuditPolicies", "updateAudit", interval=5, unit="minutes", args=[api]
)
else:
reload_jobs()
start_scheduler()
if __name__ == "__main__":
main()
+3 -3
View File
@@ -1,7 +1,7 @@
# 🛡️ Airlock Tools
# 🛡️ Loxide
Python toolkit for secure, auditable, and automated airlock agent and policy management. Designed for enterprise environments, it supports advanced policy workflows, device tracking, and terminal-based interaction.
Python/Rust/Oxide toolkit for secure, auditable, and automated airlock agent and policy management. Designed for enterprise environments, it supports advanced policy workflows, device tracking, and terminal-based interaction.
---
@@ -51,7 +51,7 @@ Python toolkit for secure, auditable, and automated airlock agent and policy man
## 📜 License
**AirlockTools** is licensed under the **GNU Affero General Public License v3.0**.
**Loxide** is licensed under the **GNU Affero General Public License v3.0**.
You may copy, distribute, and modify the software under the terms of the AGPL-3.0 license.
+34 -11
View File
@@ -30,6 +30,7 @@ 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.
@@ -38,6 +39,7 @@ def register_function(name: str, func: Callable):
"""
FUNCTION_MAP[name] = func
def load_jobs() -> List[Dict[str, Any]]:
"""
Load jobs from the JSON file, or return [] if none exist.
@@ -47,6 +49,7 @@ def load_jobs() -> List[Dict[str, Any]]:
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).
@@ -54,6 +57,7 @@ def save_jobs(jobs: List[Dict[str, Any]]):
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.
@@ -66,7 +70,15 @@ def cancel_job(job_id: str):
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):
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).
"""
@@ -87,18 +99,25 @@ def run_once_job(job_id: str, func_name: str, delay_seconds: float, args=None, k
if persist:
jobs = [j for j in load_jobs() if j.get("id") != job_id]
jobs.append({
jobs.append(
{
"id": job_id,
"type": "once",
"delay": delay_seconds,
"function": func_name,
"args": args,
"kwargs": kwargs
})
"kwargs": kwargs,
}
)
save_jobs(jobs)
logger.info(f"Scheduled one-time job '{job_id}' to run in {delay_seconds} seconds.")
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):
def recurring_job(
job_id: str, func_name: str, interval: float, args=None, kwargs=None, persist=True
):
"""
Schedule a recurring job.
"""
@@ -121,17 +140,20 @@ def recurring_job(job_id: str, func_name: str, interval: float, args=None, kwarg
if persist:
jobs = [j for j in load_jobs() if j.get("id") != job_id]
jobs.append({
jobs.append(
{
"id": job_id,
"type": "recurring",
"interval": interval,
"function": func_name,
"args": args,
"kwargs": kwargs
})
"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.
@@ -145,7 +167,7 @@ def reload_jobs():
job["delay"],
job.get("args"),
job.get("kwargs"),
persist=False
persist=False,
)
elif job["type"] == "recurring":
recurring_job(
@@ -154,9 +176,10 @@ def reload_jobs():
job["interval"],
job.get("args"),
job.get("kwargs"),
persist=False
persist=False,
)
async def start_scheduler():
"""
Start the asynchronous scheduler loop.
+5 -2
View File
@@ -1,5 +1,8 @@
from typing import Dict, List, Optional
def pull_policy_exec_histories(self, type: List[str], checkpoint: str, policy: List[str]) -> str:
from typing import Dict, List
def pull_policy_exec_histories(
self, type: List[str], checkpoint: str, policy: List[str]
) -> str:
"""Retrieve execution history logs."""
def api(AirlockAPIWrapper):
+29 -9
View File
@@ -41,7 +41,9 @@ def getLocalApprovals(api: AirlockAPIWrapper):
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 = 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
)
@@ -93,7 +95,9 @@ def scheduleAddingLAHashes(api: AirlockAPIWrapper):
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_time = start_time + datetime.timedelta(
minutes=np.floor(duration_minutes * 0.95)
)
early_timestamp = early_time.timestamp()
run_timestamp = run_time.timestamp()
@@ -148,7 +152,13 @@ def scheduleAddingLAHashes(api: AirlockAPIWrapper):
logger.warning(f"Failed to process batch {batchid}: {e}")
def returnFromLocalApproval(api, device_df, policy_relationship_map, bad_publisher_list, pups, threat_tolerance_constant
def returnFromLocalApproval(
api,
device_df,
policy_relationship_map,
bad_publisher_list,
pups,
threat_tolerance_constant,
):
"""
# Get unique policy names from device list
@@ -170,7 +180,10 @@ def returnFromLocalApproval(api, device_df, policy_relationship_map, bad_publish
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}")
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]
@@ -218,7 +231,6 @@ def addLocalApproval(api: AirlockAPIWrapper, batchid, duration_selected, agentid
api.otp_generate(agentid, duration_selected, purpose)
def monitorAuditStatus(api: AirlockAPIWrapper):
current_agents = findAllAgents(api)
last_agents = []
@@ -228,7 +240,9 @@ def monitorAuditStatus(api: AirlockAPIWrapper):
# 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())
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}
@@ -273,15 +287,21 @@ def getNewLocalApprovals(api: AirlockAPIWrapper):
old_la = pd.DataFrame(columns=current_la.columns)
# Create composite keys
current_la["key"] = current_la["clientid"].astype(str) + "_" + current_la["granted"].astype(str)
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)
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
+50 -33
View File
@@ -14,7 +14,6 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from datetime import datetime
import logging
import os
@@ -45,7 +44,12 @@ def otp_generate(api: AirlockAPIWrapper):
possible_durations = [15, 60, 360, 1440, 10080]
print(colorText("Please select a duration in minutes: ", "white"))
print(colorText("15 mins, 60 mins, 360 mins(6 Hours), 1440 mins (24 Hours), 10080 mins (7 Days):", "white"))
print(
colorText(
"15 mins, 60 mins, 360 mins(6 Hours), 1440 mins (24 Hours), 10080 mins (7 Days):",
"white",
)
)
duration_selected = Selector.select_int(possible_durations)
if isinstance(duration_selected, list):
@@ -62,54 +66,66 @@ def otp_generate(api: AirlockAPIWrapper):
for key, value in otp_dict.items():
print(colorText(f"{key} | {value}", "green"))
def otp_activities_by_agent(api: AirlockAPIWrapper):
activeagents = api.otp_find_active()
awaitingagents = api.otp_find_awaiting()
enforcedagents = api.otp_find_enforced()
revokedagents = api.otp_find_revoked()
# Add a 'status' column to each DataFrame
activeagents['status'] = 'active'
awaitingagents['status'] = 'awaiting'
enforcedagents['status'] = 'enforced'
revokedagents['status'] = 'revoked'
activeagents["status"] = "active"
awaitingagents["status"] = "awaiting"
enforcedagents["status"] = "enforced"
revokedagents["status"] = "revoked"
# Combine all into one DataFrame
combined_agents = pd.concat([activeagents, awaitingagents, enforcedagents, revokedagents], ignore_index=True)
combined_agents = combined_agents.sort_values(by='otpid', ascending=False)
combined_agents = pd.concat(
[activeagents, awaitingagents, enforcedagents, revokedagents], ignore_index=True
)
combined_agents = combined_agents.sort_values(by="otpid", ascending=False)
# Optionally, select specific hosts
user_input = get_sanitized_input("\nWould you like to search for a specific device? (y/n): ").strip().lower()
if user_input == 'y':
user_input = (
get_sanitized_input("\nWould you like to search for a specific device? (y/n): ")
.strip()
.lower()
)
if user_input == "y":
agentnames = []
agents = selectAgents(api)
for agent in agents:
agentnames.append(agent.hostname)
combined_agents = combined_agents[combined_agents['hostname'].isin(agentnames)]
combined_agents = combined_agents[combined_agents["hostname"].isin(agentnames)]
# Present and select rows
selected_rows = Selector.select_dataframe_with_mode(
combined_agents,
columns=['otpid', 'hostname', 'status','purpose','granted'],
header="OTP Sessions"
columns=["otpid", "hostname", "status", "purpose", "granted"],
header="OTP Sessions",
)
combined_df = pd.DataFrame()
for row in selected_rows:
otpid = row['otpid']
hostname = row['hostname']
otpid = row["otpid"]
hostname = row["hostname"]
result = api.otp_get_activities(otpid)
result['hostname'] = hostname
result["hostname"] = hostname
if not result.empty:
logger.info(f"Activities for {hostname} (otpid: {otpid}):\n{result}")
combined_df = pd.concat([combined_df, result], ignore_index=True)
else:
logger.info(f"No activities found for {hostname} (otpid: {otpid})")
user_input = get_sanitized_input("\nWould you like to export the results to a CSV file? (y/n): ").strip().lower()
if user_input == 'y':
user_input = (
get_sanitized_input(
"\nWould you like to export the results to a CSV file? (y/n): "
)
.strip()
.lower()
)
if user_input == "y":
working_dir = load_env("WORKING_DIR")
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"otp_activities_{timestamp}.csv"
@@ -128,43 +144,44 @@ def otp_activities_by_agent(api: AirlockAPIWrapper):
logging.debug("User declined to export the DataFrame.")
def otp_revoke(api: AirlockAPIWrapper):
activeagents = api.otp_find_active()
awaitingagents = api.otp_find_awaiting()
activeagents['status'] = 'active'
awaitingagents['status'] = 'awaiting'
activeagents["status"] = "active"
awaitingagents["status"] = "awaiting"
combined_agents = pd.concat([activeagents, awaitingagents], ignore_index=True)
combined_agents = combined_agents.sort_values(by='otpid', ascending=False)
combined_agents = combined_agents.sort_values(by="otpid", ascending=False)
# Combine all into one DataFrame
combined_agents = pd.concat([activeagents, awaitingagents], ignore_index=True)
combined_agents = combined_agents.sort_values(by='otpid', ascending=False)
combined_agents = combined_agents.sort_values(by="otpid", ascending=False)
# Optionally, select specific hosts
user_input = get_sanitized_input("\nWould you like to search for a specific device? (y/n): ").strip().lower()
if user_input == 'y':
user_input = (
get_sanitized_input("\nWould you like to search for a specific device? (y/n): ")
.strip()
.lower()
)
if user_input == "y":
agentnames = []
agents = selectAgents(api)
for agent in agents:
agentnames.append(agent.hostname)
combined_agents = combined_agents[combined_agents['hostname'].isin(agentnames)]
combined_agents = combined_agents[combined_agents["hostname"].isin(agentnames)]
# Present and select rows
selected_rows = Selector.select_dataframe_with_mode(
combined_agents,
columns=['otpid', 'hostname', 'status','purpose','granted'],
header="OTP Sessions"
columns=["otpid", "hostname", "status", "purpose", "granted"],
header="OTP Sessions",
)
for row in selected_rows:
otpid = row['otpid']
hostname = row['hostname']
otpid = row["otpid"]
hostname = row["hostname"]
result = api.otp_revoke(otpid)
logger.info(f"{hostname} (otpid: {otpid}):\n{result}")
+334 -91
View File
@@ -44,7 +44,6 @@ logger = logging.getLogger(__name__)
dotenv.load_dotenv()
def selectPolicies(api: AirlockAPIWrapper, allow_multiple=True) -> List[Policy]:
policies = [Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()]
@@ -60,9 +59,18 @@ def selectPolicies(api: AirlockAPIWrapper, allow_multiple=True) -> List[Policy]:
return selected if isinstance(selected, list) else [selected]
def selectAllowlists(api: AirlockAPIWrapper, policy = all, allow_multiple=True) -> List[Allowlist]:
if policy == "all": allowlists = [Allowlist(**row.to_dict()) for _, row in api.allowlist_find_all().iterrows()]
else: allowlists = [Allowlist(**row.to_dict()) for _, row in api.policy_list_allowlists(policy[0].groupid).iterrows()]
def selectAllowlists(
api: AirlockAPIWrapper, policy=all, allow_multiple=True
) -> List[Allowlist]:
if policy == "all":
allowlists = [
Allowlist(**row.to_dict()) for _, row in api.allowlist_find_all().iterrows()
]
else:
allowlists = [
Allowlist(**row.to_dict())
for _, row in api.policy_list_allowlists(policy[0].groupid).iterrows()
]
logger.debug("Prompting for Allowlist(s)")
print(colorText("Please select allowlist(s)", "white"))
selected = Selector.select_objects(allowlists, allow_multiple, prompt_each=True)
@@ -76,9 +84,7 @@ def selectAllowlists(api: AirlockAPIWrapper, policy = all, allow_multiple=True)
def sortHashes(
api: AirlockAPIWrapper,
selected_policies: List[Policy],
type=[1, 2, 6, 7]
api: AirlockAPIWrapper, selected_policies: List[Policy], type=[1, 2, 6, 7]
):
working_dir = load_env("WORKING_DIR")
history_days = Selector.select_value(
@@ -99,18 +105,25 @@ def sortHashes(
logger.debug(f"Executions contains {policy_executions}")
enriched_executions = ExecutionHistoryRecord.enrich_with_hashes(api, policy_executions)
categorized_executions = ExecutionHistoryRecord.categorize_executions_by_hash_decision(enriched_executions)
approved, unapproved, needs_review, unknown = ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions)
enriched_executions = ExecutionHistoryRecord.enrich_with_hashes(
api, policy_executions
)
categorized_executions = (
ExecutionHistoryRecord.categorize_executions_by_hash_decision(
enriched_executions
)
)
approved, unapproved, needs_review, unknown = (
ExecutionHistoryRecord.sort_by_hash_decision(categorized_executions)
)
categories = {
"needs_review": needs_review,
"approved": approved,
"unapproved": unapproved,
"leftover" : unknown
"leftover": unknown,
}
for label, records in categories.items():
if not records:
continue # Skip empty or falsy categories
@@ -122,9 +135,9 @@ def sortHashes(
df = pd.DataFrame([r.__dict__ for r in records])
# Optional: flatten hash_obj if needed
if not df.empty and 'hash_obj' in df.columns:
hash_df = df['hash_obj'].apply(lambda h: h.to_dict() if h else {})
df = pd.concat([df.drop(columns=['hash_obj']), hash_df], axis=1)
if not df.empty and "hash_obj" in df.columns:
hash_df = df["hash_obj"].apply(lambda h: h.to_dict() if h else {})
df = pd.concat([df.drop(columns=["hash_obj"]), hash_df], axis=1)
# Save to CSV
df.to_csv(csv_path, index=False)
@@ -140,7 +153,9 @@ def buildPathsandPublishers(selected_policies: List[Policy], split):
df1 = pd.DataFrame()
df2 = pd.DataFrame()
all_approved_hashes = pd.DataFrame()
path1 = f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv"
path1 = (
f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv"
)
path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_needs_review_executions.csv"
path_exclusion_constant = get_protected_value("PATH_EXCLUSION_CONST", cast_type=int)
@@ -163,12 +178,15 @@ def buildPathsandPublishers(selected_policies: List[Policy], split):
if "filename" in all_approved_hashes.columns:
all_approved_hashes = all_approved_hashes.sort_values(by="filename")
else:
logger.warning("Warning: 'filename' column not found in concatenated DataFrame.")
logger.warning(
"Warning: 'filename' column not found in concatenated DataFrame."
)
if not all_approved_hashes.empty and path_exclusion_constant:
primary_path_exclusions = calculatePath(
all_approved_hashes, path_exclusion_constant,
all_approved_hashes,
path_exclusion_constant,
split,
)
remaining_hashes = all_approved_hashes[
@@ -184,16 +202,24 @@ def buildPathsandPublishers(selected_policies: List[Policy], split):
"all_approved_hashes": all_approved_hashes,
"primary_Paths": primary_path_exclusions,
"secondary_Paths": secondary_path_exclusions,
"hashes_not_approvable_by_path": remaining_hashes
"hashes_not_approvable_by_path": remaining_hashes,
}
logger.debug("Preparing to sort dataframes")
for name, df in dataframes.items():
logger.debug(f" DataFrame headers: {list(df.columns)}")
if "hashes" in name : df.sort_values(by="filename", inplace=True)
else: df.sort_values(by="longestcfp", inplace=True)
if "hashes" in name:
df.sort_values(by="filename", inplace=True)
else:
df.sort_values(by="longestcfp", inplace=True)
df.to_csv(f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_{name}.csv", index=False)
formatHTML(df, f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{name}.html")
df.to_csv(
f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_{name}.csv",
index=False,
)
formatHTML(
df,
f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{name}.html",
)
if not all_approved_hashes.empty:
# Drop all not signed, only keep unique values
@@ -205,10 +231,14 @@ def buildPathsandPublishers(selected_policies: List[Policy], split):
publist = publist[~publist["publisher"].str.contains(pattern, na=False)]
publist = publist[["publisher"]]
publist.sort_values(by="publisher", inplace=True)
publist.to_csv(f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_publishers.csv", index=False)
publist.to_csv(
f"{working_dir}\\Needs_Review\\Review_Second\\{selected_policies[0].name}_publishers.csv",
index=False,
)
else:
logger.debug("Approved Hashes list appears empty")
def buildPreflights(selected_policies: List[Policy]):
working_dir = load_env("WORKING_DIR")
@@ -222,7 +252,6 @@ def buildPreflights(selected_policies: List[Policy]):
path2 = f"{working_dir}\\Approved\\{selected_policies[0].name}_secondary_Paths.csv"
publishers = f"{working_dir}\\Approved\\{selected_policies[0].name}_publishers.csv"
# Read in and combine the two path generations
if os.path.exists(path1):
df1 = pd.read_csv(path1)
@@ -245,30 +274,43 @@ def buildPreflights(selected_policies: List[Policy]):
# We create a list of hashes that are left over if we exclude the ones that are covered by the path exclusions.
if os.path.exists(hash):
hashes = pd.read_csv(hash)
approved_hashes = hashes[~hashes['filename'].isin(approved_paths['longestcfp'])]
approved_hashes = hashes[~hashes["filename"].isin(approved_paths["longestcfp"])]
approved_hashes = approved_hashes.drop_duplicates(subset="sha256", keep="first")
else:
logger.warning(f"File not found: {hash}")
if os.path.exists(publishers):
approved_publishers = pd.read_csv(publishers)
else:
logger.warning(f"File not found: {publishers}")
dataframes = {"approved_paths": approved_paths, "approved_hashes": approved_hashes, "approved_publishers": approved_publishers}
dataframes = {
"approved_paths": approved_paths,
"approved_hashes": approved_hashes,
"approved_publishers": approved_publishers,
}
for name, df in dataframes.items():
logger.debug(f" DataFrame headers: {list(df.columns)}")
if name == "approved_paths":df.sort_values(by="longestcfp", inplace=True)
elif name == "approved_hashes":df.sort_values(by="filename", inplace=True)
elif name == "approved_publishers" : df.sort_values(by="publisher", inplace=True)
if name == "approved_paths":
df.sort_values(by="longestcfp", inplace=True)
elif name == "approved_hashes":
df.sort_values(by="filename", inplace=True)
elif name == "approved_publishers":
df.sort_values(by="publisher", inplace=True)
df.to_csv(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_{name}.csv",
index=False,
)
formatHTML(
df,
f"{working_dir}\\Preflight\\HTML\\{selected_policies[0].name}_{name}.html",
)
df.to_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_{name}.csv", index=False)
formatHTML(df, f"{working_dir}\\Preflight\\HTML\\{selected_policies[0].name}_{name}.html")
def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"):
min_files_for_path = get_protected_value("MIN_FILES_FOR_PATH", cast_type=int)
@@ -281,7 +323,9 @@ def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"):
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:
print(f"[WARNING] Non-string entries found in column '{col}':")
print(non_string_entries)
@@ -290,10 +334,14 @@ def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"):
split_paths = df[col].apply(clean_split)
if min_files_for_path is not None:
df = df[split_paths.apply(lambda parts: len(parts) >= min_files_for_path)].copy()
df = df[
split_paths.apply(lambda parts: len(parts) >= min_files_for_path)
].copy()
split_paths = split_paths[df.index]
df["group_key"] = split_paths.apply(lambda parts: os.sep.join(parts[:path_exclusion_constant]))
df["group_key"] = split_paths.apply(
lambda parts: os.sep.join(parts[:path_exclusion_constant])
)
grouped = df.groupby("group_key")
new_rows = []
@@ -330,6 +378,7 @@ def splitFilepathsGrouped(df, path_exclusion_constant, col="filename"):
return pd.DataFrame(new_rows).drop(columns=["group_key"])
def calculatePath(approved_hashes, path_exclusion_constant, split):
if split:
dfs_by_policy = [group for _, group in approved_hashes.groupby("policy")]
@@ -364,7 +413,9 @@ def calculatePath(approved_hashes, path_exclusion_constant, split):
]
unique_sha_counts = (
lcp_not_forbidden_review.groupby("longestcfp")["sha256"].nunique().reset_index()
lcp_not_forbidden_review.groupby("longestcfp")["sha256"]
.nunique()
.reset_index()
)
unique_sha_counts.columns = ["longestcfp", "unique_sha256_count"]
@@ -380,16 +431,23 @@ def calculatePath(approved_hashes, path_exclusion_constant, split):
return pathExclusions
def testChange(selected_policies, destination_policy, destination_allowlist):
working_dir = load_env("WORKING_DIR")
logger.info("These path exclusions would be added to:")
logger.info(destination_policy)
pathexclusions = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv")
hashes = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv")
pathexclusions = pd.read_csv(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
)
hashes = pd.read_csv(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
)
unique_combinations = pathexclusions[["longestcfp", "file_extension"]].drop_duplicates()
unique_combinations = pathexclusions[
["longestcfp", "file_extension"]
].drop_duplicates()
drive_letter_pattern = re.compile(r"^[a-zA-Z]:\\")
processed_paths = [
@@ -402,14 +460,17 @@ def testChange(selected_policies, destination_policy, destination_allowlist):
print(colorText("These publishers would added", "yellow"))
processed_publishers = []
if os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"):
publishers = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv")
if os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"
):
publishers = pd.read_csv(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"
)
if publishers.empty:
print(colorText("The publishers list is empty.", "red"))
else:
processed_publishers = (
publishers[publishers["publisher"] != "Not Signed"]
["publisher"]
publishers[publishers["publisher"] != "Not Signed"]["publisher"]
.drop_duplicates()
.tolist()
)
@@ -424,7 +485,10 @@ def testChange(selected_policies, destination_policy, destination_allowlist):
return processed_paths, processed_hashes, processed_publishers
def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7 into functions
def menu_policy_enforce(
api: AirlockAPIWrapper,
): # TODO Need to clean up 6 and 7 into functions
selected_policies = []
destination_policy = []
destination_allowlist = []
@@ -434,7 +498,9 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
working_dir = load_env("WORKING_DIR")
while True:
printEnforceChecklist(selected_policies, destination_policy, destination_allowlist)
printEnforceChecklist(
selected_policies, destination_policy, destination_allowlist
)
choice = get_sanitized_input("\nEnter your choice: ")
if choice == "1":
@@ -443,7 +509,11 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
elif choice == "2":
clear_screen()
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)
@@ -461,35 +531,53 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
elif choice == "4":
clear_screen()
if os.path.exists(f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_approved_executions.csv"):
if os.path.exists(
f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_approved_executions.csv"
):
buildPathsandPublishers(selected_policies, False)
else:
print("File not found. Please make sure it's saved correctly and try again.")
print(
"File not found. Please make sure it's saved correctly and try again."
)
elif choice == "5":
clear_screen()
if os.path.exists(f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv") and os.path.exists(
if os.path.exists(
f"{working_dir}\\Approved\\{selected_policies[0].name}_approved_executions.csv"
) and os.path.exists(
f"{working_dir}\\Approved\\{selected_policies[0].name}_primary_Paths.csv"
):
buildPreflights(selected_policies)
else:
print("File not found. Please make sure it's saved correctly and try again.")
print(
"File not found. Please make sure it's saved correctly and try again."
)
elif choice == "6":
clear_screen()
if (
os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv")
and os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv")
os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
)
and os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
)
and destination_policy
and destination_allowlist
):
processed_paths, processed_hashes, processed_publishers = testChange(selected_policies, destination_policy, destination_allowlist)
processed_paths, processed_hashes, processed_publishers = testChange(
selected_policies, destination_policy, destination_allowlist
)
else:
# Log which condition(s) failed
missing_items = []
if not os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"):
if not os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv"
):
missing_items.append("approved_paths.csv not found")
if not os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"):
if not os.path.exists(
f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv"
):
missing_items.append("approved_hashes.csv not found")
if not destination_policy:
missing_items.append("destination_policy is empty or None")
@@ -513,10 +601,16 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
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)
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)
api.policy_add_publishers(
destination_policy[0].groupid, processed_publishers
)
locked()
@@ -529,30 +623,52 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
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())
logger.error(
" - User did not confirm with 'I AGREE'. Received: '%s'",
confirmation.strip(),
)
elif choice.upper() == "F":
open_directory(working_dir)
elif choice.upper() == "B":
break
else:
print(colorText("Invalid choice. Please try again.", "red"))
def section_header(title):
print(colorText("\n --------------------------------------------------------------------", "cyan"))
print(
colorText(
"\n --------------------------------------------------------------------",
"cyan",
)
)
print(colorText(f" ------------- {title} -------------", "cyan"))
print(colorText(" --------------------------------------------------------------------", "cyan"))
print(
colorText(
" --------------------------------------------------------------------",
"cyan",
)
)
def printEnforceChecklist(selected_policies, destination_policy, destination_allowlist):
working_dir = load_env("WORKING_DIR")
section_header("🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒")
print(colorText("\nSequentially follow these steps to prepare a policy for enforcement:", "white"))
print(
colorText(
"\nSequentially follow these steps to prepare a policy for enforcement:",
"white",
)
)
# Step 1: Originating Policies
print(colorText("\n1. Choose which policy or policies to gather execution info from", "cyan"))
print(
colorText(
"\n1. Choose which policy or policies to gather execution info from", "cyan"
)
)
if not selected_policies:
print(colorText(" [✗] No policies have been chosen", "red"))
else:
@@ -561,67 +677,194 @@ def printEnforceChecklist(selected_policies, destination_policy, destination_all
print(colorText(f" [✓] {policy.name}", "green"))
# Step 2: Destination Policy and Allowlist
print(colorText("2. Choose the destination policy and associated allowlist", "cyan"))
print(
colorText("2. Choose the destination policy and associated allowlist", "cyan")
)
if destination_policy:
print(colorText(f" [✓] {destination_policy[0].name} has been selected as the destination policy", "green"))
print(
colorText(
f" [✓] {destination_policy[0].name} has been selected as the destination policy",
"green",
)
)
else:
print(colorText(" [✗] No destination policy has been chosen", "red"))
if destination_allowlist:
print(colorText(f" [✓] {destination_allowlist[0].name} has been selected as allowlist", "green"))
print(
colorText(
f" [✓] {destination_allowlist[0].name} has been selected as allowlist",
"green",
)
)
else:
print(colorText(" [✗] No allowlist has been chosen", "red"))
# Step 3: Data Preparation
print(colorText(f"3. Select to begin pulling execution history. The executions will be sorted and placed in {working_dir}\\data\\Needs_Review", "cyan"))
print(
colorText(
f"3. Select to begin pulling execution history. The executions will be sorted and placed in {working_dir}\\data\\Needs_Review",
"cyan",
)
)
if selected_policies:
policy_id = selected_policies[0].name
review_path = f"{working_dir}\\Needs_Review\\Review_First\\{policy_id}_approved_executions.csv"
print(colorText(" [✓] Data has been fetched" if os.path.exists(review_path) else " [✗] Data has not been fetched", "green" if os.path.exists(review_path) else "red"))
print(
colorText(
(
" [✓] Data has been fetched"
if os.path.exists(review_path)
else " [✗] Data has not been fetched"
),
"green" if os.path.exists(review_path) else "red",
)
)
else:
print(colorText(" [✗] No policies selected, cannot check data fetch status", "red"))
print(
colorText(
" [✗] No policies selected, cannot check data fetch status", "red"
)
)
# Step 4: Manual Review
print(colorText("4. Manually review the files:", "cyan"))
print(colorText(" Remove the rows containing hashes you do not approve of", "cyan"))
print(colorText(f" When complete, save both csv files to {working_dir}\\data\\Approved and choose this option.", "cyan"))
print(colorText(" This will start the process to generate possible filepath approvals", "cyan"))
print(
colorText(
" Remove the rows containing hashes you do not approve of", "cyan"
)
)
print(
colorText(
f" When complete, save both csv files to {working_dir}\\data\\Approved and choose this option.",
"cyan",
)
)
print(
colorText(
" This will start the process to generate possible filepath approvals",
"cyan",
)
)
if selected_policies:
policy_id = selected_policies[0].name
approved_path = f"{working_dir}\\Approved\\{policy_id}_approved_executions.csv"
second_review_path = f"{working_dir}\\Needs_Review\\Review_Second\\{policy_id}_primary_Paths.csv"
print(colorText(" [✓] Reviewed hashes have been loaded" if os.path.exists(approved_path) else " [✗] Reviewed hashes have not been loaded", "green" if os.path.exists(approved_path) else "red"))
print(colorText(" [✓] Path review list created" if os.path.exists(second_review_path) else " [✗] Path review list has not been created", "green" if os.path.exists(second_review_path) else "red"))
second_review_path = (
f"{working_dir}\\Needs_Review\\Review_Second\\{policy_id}_primary_Paths.csv"
)
print(
colorText(
(
" [✓] Reviewed hashes have been loaded"
if os.path.exists(approved_path)
else " [✗] Reviewed hashes have not been loaded"
),
"green" if os.path.exists(approved_path) else "red",
)
)
print(
colorText(
(
" [✓] Path review list created"
if os.path.exists(second_review_path)
else " [✗] Path review list has not been created"
),
"green" if os.path.exists(second_review_path) else "red",
)
)
else:
print(colorText(" [✗] No policies selected, cannot check reviewed hashes or path list", "red"))
print(
colorText(
" [✗] No policies selected, cannot check reviewed hashes or path list",
"red",
)
)
# Step 5: Path Review
print(colorText(f"5. Manually review the files in {working_dir}\\Needs_Review\\Review_Second\\", "cyan"))
print(colorText(" Remove the rows containing path exclusions or publishers you do not approve of.", "cyan"))
print(colorText(f" When complete, save the files to {working_dir}\\data\\Approved", "cyan"))
print(colorText(" Choose this option when done to build your preflights", "cyan"))
print(
colorText(
f"5. Manually review the files in {working_dir}\\Needs_Review\\Review_Second\\",
"cyan",
)
)
print(
colorText(
" Remove the rows containing path exclusions or publishers you do not approve of.",
"cyan",
)
)
print(
colorText(
f" When complete, save the files to {working_dir}\\data\\Approved",
"cyan",
)
)
print(
colorText(" Choose this option when done to build your preflights", "cyan")
)
if selected_policies:
policy_id = selected_policies[0].name
reviewed_path = f"{working_dir}\\Approved\\{policy_id}_primary_Paths.csv"
preflight_paths = f"{working_dir}\\Preflight\\{policy_id}_approved_paths.csv"
preflight_hashes = f"{working_dir}\\Preflight\\{policy_id}_approved_hashes.csv"
print(colorText(" [✓] Reviewed path list detected" if os.path.exists(reviewed_path) else " [✗] Path review list has not been detected", "green" if os.path.exists(reviewed_path) else "red"))
preflight_ready = os.path.exists(preflight_paths) and os.path.exists(preflight_hashes)
print(colorText(" [✓] Preflight Path Exclusion List has been generated" if preflight_ready else " [✗] Preflight Path Exclusion List has not been generated", "green" if preflight_ready else "red"))
print(
colorText(
(
" [✓] Reviewed path list detected"
if os.path.exists(reviewed_path)
else " [✗] Path review list has not been detected"
),
"green" if os.path.exists(reviewed_path) else "red",
)
)
preflight_ready = os.path.exists(preflight_paths) and os.path.exists(
preflight_hashes
)
print(
colorText(
(
" [✓] Preflight Path Exclusion List has been generated"
if preflight_ready
else " [✗] Preflight Path Exclusion List has not been generated"
),
"green" if preflight_ready else "red",
)
)
else:
print(colorText(" [✗] No policies selected, cannot check preflight status", "red"))
print(
colorText(
" [✗] No policies selected, cannot check preflight status", "red"
)
)
# Final Steps
print(colorText("6. Test ------------------------------------------------------", "cyan"))
print(colorText(" Prints to console the changes that would be made, must be done to proceed. ", "cyan"))
print(
colorText(
"6. Test ------------------------------------------------------", "cyan"
)
)
print(
colorText(
" Prints to console the changes that would be made, must be done to proceed. ",
"cyan",
)
)
print(colorText("7. Liftoff ------------------------------------------------------", "cyan"))
print(colorText(" Apply path exclusions and approved publishers to selected policy", "cyan"))
print(
colorText(
"7. Liftoff ------------------------------------------------------", "cyan"
)
)
print(
colorText(
" Apply path exclusions and approved publishers to selected policy",
"cyan",
)
)
print(colorText(" Apply approved hashes to allowlist", "cyan"))
# Utility Options
print(colorText("F. 📂 - Open Working Directory", "cyan"))
print(colorText("B. 🔚 - Back", "cyan"))
+15 -10
View File
@@ -51,20 +51,22 @@ def findQuietAgents(api: AirlockAPIWrapper):
valid_range=(1, 150),
)
confirm = Selector.confirm(f"Do you wish to proceed to pull history for {selected_policy[0].name}? Y/N : ")
confirm = Selector.confirm(
f"Do you wish to proceed to pull history for {selected_policy[0].name}? Y/N : "
)
# Get execution history as a DataFrame
if confirm:
policy_exec_history = getPolicyInfo(
api, selected_policy[0], [1, 2, 6, 7], history_days
)
if policy_exec_history.empty:
logging.info("No execution history found for the selected policy and time range.")
logging.info(
"No execution history found for the selected policy and time range."
)
get_sanitized_input("Press enter to continue")
return
# Convert 'datetime' column to timezone-aware datetime objects
policy_exec_history["datetime"] = pd.to_datetime(
policy_exec_history["datetime"], format="%Y-%m-%dT%H:%M:%SZ", utc=True
@@ -82,12 +84,14 @@ def findQuietAgents(api: AirlockAPIWrapper):
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(
subset="hostname", keep="first"
)
most_recent_exec = policy_exec_history.sort_values(
by="days_ago"
).drop_duplicates(subset="hostname", keep="first")
# Map most recent execution age to agents
agents["days_since"] = agents["hostname"].map(
@@ -101,7 +105,9 @@ def findQuietAgents(api: AirlockAPIWrapper):
)
# 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"
@@ -117,7 +123,6 @@ def findQuietAgents(api: AirlockAPIWrapper):
# Print results
message = (
f"Total agents: {total_agents}\n"
f"Agents marked as 'enforce_ready': {ready_agents}\n"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

+3 -7
View File
@@ -38,13 +38,7 @@ class Agent:
status_text: Optional[str] = field(default=None)
# Class-level status map
status_map: ClassVar[dict] = {
0: "Offline",
1: "Online",
2: "Hidden",
3: "Safemode"
}
status_map: ClassVar[dict] = {0: "Offline", 1: "Online", 2: "Hidden", 3: "Safemode"}
def enrich_with_policies(self, policies: List[Policy]):
"""Enrich the agent with groupname and human-readable status."""
@@ -55,6 +49,8 @@ class Agent:
break
if not self.groupname:
self.groupname = "Unknown"
"""
from models.agent import Agent
+65 -31
View File
@@ -28,7 +28,6 @@ import pandas as pd
import airlock_libs
from services.API import AirlockAPIWrapper
from services.policyhandler import pullPolicyExechistories
from utils.configmanager import get_protected_value, load_env_json
from utils.utils import colorText, regulator
@@ -36,11 +35,13 @@ logger = logging.getLogger(__name__)
dotenv.load_dotenv()
@dataclass
class Hash:
"""
Hash model representing Hash data
"""
sha256: str
applications: str
baselines: str
@@ -100,11 +101,15 @@ class Hash:
for hash_obj in hashes:
publisher = hash_obj.publisher or ""
description = hash_obj.description or ""
reputation = hash_obj.reputation if isinstance(hash_obj.reputation, dict) else {}
reputation = (
hash_obj.reputation if isinstance(hash_obj.reputation, dict) else {}
)
scannermatch = reputation.get("scannermatch")
logger.debug(f"Evaluating hash: {hash_obj}")
logger.debug(f"Publisher: {publisher}, Description: {description}, Scannermatch: {scannermatch}")
logger.debug(
f"Publisher: {publisher}, Description: {description}, Scannermatch: {scannermatch}"
)
# 1. Unapproved: bad publisher or PUP
if re.search(bad_publishers_pattern, publisher, re.IGNORECASE):
@@ -139,15 +144,17 @@ class Hash:
hash_obj.at_decision = "approved"
approved_count += 1
except (ValueError, TypeError):
logger.debug("Needs Review: Scannermatch score is missing or invalid. — {e}")
logger.debug(
"Needs Review: Scannermatch score is missing or invalid. — {e}"
)
hash_obj.at_decision = "needs_review"
needs_review_count += 1
logger.debug(f"Final counts — Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}")
logger.debug(
f"Final counts — Needs Review: {needs_review_count}, Approved: {approved_count}, Unapproved: {unapproved_count}"
)
return hashes
@classmethod
def export_to_csv(cls, hash_list, directory_path):
"""
@@ -204,11 +211,12 @@ class ExecutionHistoryRecord:
localip: Optional[str] = None
extid: Optional[str] = None
extname: Optional[str] = None
exttype: Optional[int] = None # 1 = CRX Chromium Extension, 2 = XPI Firefox Extension
exttype: Optional[int] = (
None # 1 = CRX Chromium Extension, 2 = XPI Firefox Extension
)
extbrowser: Optional[int] = None # 1 = Chrome, 2 = Firefox, 3 = Edge
hash_obj: Optional[Hash] = None
@classmethod
def from_dict(cls, data: dict):
mandatory_fields = [
@@ -225,7 +233,9 @@ class ExecutionHistoryRecord:
"datetime",
]
missing_fields = [
field for field in mandatory_fields if field not in data or data[field] is None
field
for field in mandatory_fields
if field not in data or data[field] is None
]
if missing_fields:
raise ValueError(f"Missing mandatory fields: {missing_fields}")
@@ -255,7 +265,7 @@ class ExecutionHistoryRecord:
extname=data.get("extname"),
exttype=data.get("exttype"),
extbrowser=data.get("extbrowser"),
hash_obj=data.get("hash_obj")
hash_obj=data.get("hash_obj"),
)
@classmethod
@@ -264,7 +274,9 @@ class ExecutionHistoryRecord:
) -> List["ExecutionHistoryRecord"]:
executions = []
for policy in selected_policies:
execs = airlock_libs.pull_policy_exec_histories(api, policy.name, str([1,2,6,7]), history_days)
execs = airlock_libs.pull_policy_exec_histories(
api, policy.name, str([1, 2, 6, 7]), history_days
)
if execs:
data = json.loads(execs)
exechistories = data.get("response", {}).get("exechistories", [])
@@ -275,8 +287,12 @@ class ExecutionHistoryRecord:
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")
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",
@@ -288,17 +304,20 @@ class ExecutionHistoryRecord:
@staticmethod
def enrich_with_hashes(
api: AirlockAPIWrapper,
executions: List["ExecutionHistoryRecord"]
api: AirlockAPIWrapper, executions: List["ExecutionHistoryRecord"]
) -> List["ExecutionHistoryRecord"]:
"""
Enriches each ExecutionHistoryRecord with a matching Hash object by querying the API.
"""
sha256_list = list({e.sha256.strip().lower() for e in executions if e.sha256})
logger.info(f"Extracted {len(sha256_list)} unique sha256 values from {len(executions)} execution records.")
logger.info(
f"Extracted {len(sha256_list)} unique sha256 values from {len(executions)} execution records."
)
if not sha256_list:
logger.warning("No sha256 values found in execution records. Skipping enrichment.")
logger.warning(
"No sha256 values found in execution records. Skipping enrichment."
)
return executions
logger.debug("Querying hash data from API...")
@@ -307,8 +326,10 @@ class ExecutionHistoryRecord:
hash_objects = []
required_fields = {
f.name for f in dataclasses.fields(Hash)
if f.default == dataclasses.MISSING and f.default_factory == dataclasses.MISSING
f.name
for f in dataclasses.fields(Hash)
if f.default == dataclasses.MISSING
and f.default_factory == dataclasses.MISSING
}
for sha256, (_, row) in zip(sha256_list, hash_df.iterrows()):
@@ -346,11 +367,15 @@ class ExecutionHistoryRecord:
exec_record.hash_obj = hash_obj
enriched_count += 1
logger.info(f"Enriched {enriched_count} out of {len(executions)} execution records with hash data.")
logger.info(
f"Enriched {enriched_count} out of {len(executions)} execution records with hash data."
)
return executions
@staticmethod
def categorize_executions_by_hash_decision(executions: List["ExecutionHistoryRecord"]) -> List["ExecutionHistoryRecord"]:
def categorize_executions_by_hash_decision(
executions: List["ExecutionHistoryRecord"],
) -> List["ExecutionHistoryRecord"]:
"""
Categorizes the hash_obj of each ExecutionHistoryRecord based on publisher, description, and reputation.
@@ -374,11 +399,15 @@ class ExecutionHistoryRecord:
publisher = hash_obj.publisher or ""
description = hash_obj.description or ""
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")
logger.debug(f"Evaluating hash: {hash_obj}")
logger.debug(f"Publisher: {publisher}, Description: {description}, Scannermatch: {scannermatch}")
logger.debug(
f"Publisher: {publisher}, Description: {description}, Scannermatch: {scannermatch}"
)
# 1. Unapproved: bad publisher or PUP
if re.search(bad_publishers_pattern, publisher, re.IGNORECASE):
@@ -413,7 +442,9 @@ class ExecutionHistoryRecord:
hash_obj.at_decision = "approved"
approved_count += 1
except (ValueError, TypeError) as e:
logger.debug(f"Needs Review: Scannermatch score is missing or invalid. — {e}")
logger.debug(
f"Needs Review: Scannermatch score is missing or invalid. — {e}"
)
hash_obj.at_decision = "needs_review"
needs_review_count += 1
@@ -424,11 +455,13 @@ class ExecutionHistoryRecord:
return executions
@classmethod
def sort_by_hash_decision(
cls, executions: List["ExecutionHistoryRecord"]
) -> Tuple[List["ExecutionHistoryRecord"], List["ExecutionHistoryRecord"], List["ExecutionHistoryRecord"], List["ExecutionHistoryRecord"]]:
def sort_by_hash_decision(cls, executions: List["ExecutionHistoryRecord"]) -> Tuple[
List["ExecutionHistoryRecord"],
List["ExecutionHistoryRecord"],
List["ExecutionHistoryRecord"],
List["ExecutionHistoryRecord"],
]:
"""
Sorts ExecutionHistoryRecord objects into approved, unapproved, needs_review, and unknown groups
based on the value of hash_obj.at_decision.
@@ -454,7 +487,9 @@ class ExecutionHistoryRecord:
else:
unknown.append(record)
logger.info(f"[ExecutionHistoryRecord] Sorted {len(sorted_executions)} records by hash_obj.at_decision:")
logger.info(
f"[ExecutionHistoryRecord] Sorted {len(sorted_executions)} records by hash_obj.at_decision:"
)
logger.info(f" Approved: {len(approved)}")
logger.info(f" Unapproved: {len(unapproved)}")
logger.info(f" Needs Review: {len(needs_review)}")
@@ -463,7 +498,6 @@ class ExecutionHistoryRecord:
return approved, unapproved, needs_review, unknown
"""
executions = ExecutionHistoryRecord.from_policies(api, selected_policies, type_=[0,1,3], history_days=30)
+6 -2
View File
@@ -29,7 +29,9 @@ class Policy:
def __repr__(self):
# Show all current attributes, including dynamically added ones
attrs = ", ".join(f"{key}={repr(value)}" for key, value in self.__dict__.items())
attrs = ", ".join(
f"{key}={repr(value)}" for key, value in self.__dict__.items()
)
return f"<Execution({attrs})>"
def to_dict(self):
@@ -53,7 +55,9 @@ class Allowlist:
def __repr__(self):
# Show all current attributes, including dynamically added ones
attrs = ", ".join(f"{key}={repr(value)}" for key, value in self.__dict__.items())
attrs = ", ".join(
f"{key}={repr(value)}" for key, value in self.__dict__.items()
)
return f"<Execution({attrs})>"
def to_dict(self):
+11 -7
View File
@@ -23,7 +23,6 @@ import requests
logger = logging.getLogger(__name__)
class AirlockAPIWrapper:
"""
A wrapper class for interacting with the Airlock API.
@@ -198,7 +197,6 @@ class AirlockAPIWrapper:
payload = {"otpcode": otpcode}
return self._post("/v1/otp/validate", payload)
# Policy Management
def policy_add_path_exclusions(self, groupid: str, paths: List[str]) -> dict:
"""Add path exclusions to a policy group."""
@@ -237,7 +235,8 @@ class AirlockAPIWrapper:
payload = {"groupid": groupid, "auditmode": auditmode}
return self._post("/v1/group/settings/auditmode", payload)
def policy_set_script_custom(self,
def policy_set_script_custom(
self,
groupid: str,
script_custom: int,
scripts_audit: List[str],
@@ -245,22 +244,27 @@ class AirlockAPIWrapper:
scripts_respect: List[str],
) -> dict:
"""Set audit mode for a policy group. 1=Audit, 0=Enforcement"""
payload = {"groupid": groupid,
payload = {
"groupid": groupid,
"script_custom": script_custom,
"scripts_audit": scripts_audit,
"scripts_disabled": scripts_disabled,
"scripts_respect": scripts_respect
"scripts_respect": scripts_respect,
}
return self._post("/v1/group/settings/script_custom", payload)
# Execution History
def history_logging(self, type: List[str], checkpoint: str, policy: List[str]) -> str:
def history_logging(
self, type: List[str], checkpoint: str, policy: List[str]
) -> str:
"""Retrieve execution history logs."""
payload = {"type": type, "checkpoint": checkpoint, "policy": policy}
result = self._post("/v1/logging/exechistories", payload)
return result["response"]["exechistories"]
def history_execution(self, today: str, date_selected: str, agent_name: str) -> List[Dict]:
def history_execution(
self, today: str, date_selected: str, agent_name: str
) -> List[Dict]:
"""
Retrieve execution history logs.
+90 -25
View File
@@ -47,7 +47,9 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool):
print(colorText("No agents selected or invalid history range.", "red"))
return
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")
all_history = []
@@ -56,7 +58,11 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool):
try:
exechistory = api.history_execution(today, historical_date, agent.hostname)
except Exception as e:
print(colorText(f"❌ Error retrieving history for {agent.hostname}: {e}", "red"))
print(
colorText(
f"❌ Error retrieving history for {agent.hostname}: {e}", "red"
)
)
continue
if isinstance(exechistory, list):
@@ -76,7 +82,9 @@ def devicehistory(api: AirlockAPIWrapper, outputjson: bool):
print(colorText(f"{key}: {value}", "green"))
print("\n")
else:
print(colorText(f"No execution history found for {agent.hostname}.", "yellow"))
print(
colorText(f"No execution history found for {agent.hostname}.", "yellow")
)
if outputjson:
print(json.dumps(all_history, indent=2))
@@ -92,6 +100,7 @@ def findAllAgents(api):
return agents
def findAgents(api, return_dataframe):
agents = selectAgents(api)
working_dir = load_env("WORKING_DIR")
@@ -113,8 +122,14 @@ def findAgents(api, return_dataframe):
print(agent_df)
logging.debug("Displayed DataFrame to console.")
user_input = get_sanitized_input("\nWould you like to export the results to a CSV file? (y/n): ").strip().lower()
if user_input == 'y':
user_input = (
get_sanitized_input(
"\nWould you like to export the results to a CSV file? (y/n): "
)
.strip()
.lower()
)
if user_input == "y":
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"agentsearch_{timestamp}.csv"
file_path = os.path.join(str(working_dir), filename)
@@ -131,17 +146,27 @@ def findAgents(api, return_dataframe):
else:
logging.debug("User declined to export the DataFrame.")
def collect_device_names() -> List[str]:
print(colorText("🔍 Device Search", "cyan"))
print(colorText("Enter the device hostnames you'd like to search for, one per line.", "cyan"))
print(colorText("When you're done, press Enter twice (Three times if you have a single device).\n", "cyan"))
print(
colorText(
"Enter the device hostnames you'd like to search for, one per line.", "cyan"
)
)
print(
colorText(
"When you're done, press Enter twice (Three times if you have a single device).\n",
"cyan",
)
)
print(colorText("Example:", "cyan"))
print(colorText("H00000\nUTN00000\ni-hSuperSecretServer\nu-hVenderBroke\n", "cyan"))
print(colorText("Paste or type your device names below:", "white"))
device_input_lines = []
empty_line_count = 0
valid_line_pattern = re.compile(r'^[a-zA-Z0-9_\- ]+$')
valid_line_pattern = re.compile(r"^[a-zA-Z0-9_\- ]+$")
while True:
line = get_sanitized_input("")
@@ -158,7 +183,12 @@ def collect_device_names() -> List[str]:
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"))
print(
colorText(
f"⚠️ Invalid input: '{stripped_line}' — only letters, numbers, underscores, spaces, and hyphens are allowed.",
"yellow",
)
)
return [name for name in device_input_lines if name]
@@ -168,10 +198,13 @@ def choose_match_type() -> bool:
return get_sanitized_input("").strip().lower() in ["y", "yes"]
def match_agents(device_names: List[str], agents: List['Agent'], use_exact: bool) -> List['Agent']:
def match_agents(
device_names: List[str], agents: List["Agent"], use_exact: bool
) -> List["Agent"]:
if use_exact:
return [
agent for agent in agents
agent
for agent in agents
if agent.hostname.lower() in [name.lower() for name in device_names]
]
else:
@@ -180,23 +213,38 @@ def match_agents(device_names: List[str], agents: List['Agent'], use_exact: bool
return [agent for agent in agents if regex.search(agent.hostname)]
def show_unmatched(device_names: List[str], matched_agents: List['Agent'], use_exact: bool):
def show_unmatched(
device_names: List[str], matched_agents: List["Agent"], use_exact: bool
):
if use_exact:
unmatched = [name for name in device_names if not any(agent.hostname.lower() == name.lower() for agent in matched_agents)]
unmatched = [
name
for name in device_names
if not any(
agent.hostname.lower() == name.lower() for agent in matched_agents
)
]
else:
unmatched = [name for name in device_names if not any(re.search(re.escape(name), agent.hostname, re.IGNORECASE) for agent in matched_agents)]
unmatched = [
name
for name in device_names
if not any(
re.search(re.escape(name), agent.hostname, re.IGNORECASE)
for agent in matched_agents
)
]
if unmatched:
logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}")
print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow"))
def enrich_agents(agents: List['Agent'], policies: List['Policy']):
def enrich_agents(agents: List["Agent"], policies: List["Policy"]):
for agent in agents:
agent.enrich_with_policies(policies)
def selectAgents(api: 'AirlockAPIWrapper') -> List['Agent']:
def selectAgents(api: "AirlockAPIWrapper") -> List["Agent"]:
device_names = collect_device_names()
if not device_names:
logger.debug("No device names entered")
@@ -231,7 +279,7 @@ def selectAgents(api: 'AirlockAPIWrapper') -> List['Agent']:
matched_agents = Selector.select_with_mode(
matched_agents,
label_func=lambda agent: agent.hostname,
header="Matched Devices:"
header="Matched Devices:",
)
if not matched_agents:
@@ -263,11 +311,17 @@ def moveAgentToRelatedPolicy(
if agent.groupid in policy_relationship_map:
target_policy = policy_relationship_map[agent.groupid]
elif agent.groupid in policy_relationship_map.values():
logger.debug(f"Agent {agent.hostname} is already in an audit group. No action needed.")
print(f"Agent {agent.hostname} is already in an audit group. No action needed.")
logger.debug(
f"Agent {agent.hostname} is already in an audit group. No action needed."
)
print(
f"Agent {agent.hostname} is already in an audit group. No action needed."
)
return
else:
logger.warning(f"Error: No corresponding audit policy found for groupid: {agent.groupid}.")
logger.warning(
f"Error: No corresponding audit policy found for groupid: {agent.groupid}."
)
return
elif mode == "enforcement":
@@ -275,10 +329,14 @@ def moveAgentToRelatedPolicy(
if agent.groupid in inverse_map:
target_policy = inverse_map[agent.groupid]
elif agent.groupid in inverse_map.values():
logger.info(f"Agent {agent.hostname} is already in an enforcement group. No action needed.")
logger.info(
f"Agent {agent.hostname} is already in an enforcement group. No action needed."
)
return
else:
logger.warning(f"Error: No corresponding enforcement policy found for groupid: {agent.groupid}.")
logger.warning(
f"Error: No corresponding enforcement policy found for groupid: {agent.groupid}."
)
return
else:
@@ -299,21 +357,28 @@ def toggleEnforcement(api: AirlockAPIWrapper):
devices = selectAgents(api)
for device in devices:
print(device.hostname)
confirm = Selector.confirm("Would you like to continue with these devices? Y/N: ")
confirm = Selector.confirm(
"Would you like to continue with these devices? Y/N: "
)
if direction and devices and confirm:
for device in devices:
result = moveAgentToRelatedPolicy(api, device, str(direction).lower())
logger.info(f"{device.hostname}: result: {result}")
get_sanitized_input("Press enter to continue")
def moveAgents(api: AirlockAPIWrapper):
devices = selectAgents(api)
for device in devices:
print(device.hostname)
confirm_devices = Selector.confirm("Would you like to continue with these devices? Y/N: ")
confirm_devices = Selector.confirm(
"Would you like to continue with these devices? Y/N: "
)
if devices and confirm_devices:
policies = selectPolicies(api, False)
confirm_move = Selector.confirm(f"Would you like to move these devices to {policies[0].name}?")
confirm_move = Selector.confirm(
f"Would you like to move these devices to {policies[0].name}?"
)
if confirm_move:
for device in devices:
result = api.agent_move(device.agentid, policies[0].groupid)
+9 -4
View File
@@ -34,7 +34,6 @@ from utils.utils import areYouSure, colorText, get_sanitized_input
logger = logging.getLogger(__name__)
def pullPolicyExechistories(
api: AirlockAPIWrapper,
policy: Policy,
@@ -98,13 +97,17 @@ def pullPolicyExechistories(
# Update checkpoint on last item
if index == len(histories) - 1:
checkpoint = history_item["checkpoint"] # pyright: ignore[reportArgumentType]
checkpoint = history_item[
"checkpoint"
] # pyright: ignore[reportArgumentType]
filebar.desc = f"Checkpoint Progress: {checkpoint}"
break
try:
history_date = datetime.datetime.strptime(
history_item["datetime"].replace(" +0000 UTC", ""), # pyright: ignore[reportArgumentType]
history_item["datetime"].replace(
" +0000 UTC", ""
), # pyright: ignore[reportArgumentType]
"%Y-%m-%dT%H:%M:%SZ",
).date()
except ValueError:
@@ -177,8 +180,10 @@ def pullPolicyExechistories(
def getPolicyInfo(api: AirlockAPIWrapper, policy, type, days):
import airlock_libs
executionhist_policy = pd.DataFrame()
exehist = pullPolicyExechistories(api, policy, type, days, True)
exehist = airlock_libs.pull_policy_exec_histories(api, policy.name, str(type), days)
if exehist is not None:
data = json.loads(exehist)
executionhist_policy = pd.DataFrame(data["response"]["exechistories"])
+20 -7
View File
@@ -49,9 +49,11 @@ def configure_keyring_backend():
system = platform.system()
if system == "Windows":
import keyring.backends.Windows
keyring.set_keyring(keyring.backends.Windows.WinVaultKeyring())
elif system == "Linux":
import keyring.backends.kwallet
keyring.set_keyring(keyring.backends.kwallet.DBusKeyring())
else:
raise EnvironmentError(f"Unsupported OS: {system}")
@@ -68,8 +70,9 @@ def store_api_key(service: str, username: str, api_key: str, password: str):
b64 = base64.b64encode(blob).decode()
keyring.set_password(service, username, b64)
logger.debug(f"API key for service '{service}' and user '{username}' stored successfully.")
logger.debug(
f"API key for service '{service}' and user '{username}' stored successfully."
)
print("\n✅ API key stored securely.")
print("The program will now exit. Press Enter to continue...")
@@ -124,7 +127,9 @@ def getAPI(USERNAME, SERVICE_NAME):
if api_key_exists(SERVICE_NAME, USERNAME):
for attempt in range(1, 4):
password = getpass(f"Attempt {attempt}/3 - Enter password to unlock your API key: ")
password = getpass(
f"Attempt {attempt}/3 - Enter password to unlock your API key: "
)
try:
apikey = retrieve_api_key(SERVICE_NAME, USERNAME, password)
logging.debug("API key successfully retrieved.")
@@ -134,9 +139,15 @@ def getAPI(USERNAME, SERVICE_NAME):
logging.error("Failed to retrieve API key after 3 incorrect attempts.")
raise ValueError("Failed to retrieve API key after 3 incorrect attempts.")
else:
logging.warning(f"No API key found for user '{USERNAME}' in service '{SERVICE_NAME}'.")
api_key = getpass(f"No API key found. Please enter your API key for '{SERVICE_NAME}': ").strip()
print("Please exit and relaunch program after saving your credential to avoid errors")
logging.warning(
f"No API key found for user '{USERNAME}' in service '{SERVICE_NAME}'."
)
api_key = getpass(
f"No API key found. Please enter your API key for '{SERVICE_NAME}': "
).strip()
print(
"Please exit and relaunch program after saving your credential to avoid errors"
)
while True:
password = getpass("Create a password to encrypt your API key: ")
@@ -155,7 +166,9 @@ def getAPI(USERNAME, SERVICE_NAME):
logging.error(f"Failed to store API key: {e}")
break
else:
logging.warning("Password does not meet complexity requirements. Try again.")
logging.warning(
"Password does not meet complexity requirements. Try again."
)
class APIKeyManager:
+23 -14
View File
@@ -29,14 +29,15 @@ PROTECTED_KEYS = [
"PATH_EXCLUSION_CONST",
"MIN_FILES_FOR_PATH",
"VT_THREAT_TOLERANCE",
"POLICY_MAP_ENF_AUD"
"POLICY_MAP_ENF_AUD",
]
_protected_config = {}
def get_system_config_path() -> Path:
# Check inside bundled EXE directory first
bundled_dir = Path(getattr(sys, '_MEIPASS', ''))
bundled_dir = Path(getattr(sys, "_MEIPASS", ""))
bundled_path = bundled_dir / "system_config.json"
if bundled_path.exists():
return bundled_path
@@ -44,6 +45,7 @@ def get_system_config_path() -> Path:
# Fallback to external location
return Path(__file__).parent.parent / "system_config.json"
def load_protected_config() -> dict:
global _protected_config
try:
@@ -52,19 +54,20 @@ def load_protected_config() -> dict:
except FileNotFoundError:
logging.warning("⚠️ system_config.json not found. Using built-in defaults.")
system_config = {
"APPNAME": "AirlockTools",
"APPNAME": "Loxide",
"PATH_EXCLUSION_CONST": 4,
"MIN_FILES_FOR_PATH": 4,
"VT_THREAT_TOLERANCE": 4,
"POLICY_MAP_ENF_AUD": {
"enforced_id": "audit_id"
}
"POLICY_MAP_ENF_AUD": {"enforced_id": "audit_id"},
}
_protected_config = {key: system_config[key] for key in PROTECTED_KEYS}
return _protected_config
def get_protected_value(key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None) -> Optional[T]:
def get_protected_value(
key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None
) -> Optional[T]:
value = _protected_config.get(key)
if value is None:
logging.warning(f"Protected config key '{key}' not found.")
@@ -74,9 +77,12 @@ def get_protected_value(key: str, cast_type: Callable[[str], T] = str, default:
value = value.strip("'\"")
return cast_type(value)
except (ValueError, TypeError):
logging.warning(f"Invalid value for protected key '{key}': {value}. Expected type {cast_type.__name__}.")
logging.warning(
f"Invalid value for protected key '{key}': {value}. Expected type {cast_type.__name__}."
)
return default
def get_protected_json(key: str, default: str = "{}") -> dict:
raw = _protected_config.get(key, default)
if isinstance(raw, dict):
@@ -85,28 +91,29 @@ def get_protected_json(key: str, default: str = "{}") -> dict:
return json.loads(raw)
except json.JSONDecodeError:
try:
escaped = raw.encode('unicode_escape').decode('utf-8')
escaped = raw.encode("unicode_escape").decode("utf-8")
return json.loads(escaped)
except Exception as e:
logging.error(f"Failed to parse protected JSON key '{key}': {e}")
return json.loads(default)
def load_env_json(key: str, default: str):
raw = os.getenv(key, default)
try:
return json.loads(raw)
except json.JSONDecodeError:
try:
escaped = raw.encode('unicode_escape').decode('utf-8')
escaped = raw.encode("unicode_escape").decode("utf-8")
return json.loads(escaped)
except Exception as e:
logging.error(f"Failed to parse {key}: {e}")
return json.loads(default)
def load_env(key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None) -> Optional[T]:
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.
@@ -126,5 +133,7 @@ def load_env(key: str, cast_type: Callable[[str], T] = str, default: Optional[T]
value = value.strip("'\"") # Strip surrounding quotes
return cast_type(value)
except (ValueError, TypeError):
logger.warning(f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}.")
logger.warning(
f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}."
)
return default
+51 -32
View File
@@ -21,9 +21,12 @@ from utils.utils import colorText, get_sanitized_input
logger = logging.getLogger(__name__)
class Selector:
@staticmethod
def _get_sorted_items(items: List[Any], label_func: Callable[[Any], str]) -> List[Any]:
def _get_sorted_items(
items: List[Any], label_func: Callable[[Any], str]
) -> List[Any]:
return sorted(items, key=lambda item: label_func(item).lower())
@staticmethod
@@ -31,7 +34,7 @@ class Selector:
items: List[Any],
label_func: Callable[[Any], str],
num_columns: int = 4,
header: str = "Available Choices:"
header: str = "Available Choices:",
) -> None:
# Force single column if items are DataFrame rows
@@ -51,9 +54,7 @@ class Selector:
@staticmethod
def _display_selected_items(
selected: List[Any],
label_func: Callable[[Any], str],
num_columns: int = 4
selected: List[Any], label_func: Callable[[Any], str], num_columns: int = 4
) -> None:
print(colorText("\nCurrent selections:", "cyan"))
if not selected:
@@ -92,7 +93,7 @@ class Selector:
allow_multiple: bool = False,
prompt_each: bool = False,
header: str = "Available Choices:",
num_columns: int = 4
num_columns: int = 4,
) -> Union[Optional[Any], List[Any]]:
if not items:
logger.warning("No items available for selection.")
@@ -104,9 +105,19 @@ class Selector:
if allow_multiple:
while True:
Selector._display_choices(remaining_items, label_func, num_columns=num_columns, header=header)
Selector._display_selected_items(selected, label_func, num_columns=num_columns)
choice = get_sanitized_input("Select item(s) by number (e.g. 1,3-5), R to reset, Q to finish: ").strip().lower()
Selector._display_choices(
remaining_items, label_func, num_columns=num_columns, header=header
)
Selector._display_selected_items(
selected, label_func, num_columns=num_columns
)
choice = (
get_sanitized_input(
"Select item(s) by number (e.g. 1,3-5), R to reset, Q to finish: "
)
.strip()
.lower()
)
if choice == "q":
break
elif choice == "r":
@@ -125,10 +136,14 @@ class Selector:
logger.info(f"Selected: {label_func(item)}")
else:
logger.warning("Item already selected.")
remaining_items = [item for item in remaining_items if item not in newly_selected]
remaining_items = [
item for item in remaining_items if item not in newly_selected
]
return selected if selected else None
else:
Selector._display_choices(full_sorted_items, label_func, num_columns=num_columns, header=header)
Selector._display_choices(
full_sorted_items, label_func, num_columns=num_columns, header=header
)
try:
choice = int(get_sanitized_input("Select one item by number: "))
if 1 <= choice <= len(full_sorted_items):
@@ -145,9 +160,14 @@ class Selector:
def select_with_mode(
items: List[Any],
label_func: Callable[[Any], str],
header: str = "Available Choices:"
header: str = "Available Choices:",
) -> List[Any]:
print(colorText("Choose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):", "white"))
print(
colorText(
"Choose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):",
"white",
)
)
mode = get_sanitized_input("").strip().lower()
if mode == "a":
return items
@@ -156,7 +176,7 @@ class Selector:
label_func=label_func,
allow_multiple=True,
prompt_each=False,
header=header
header=header,
)
if not selected:
return items
@@ -172,44 +192,38 @@ class Selector:
@staticmethod
def select_objects(
objects: List[Any],
allow_multiple: bool = False,
prompt_each: bool = False
objects: List[Any], allow_multiple: bool = False, prompt_each: bool = False
) -> Union[Optional[Any], List[Any]]:
return Selector._select_from_list(
objects,
label_func=lambda obj: getattr(obj, "name", str(obj)),
allow_multiple=allow_multiple,
prompt_each=prompt_each,
header="Available Objects:"
header="Available Objects:",
)
@staticmethod
def select_string(
options: List[str],
allow_multiple: bool = False,
prompt_each: bool = False
options: List[str], allow_multiple: bool = False, prompt_each: bool = False
) -> Union[Optional[str], List[str]]:
return Selector._select_from_list(
options,
label_func=str,
allow_multiple=allow_multiple,
prompt_each=prompt_each,
header="Available Options:"
header="Available Options:",
)
@staticmethod
def select_int(
options: List[int],
allow_multiple: bool = False,
prompt_each: bool = False
options: List[int], allow_multiple: bool = False, prompt_each: bool = False
) -> Union[Optional[int], List[int]]:
return Selector._select_from_list(
options,
label_func=lambda x: str(x),
allow_multiple=allow_multiple,
prompt_each=prompt_each,
header="Available Integers:"
header="Available Integers:",
)
@staticmethod
@@ -217,7 +231,7 @@ class Selector:
prompt: str,
value_type: type = int,
valid_range: Optional[tuple] = None,
allow_quit: bool = False
allow_quit: bool = False,
) -> Optional[Any]:
while True:
user_input = get_sanitized_input(prompt).strip().lower()
@@ -255,7 +269,7 @@ class Selector:
columns: Optional[List[str]] = None,
allow_multiple: bool = False,
prompt_each: bool = False,
header: str = "Available Rows:"
header: str = "Available Rows:",
) -> List[pd.Series]:
if df.empty:
print("DataFrame is empty.")
@@ -272,7 +286,7 @@ class Selector:
label_func=label_func,
allow_multiple=allow_multiple,
prompt_each=prompt_each,
header=header
header=header,
)
if isinstance(result, pd.Series):
@@ -286,7 +300,7 @@ class Selector:
def select_dataframe_with_mode(
df: pd.DataFrame,
columns: Optional[List[str]] = None,
header: str = "Available Rows:"
header: str = "Available Rows:",
) -> List[pd.Series]:
if df.empty:
print("⚠️ DataFrame is empty.")
@@ -305,7 +319,12 @@ class Selector:
print(f"{i}: {label_func(row)}")
# Prompt for mode once
print(colorText("\nChoose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):", "white"))
print(
colorText(
"\nChoose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):",
"white",
)
)
mode = get_sanitized_input("").strip().lower()
if mode == "a":
@@ -317,7 +336,7 @@ class Selector:
label_func=label_func,
allow_multiple=True,
prompt_each=False,
header=header
header=header,
)
if not selected:
+28 -24
View File
@@ -30,16 +30,16 @@ from utils.configmanager import PROTECTED_KEYS, load_protected_config
def get_base_directory() -> Path:
system = platform.system()
home = Path.home()
if system == 'Windows':
return Path(os.getenv('APPDATA', home / 'AppData' / 'Roaming')) / "AirlockTools"
elif system == 'Darwin':
return home / 'Library' / 'Application Support' / "AirlockTools"
if system == "Windows":
return Path(os.getenv("APPDATA", home / "AppData" / "Roaming")) / "Loxide"
elif system == "Darwin":
return home / "Library" / "Application Support" / "Loxide"
else:
return home / '.local' / 'share' / "AirlockTools"
return home / ".local" / "share" / "Loxide"
def configure_logging(log_dir: Path, log_level: str = "DEBUG"):
log_file = log_dir / "airlocktools.log"
log_file = log_dir / "Loxide.log"
config = {
"version": 1, # Required key for dictConfig format version
@@ -82,7 +82,7 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"):
try:
config["handlers"]["eventlog"] = {
"class": "logging.handlers.NTEventLogHandler",
"appname": "AirlockTools", # Event log source name
"appname": "Loxide", # Event log source name
"level": "CRITICAL", # Only log critical errors
"formatter": "simple", # Use simple format
}
@@ -96,7 +96,9 @@ def configure_logging(log_dir: Path, log_level: str = "DEBUG"):
def get_system_config_path() -> Path:
base_path = Path(getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(__file__))))
base_path = Path(
getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
)
return base_path.parent / "system_config.json"
@@ -108,45 +110,45 @@ def load_system_config() -> dict:
except FileNotFoundError:
logging.warning("⚠️ system_config.json not found. Using built-in defaults.")
return {
"APPNAME": "AirlockTools",
"APPNAME": "Loxide",
"LOG_LEVEL": "DEBUG",
"PATH_EXCLUSION_CONST": 4,
"MIN_FILES_FOR_PATH": 4,
"VT_THREAT_TOLERANCE": 4,
"POLICY_MAP_ENF_AUD": {
"enforced_id": "audit_id"
}
"POLICY_MAP_ENF_AUD": {"enforced_id": "audit_id"},
}
def load_user_config(config_dir: Path) -> dict:
user_config_path = config_dir / "user_config.json"
if not user_config_path.exists():
default_user_config = {
"URL": "",
"LOG_LEVEL": "INFO"
}
default_user_config = {"URL": "", "LOG_LEVEL": "INFO"}
with open(user_config_path, "w") as f:
json.dump(default_user_config, f, indent=4)
logging.debug(f"Created user config at {user_config_path}")
with open(user_config_path, "r") as f:
return json.load(f)
def write_config_to_env(config: dict, env_path: Path):
for key, value in config.items():
if key in PROTECTED_KEYS:
continue # Skip protected keys
try:
serialized = json.dumps(value) if isinstance(value, (list, dict)) else str(value)
serialized = (
json.dumps(value) if isinstance(value, (list, dict)) else str(value)
)
set_key(env_path, key, serialized)
except Exception as e:
logging.warning(f"Failed to write {key} to .env: {e}")
def setup():
base_dir = get_base_directory()
dirs = {
'config': base_dir / 'config',
'cache': base_dir / 'cache',
'logs': base_dir / 'logs',
"config": base_dir / "config",
"cache": base_dir / "cache",
"logs": base_dir / "logs",
}
for name, path in dirs.items():
@@ -154,7 +156,7 @@ def setup():
logging.debug(f"{name.capitalize()} directory ensured at: {path}")
system_config = load_system_config()
configure_logging(dirs['logs'], system_config.get("LOG_LEVEL", "DEBUG"))
configure_logging(dirs["logs"], system_config.get("LOG_LEVEL", "DEBUG"))
env_path = base_dir / ".env"
if not env_path.exists():
@@ -171,7 +173,7 @@ def setup():
"Approved": [],
"Needs_Review": ["Review_First", "Review_Second", "HTML"],
"Preflight": ["HTML"],
"Archived": []
"Archived": [],
}
for folder_name, subfolders in folders_structure.items():
@@ -183,7 +185,7 @@ def setup():
subfolder_path.mkdir(parents=True, exist_ok=True)
logging.debug(f" └─ '{subfolder}' subfolder created at: {subfolder_path}")
user_config = load_user_config(dirs['config'])
user_config = load_user_config(dirs["config"])
merged_config = {**system_config, **user_config}
protected_config = load_protected_config()
@@ -194,7 +196,9 @@ def setup():
if not url:
url = os.getenv("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
set_key(env_path, "URL", url)
os.environ["URL"] = url
View File
+17 -22
View File
@@ -58,7 +58,9 @@ def _persist_user_theme(theme_name: str) -> None:
config_dir.mkdir(parents=True, exist_ok=True)
if not user_config_path.exists():
# minimal default like your load_user_config does
user_config_path.write_text('{"URL": "", "LOG_LEVEL": "INFO"}\n', encoding="utf-8")
user_config_path.write_text(
'{"URL": "", "LOG_LEVEL": "INFO"}\n', encoding="utf-8"
)
# load existing user config
user_conf = load_user_config(config_dir)
@@ -86,8 +88,6 @@ def _persist_user_theme(theme_name: str) -> None:
logger.debug("Reloaded .env from %s", env_path)
# ---------------------------------------------------------------------------
# 1) SCREEN
# ---------------------------------------------------------------------------
@@ -138,7 +138,6 @@ class MainMenuScreen(Screen):
wd = os.getcwd()
self.working_dir = wd
def _make_buttons_for(self, tab_id: str) -> Vertical:
defs = self.BUTTON_DEFS.get(tab_id, [])
buttons = []
@@ -148,9 +147,6 @@ class MainMenuScreen(Screen):
buttons.append(btn)
return Vertical(*buttons)
def compose(self) -> ComposeResult:
yield Header(show_clock=True, icon="")
@@ -209,7 +205,6 @@ class MainMenuScreen(Screen):
if 0 <= new_index < len(buttons):
buttons[new_index].focus()
def switch_tab(self, tab_id: str) -> None:
self.current_tab = tab_id
content = self.query_one("#content", Vertical)
@@ -230,7 +225,9 @@ class MainMenuScreen(Screen):
layout.mount(policy_tree)
# Right: Details pane
details_pane = Static("Select a policy or device to view details", id="details-pane")
details_pane = Static(
"Select a policy or device to view details", id="details-pane"
)
details_pane.styles.width = "3fr"
layout.mount(details_pane)
@@ -240,7 +237,9 @@ class MainMenuScreen(Screen):
# Top-level policies
for _, policy in self.app.policies.iterrows():
if policy["parent"] == "global-policy-settings":
node = policy_tree.root.add(label=policy["name"], data=policy.to_dict())
node = policy_tree.root.add(
label=policy["name"], data=policy.to_dict()
)
node_map[policy["groupid"]] = node
# Child policies
@@ -259,7 +258,6 @@ class MainMenuScreen(Screen):
label = device["hostname"] # Keep tree clean
parent_node.add(label=label, data=device.to_dict())
elif tab_id == "settings":
# Create and mount the horizontal container
horizontal_container = Horizontal(id="settings_grid")
@@ -301,8 +299,9 @@ class MainMenuScreen(Screen):
details_pane.update(details)
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
def on_directory_tree_file_selected(
self, event: DirectoryTree.FileSelected
) -> None:
path = event.path
logger.debug("Directory file selected: %s", path)
try:
@@ -359,14 +358,10 @@ class MainMenuScreen(Screen):
self.app.exit()
# ---------------------------------------------------------------------------
# 2) APP
# ---------------------------------------------------------------------------
class AirlockTools(App):
class Loxide(App):
CSS = """
#logo {
width: 100%;
@@ -407,7 +402,6 @@ class AirlockTools(App):
screen.switch_tab("dir")
# ---------------------------------------------------------------------------
# 3) TERMINAL + LEGACY
# ---------------------------------------------------------------------------
@@ -422,6 +416,7 @@ def _restore_terminal_for_legacy() -> None:
if os.name == "nt":
try:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-11)
mode = ctypes.c_ulong()
@@ -447,7 +442,7 @@ def _run_legacy_job(func, args, kwargs) -> None:
# ---------------------------------------------------------------------------
# 4) PUBLIC ENTRYPOINT
# ---------------------------------------------------------------------------
def run_AirlockTools(api: AirlockAPIWrapper) -> None:
def run_Loxide(api: AirlockAPIWrapper) -> None:
global _PENDING_JOB
while True:
@@ -456,7 +451,7 @@ def run_AirlockTools(api: AirlockAPIWrapper) -> None:
dotenv.load_dotenv(dotenv_path=env_path, override=True)
_PENDING_JOB = None
app = AirlockTools(api)
app = Loxide(api)
try:
app.run()
@@ -486,4 +481,4 @@ def run_AirlockTools(api: AirlockAPIWrapper) -> None:
# ---------------------------------------------------------------------------
if __name__ == "__main__":
api = AirlockAPIWrapper()
run_AirlockTools(api)
run_Loxide(api)
+53 -18
View File
@@ -28,8 +28,6 @@ import pandas as pd
logger = logging.getLogger(__name__)
def import_to_dataframe(file_path: str) -> pd.DataFrame:
df = pd.DataFrame()
@@ -96,17 +94,17 @@ def choose_file(initial_directory=None, required_substring=None):
return file_path
def get_sanitized_input(prompt: str) -> str:
while True:
user_input = input(prompt)
if user_input.strip() == "":
return user_input # Allow blank lines
if re.match(r'^[a-zA-Z0-9_\- .]+$', user_input.strip()):
if re.match(r"^[a-zA-Z0-9_\- .]+$", user_input.strip()):
return user_input
else:
print("Invalid input. Only letters, numbers, underscores, spaces, hyphens, and periods are allowed.")
print(
"Invalid input. Only letters, numbers, underscores, spaces, hyphens, and periods are allowed."
)
def regulator(paths, case_insensitive=True):
@@ -119,6 +117,8 @@ def regulator(paths, case_insensitive=True):
pattern = "(?i)" + pattern # Add inline case-insensitive flag
print(f"Regulator is providing: {pattern}")
return pattern
def irtang():
print(
colorText(
@@ -149,6 +149,8 @@ def irtang():
"yellow",
)
)
def displayIntro():
print(
@@ -164,6 +166,8 @@ def displayIntro():
"cyan",
)
)
def welcome():
print(
colorText(
@@ -184,11 +188,21 @@ def welcome():
)
)
def section_header(title):
print(colorText("\n --------------------------------------------------------------------", "cyan"))
print(colorText(f" ------------- {title} -------------", "cyan"))
print(colorText(" --------------------------------------------------------------------", "cyan"))
def section_header(title):
print(
colorText(
"\n --------------------------------------------------------------------",
"cyan",
)
)
print(colorText(f" ------------- {title} -------------", "cyan"))
print(
colorText(
" --------------------------------------------------------------------",
"cyan",
)
)
def areYouSure():
@@ -348,7 +362,11 @@ def printDeviceEnforceChecklist():
"cyan",
)
)
print(colorText(" When complete, save the csv file to the directory 'approved'", "cyan"))
print(
colorText(
" When complete, save the csv file to the directory 'approved'", "cyan"
)
)
print(
colorText(
" Do the same process with the list of publishers forthe same directories",
@@ -357,20 +375,38 @@ def printDeviceEnforceChecklist():
)
print(colorText(" Preflight Lists will be generated", "cyan"))
print(colorText("5. Choose the destination policy and parent and child allow list", "cyan"))
print(
colorText(
"5. Choose the destination policy and parent and child allow list", "cyan"
)
)
print(colorText("6. Test ------------------------------------------------------", "cyan"))
print(
colorText(
"6. Test ------------------------------------------------------", "cyan"
)
)
print(colorText(" Print rather than apply selected data.", "cyan"))
print(colorText("7. Liftoff ------------------------------------------------------", "cyan"))
print(
colorText(
"7. Liftoff ------------------------------------------------------", "cyan"
)
)
print(
colorText(
" Apply path exclusions according to allowed and approved paths",
"cyan",
)
)
print(colorText(" Apply signed or attested hashes to Parent Allow List", "cyan"))
print(colorText(" Apply approved, but unsigned hashes to the Child Allow List", "cyan"))
print(
colorText(" Apply signed or attested hashes to Parent Allow List", "cyan")
)
print(
colorText(
" Apply approved, but unsigned hashes to the Child Allow List", "cyan"
)
)
print(
colorText(
@@ -523,7 +559,6 @@ def formatHTML(df, output_html_path=None, overwrite=True):
return styled_html
def open_directory(path):
system = platform.system()
@@ -542,4 +577,4 @@ def print_x_wide(items: list, width: int):
def clear_screen():
os.system('cls' if os.name == 'nt' else 'clear')
os.system("cls" if os.name == "nt" else "clear")