RustImplementation #23

Merged
mysticmomba merged 118 commits from RustImplementation into master 2025-11-04 18:13:24 -05:00
5 changed files with 240 additions and 126 deletions
Showing only changes of commit 629f6b510d - Show all commits
+50 -5
View File
@@ -16,6 +16,7 @@
import logging import logging
import os import os
import os.path import os.path
import re
from typing import List from typing import List
import dotenv import dotenv
@@ -26,11 +27,7 @@ from models.policy import Allowlist, Policy
from services.API import AirlockAPIWrapper from services.API import AirlockAPIWrapper
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, formatHTML, print_x_wide, regulator
colorText,
formatHTML,
regulator,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -105,6 +102,9 @@ def sortHashes(
for label, records in categories.items(): for label, records in categories.items():
if not records:
continue # Skip empty or falsy categories
csv_path = f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_{label}_executions.csv" csv_path = f"{working_dir}\\Needs_Review\\Review_First\\{selected_policies[0].name}_{label}_executions.csv"
html_path = f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{label}.html" html_path = f"{working_dir}\\Needs_Review\\HTML\\{selected_policies[0].name}_{label}.html"
@@ -369,3 +369,48 @@ def calculatePath(approved_hashes, path_exclusion_constant, split):
pathExclusions = pd.concat(processed_dfs, ignore_index=True) pathExclusions = pd.concat(processed_dfs, ignore_index=True)
return pathExclusions return pathExclusions
def testChange(selected_policies, destination_policy, destination_allowlist):
working_dir = load_env("WORKING_DIR")
logger.info("These path exclusions would be added to:")
logger.info(destination_policy)
pathexclusions = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv")
hashes = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv")
unique_combinations = pathexclusions[["longestcfp", "file_extension"]].drop_duplicates()
drive_letter_pattern = re.compile(r"^[a-zA-Z]:\\")
processed_paths = [
(path if drive_letter_pattern.match(path) else f"\\\\{path}") + f"\\**{ext}"
for path, ext in unique_combinations.itertuples(index=False, name=None)
]
for path in processed_paths:
logger.info(path)
print(colorText("These publishers would added", "yellow"))
processed_publishers = []
if os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"):
publishers = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv")
if publishers.empty:
print(colorText("The publishers list is empty.", "red"))
else:
processed_publishers = (
publishers[publishers["publisher"] != "Not Signed"]
["publisher"]
.drop_duplicates()
.tolist()
)
for publisher in processed_publishers:
print(publisher)
print(colorText("These hashes would be added to:", "yellow"))
print(destination_allowlist)
processed_hashes = hashes["sha256"].unique().tolist()
print_x_wide(processed_hashes, 3)
return processed_paths, processed_hashes, processed_publishers
+68 -28
View File
@@ -130,24 +130,16 @@ def findAgents(api, return_dataframe):
else: else:
logging.debug("User declined to export the DataFrame.") logging.debug("User declined to export the DataFrame.")
def collect_device_names() -> List[str]:
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 (Three times if you have a single device).\n", "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("Example:", "cyan"))
print(colorText("H00000", "cyan")) print(colorText("H00000\nUTN00000\ni-hSuperSecretServer\nu-hVenderBroke\n", "cyan"))
print(colorText("UTN00000", "cyan"))
print(colorText("i-hSuperSecretServer", "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()]
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:
@@ -158,49 +150,97 @@ def selectAgents(api: AirlockAPIWrapper) -> List[Agent]:
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
# Validate only non-empty lines
if valid_line_pattern.match(stripped_line): if valid_line_pattern.match(stripped_line):
device_input_lines.append(stripped_line) device_input_lines.append(stripped_line)
else: 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"))
device_names = [name for name in device_input_lines if name] return [name for name in device_input_lines if name]
def choose_match_type() -> bool:
print(colorText("Use exact match? (Y for exact, N for fuzzy):", "white"))
return get_sanitized_input("").strip().lower() in ["y", "yes"]
def match_agents(device_names: List[str], agents: List['Agent'], use_exact: bool) -> List['Agent']:
if use_exact:
return [
agent for agent in agents
if agent.hostname.lower() in [name.lower() for name in device_names]
]
else:
pattern = "|".join(map(re.escape, device_names))
regex = re.compile(pattern, re.IGNORECASE)
return [agent for agent in agents if regex.search(agent.hostname)]
def show_unmatched(device_names: List[str], matched_agents: List['Agent'], use_exact: bool):
if use_exact:
unmatched = [name for name in device_names if not any(agent.hostname.lower() == name.lower() for agent in matched_agents)]
else:
unmatched = [name for name in device_names if not any(re.search(re.escape(name), agent.hostname, re.IGNORECASE) for agent in matched_agents)]
if unmatched:
logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}")
print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow"))
def enrich_agents(agents: List['Agent'], policies: List['Policy']):
for agent in agents:
agent.enrich_with_policies(policies)
def selectAgents(api: 'AirlockAPIWrapper') -> List['Agent']:
device_names = collect_device_names()
if not device_names: 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 use_exact = choose_match_type()
pattern = "|".join(map(re.escape, device_names))
regex = re.compile(pattern, re.IGNORECASE)
# Fetch agents policies = [Policy(**row.to_dict()) for _, row in api.policy_find_all().iterrows()]
agents = [Agent(**row.to_dict()) for _, row in api.agent_find_all().iterrows()] agents = [Agent(**row.to_dict()) for _, row in api.agent_find_all().iterrows()]
matched_agents = [agent for agent in agents if regex.search(agent.hostname)] matched_agents = match_agents(device_names, agents, use_exact)
matched_agents.sort(key=lambda agent: agent.hostname.lower()) matched_agents.sort(key=lambda agent: agent.hostname.lower())
# Show unmatched show_unmatched(device_names, matched_agents, use_exact)
unmatched = [name for name in device_names if not any(regex.search(agent.hostname) for agent in agents)]
if unmatched:
logger.debug(f"⚠️ No matches for: {', '.join(unmatched)}")
print(colorText(f"⚠️ No matches for: {', '.join(unmatched)}", "yellow"))
if not matched_agents: if not matched_agents:
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: return []
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"))
logger.info("Matched agent hostnames:")
rows = (len(matched_agents) + 2) // 3 # 3 columns
for row in range(rows):
line = ""
for col in range(3):
idx = row + col * rows
if idx < len(matched_agents):
line += f"{matched_agents[idx].hostname:<30}"
logger.info(line)
# Enrich each agent using its class method matched_agents = Selector.select_with_mode(
for agent in matched_agents: matched_agents,
agent.enrich_with_policies(policies) label_func=lambda agent: agent.hostname,
header="Matched Devices:"
)
if not matched_agents:
logger.debug("❌ No matching devices remain after refinement.")
print(colorText("❌ No matching devices remain after refinement.", "red"))
return []
enrich_agents(matched_agents, policies)
return matched_agents return matched_agents
def moveAgentToRelatedPolicy( def moveAgentToRelatedPolicy(
api: AirlockAPIWrapper, api: AirlockAPIWrapper,
agent: Agent, agent: Agent,
+8 -52
View File
@@ -15,10 +15,8 @@
import logging import logging
import os import os
import re
import dotenv import dotenv
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
@@ -28,6 +26,7 @@ from flows.prepPolicy import (
selectAllowlists, selectAllowlists,
selectPolicies, selectPolicies,
sortHashes, sortHashes,
testChange,
) )
from flows.quietAgent import findQuietAgents from flows.quietAgent import findQuietAgents
from services.agenthandler import findAgents, moveAgentToRelatedPolicy, selectAgents from services.agenthandler import findAgents, moveAgentToRelatedPolicy, selectAgents
@@ -39,9 +38,9 @@ from utils.utils import (
colorText, colorText,
displayIntro, displayIntro,
get_sanitized_input, get_sanitized_input,
locked,
open_directory, open_directory,
printEnforceChecklist, printEnforceChecklist,
locked
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -107,7 +106,6 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
processed_paths = [] processed_paths = []
processed_hashes = [] processed_hashes = []
processed_publishers = [] processed_publishers = []
tested = False
working_dir = load_env("WORKING_DIR") working_dir = load_env("WORKING_DIR")
while True: while True:
@@ -154,47 +152,7 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
and destination_policy and destination_policy
and destination_allowlist and destination_allowlist
): ):
print(colorText("These path exclusions would be added to:", "yellow")) processed_paths, processed_hashes, processed_publishers = testChange(selected_policies, destination_policy, destination_allowlist)
print(destination_policy)
pathexclusions = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_paths.csv")
hashes = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_hashes.csv")
unique_combinations = pathexclusions[["longestcfp", "file_extension"]].drop_duplicates()
drive_letter_pattern = re.compile(r"^[a-zA-Z]:\\")
processed_paths = [
(path if drive_letter_pattern.match(path) else f"\\\\{path}") + f"\\**{ext}"
for path, ext in unique_combinations.itertuples(index=False, name=None)
]
for path in processed_paths:
print(path)
print(colorText("These publishers would added", "yellow"))
if os.path.exists(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv"):
publishers = pd.read_csv(f"{working_dir}\\Preflight\\{selected_policies[0].name}_approved_publishers.csv")
if publishers.empty:
print(colorText("The publishers list is empty.", "red"))
else:
processed_publishers = (
publishers[publishers["publisher"] != "Not Signed"]
["publisher"]
.drop_duplicates()
.tolist()
)
for publisher in processed_publishers:
print(publisher)
print(colorText("These hashes would be added to:", "yellow"))
print(destination_allowlist)
processed_hashes = hashes["sha256"].unique().tolist()
print_three_wide(processed_hashes)
if processed_paths and processed_hashes:
tested = True
else: else:
# Log which condition(s) failed # Log which condition(s) failed
missing_items = [] missing_items = []
@@ -215,7 +173,9 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
areYouSure() areYouSure()
confirmation = get_sanitized_input("Type 'I AGREE' to continue: ") confirmation = get_sanitized_input("Type 'I AGREE' to continue: ")
if ( if (
tested processed_paths
and processed_hashes
and processed_publishers
and destination_policy and destination_policy
and destination_allowlist and destination_allowlist
and confirmation.strip() == "I AGREE" and confirmation.strip() == "I AGREE"
@@ -230,8 +190,8 @@ def menu_policy_enforce(api: AirlockAPIWrapper): #TODO Need to clean up 6 and 7
else: else:
logger.error("Confirmation block failed. Reasons:") logger.error("Confirmation block failed. Reasons:")
if not tested: if not processed_publishers or processed_hashes or processed_paths:
logger.error(" - Preflight checks were not completed successfully (`tested` is False).") logger.error(" - Test not performed.")
if not destination_policy: if not destination_policy:
logger.error(" - `destination_policy` is missing or invalid.") logger.error(" - `destination_policy` is missing or invalid.")
if not destination_allowlist: if not destination_allowlist:
@@ -321,7 +281,3 @@ def menu_settings():
else: else:
print("Invalid choice. Please try again.") print("Invalid choice. Please try again.")
def print_three_wide(items):
for i in range(0, len(items), 3):
row = items[i:i+3]
print(" | ".join(row))
+103 -34
View File
@@ -1,28 +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 colorText, get_sanitized_input
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Selector: class Selector:
@staticmethod
def _get_sorted_items(items: List[Any], label_func: Callable[[Any], str]) -> List[Any]:
return sorted(items, key=lambda item: label_func(item).lower())
@staticmethod @staticmethod
def _display_choices( def _display_choices(
items: List[Any], items: List[Any],
@@ -30,18 +18,54 @@ class Selector:
num_columns: int = 4, num_columns: int = 4,
header: str = "Available Choices:" header: str = "Available Choices:"
) -> None: ) -> None:
sorted_items = sorted(items, key=lambda item: label_func(item).lower()) rows = (len(items) + num_columns - 1) // num_columns
rows = (len(sorted_items) + num_columns - 1) // num_columns
print(f"\n{header}") print(f"\n{header}")
for row in range(rows): for row in range(rows):
line = "" line = ""
for col in range(num_columns): for col in range(num_columns):
idx = row + col * rows idx = row + col * rows
if idx < len(sorted_items): if idx < len(items):
label = label_func(sorted_items[idx]) label = label_func(items[idx])
line += f"{idx + 1}: {label:<30}" line += f"{idx + 1}: {label:<30}"
print(line) print(line)
@staticmethod
def _display_selected_items(
selected: List[Any],
label_func: Callable[[Any], str],
num_columns: int = 4
) -> None:
print(colorText("\nCurrent selections:", "cyan"))
if not selected:
print(" (none)")
return
sorted_selected = sorted(selected, key=lambda item: label_func(item).lower())
rows = (len(sorted_selected) + num_columns - 1) // num_columns
for row in range(rows):
line = ""
for col in range(num_columns):
idx = row + col * rows
if idx < len(sorted_selected):
label = label_func(sorted_selected[idx])
line += f"{label:<30}"
print(line)
@staticmethod
def _parse_selection_input(input_str: str, max_index: int) -> List[int]:
selections = []
for part in input_str.split(","):
part = part.strip()
if "-" in part:
try:
start, end = map(int, part.split("-"))
selections.extend(range(start, end + 1))
except ValueError:
continue
elif part.isdigit():
selections.append(int(part))
return [i for i in selections if 1 <= i <= max_index]
@staticmethod @staticmethod
def _select_from_list( def _select_from_list(
items: List[Any], items: List[Any],
@@ -54,35 +78,48 @@ class Selector:
logger.warning("No items available for selection.") logger.warning("No items available for selection.")
return None return None
Selector._display_choices(items, label_func, header=header) full_sorted_items = Selector._get_sorted_items(items, label_func)
sorted_items = sorted(items, key=lambda item: label_func(item).lower()) remaining_items = full_sorted_items.copy()
selected = [] selected = []
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() Selector._display_choices(remaining_items, label_func, header=header)
Selector._display_selected_items(selected, label_func)
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": if choice == "q":
break break
try: elif choice == "r":
index = int(choice) selected.clear()
if 1 <= index <= len(sorted_items): remaining_items = full_sorted_items.copy()
item = sorted_items[index - 1] print(colorText("🔄 Selections reset.", "yellow"))
continue
indices = Selector._parse_selection_input(choice, len(remaining_items))
newly_selected = []
for index in indices:
item = remaining_items[index - 1]
if item not in selected: if item not in selected:
selected.append(item) selected.append(item)
newly_selected.append(item)
if prompt_each: if prompt_each:
logger.info(f"Selected: {label_func(item)}") logger.info(f"Selected: {label_func(item)}")
else: else:
logger.warning("Item already selected.") logger.warning("Item already selected.")
else:
logger.warning("Selection out of range. Try again.") # Remove newly selected items from remaining list
except ValueError: remaining_items = [item for item in remaining_items if item not in newly_selected]
logger.warning("Invalid input. Enter a number or 'Q' to quit.")
return selected if selected else None return selected if selected else None
else: else:
Selector._display_choices(full_sorted_items, label_func, header=header)
try: try:
choice = int(get_sanitized_input("Select one item by number: ")) choice = int(get_sanitized_input("Select one item by number: "))
if 1 <= choice <= len(sorted_items): if 1 <= choice <= len(full_sorted_items):
selected_item = sorted_items[choice - 1] selected_item = full_sorted_items[choice - 1]
logger.info(f"Selected: {label_func(selected_item)}") logger.info(f"Selected: {label_func(selected_item)}")
return selected_item return selected_item
else: else:
@@ -91,6 +128,38 @@ class Selector:
logger.warning("Invalid input.") logger.warning("Invalid input.")
return None return None
@staticmethod
def select_with_mode(
items: List[Any],
label_func: Callable[[Any], str],
header: str = "Available Choices:"
) -> List[Any]:
print(colorText("Choose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):", "white"))
mode = get_sanitized_input("").strip().lower()
if mode == "a":
return items
selected = Selector._select_from_list(
items,
label_func=label_func,
allow_multiple=True,
prompt_each=False,
header=header
)
if not selected:
return items
if mode == "i":
print(colorText(f"✅ Included {len(selected)} item(s).", "green"))
return selected
elif mode == "e":
print(colorText(f"🚫 Excluded {len(selected)} item(s).", "yellow"))
return [item for item in items if item not in selected]
else:
print(colorText("⚠️ Invalid mode. Returning all items.", "yellow"))
return items
@staticmethod @staticmethod
def select_objects( def select_objects(
objects: List[Any], objects: List[Any],
+4
View File
@@ -615,3 +615,7 @@ def open_directory(path):
raise OSError(f"Unsupported operating system: {system}") raise OSError(f"Unsupported operating system: {system}")
def print_x_wide(items: list, width: int):
for i in range(0, len(items), width):
row = items[i:i+width]
print(" | ".join(row))