Files
AirlockTools/flows/prepPolicy.py
T

356 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 asyncio
import logging
import os
import os.path
from typing import List, Optional
import dotenv
import pandas as pd
from models.execution import ExecutionHistoryRecord, Hash
from models.policy import Allowlist, Policy
from services.API import AirlockAPIWrapper
from services.TaskQueue import AsyncTaskQueue, run_sync_task_in_thread
from utils.configmanager import get_protected_value, load_env, load_env_json
from utils.Selector import Selector
from utils.utils import (
colorText,
formatHTML,
regulator,
)
logger = logging.getLogger(__name__)
dotenv.load_dotenv()
async def selectPolicies(api: AirlockAPIWrapper, allow_multiple=True) -> List[Policy]:
df = await api.policy_find_all()
policies = [Policy(**row.to_dict()) for _, row in df.iterrows()]
logger.debug("Prompting for Policies")
print(colorText("Please select policy/policies", "white"))
selected = await Selector.select_objects(policies, allow_multiple, prompt_each=True)
if selected is None:
return []
logger.debug("Returning selected policies")
return selected if isinstance(selected, list) else [selected]
async def selectAllowlists(api: AirlockAPIWrapper, policy="all", allow_multiple=True) -> List[Allowlist]:
if policy == "all":
df = await api.allowlist_find_all()
else:
df = await api.policy_list_allowlists(policy[0].groupid) # pyright: ignore[reportAttributeAccessIssue]
allowlists = [Allowlist(**row.to_dict()) for _, row in df.iterrows()]
logger.debug("Prompting for Allowlist(s)")
print(colorText("Please select allowlist(s)", "white"))
selected = await Selector.select_objects(allowlists, allow_multiple, prompt_each=True)
if selected is None:
return []
logger.debug(f"Returning {selected}")
return selected if isinstance(selected, list) else [selected]
async def sortHashes(
api: AirlockAPIWrapper,
queue: AsyncTaskQueue,
selected_policies: List[Policy],
type=[1, 2, 6, 7],
history_days: Optional[int] = None
):
if history_days is None:
history_days = await Selector.select_value(
prompt="Enter how many days of history to pull (1150): ",
value_type=int,
valid_range=(1, 150),
)
logger.debug(f"{history_days} day selected for history")
if history_days is None:
logging.warning("No history range selected. Aborting.")
return
executions = []
hashes = []
working_dir = await load_env("WORKING_DIR")
# Pull execution histories for each policy
policy_executions = await ExecutionHistoryRecord.from_policies(
api, selected_policies, type_=type, history_days=history_days
)
logger.debug(f"Policy_executions is {policy_executions}")
executions.extend(policy_executions)
logger.debug(f"Executions contains {executions}")
if executions:
sha_list = [record.sha256 for record in executions]
hash_df = await api.hash_query(sha_list)
hashes = [
Hash(sha256=row["sha256"], **row["data"])
for _, row in hash_df.iterrows()
]
if hashes:
unique_hashes = Hash.deduplicate(hashes)
needs_review, approved, unapproved = await Hash.categorize_hashes(hashes=unique_hashes)
categories = {
"needs_review": needs_review,
"approved": approved,
"unapproved": unapproved,
}
for label, category in categories.items():
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"
df = await ExecutionHistoryRecord.enrich_with_hashes(executions, category)
asyncio.create_task(queue.enqueue(
f"DF TO CSV {selected_policies[0].name}_{label}",
run_sync_task_in_thread,
df.to_csv,
csv_path,
index=False,
encoding='utf-8'
))
asyncio.create_task(queue.enqueue(
f"DF TO HTML {selected_policies[0].name}_{label}",
run_sync_task_in_thread,
formatHTML,
df,
html_path
))
print("sortHashes completed successfully.")
async def buildPathsandPublishers(queue: AsyncTaskQueue, split):
working_dir = await load_env("WORKING_DIR")
path1 = f"{working_dir}/Approved/approved_executions.csv"
path2 = f"{working_dir}/Approved/needs_review_executions.csv"
df1 = await asyncio.to_thread(pd.read_csv, path1) if os.path.exists(path1) else pd.DataFrame()
if df1.empty:
logger.warning(f"File not found or empty: {path1}")
df2 = await asyncio.to_thread(pd.read_csv, path2) if os.path.exists(path2) else pd.DataFrame()
if df2.empty:
logger.warning(f"File not found or empty: {path2}")
if df1.empty and df2.empty:
logger.warning("Both DataFrames are empty. Skipping sort.")
all_approved_hashes = pd.DataFrame()
logger.debug(all_approved_hashes.head())
return
all_approved_hashes = pd.concat([df1, df2], ignore_index=True)
if "filename_exec" in all_approved_hashes.columns:
all_approved_hashes = all_approved_hashes.sort_values(by="filename_exec")
else:
logger.warning("'filename_exec' column not found in concatenated DataFrame.")
primary_path_exclusions = await calculatePath(all_approved_hashes, split)
remaining_hashes = all_approved_hashes[~all_approved_hashes["sha256"].isin(primary_path_exclusions["sha256"])]
secondary_path_exclusions = await calculatePath(remaining_hashes, split)
remaining_hashes = remaining_hashes[~remaining_hashes["sha256"].isin(secondary_path_exclusions["sha256"])]
dataframes = {
"primary_Paths": primary_path_exclusions,
"secondary_Paths": secondary_path_exclusions,
"hashes_to_add": remaining_hashes,
}
for name, df in dataframes.items():
logger.debug(f"DataFrame headers for {name}: {list(df.columns)}")
sort_column = "filename_exec" if name == "hashes_to_add" else "longestcfp"
df.sort_values(by=sort_column, inplace=True)
csv_path = f"{working_dir}/Needs_Review/Review_Second/{name}.csv"
html_path = f"{working_dir}/Needs_Review/HTML/{name}.html"
await asyncio.to_thread(df.to_csv, csv_path, index=False)
await asyncio.to_thread(formatHTML, df, html_path)
publist = all_approved_hashes[all_approved_hashes["publisher_hash"] != "Not Signed"].drop_duplicates(subset=["publisher_hash"])
pattern = regulator(await load_env_json("BAD_PUBLISHERS", "[]"))
publist = publist[~publist["publisher_hash"].str.contains(pattern, na=False)]
publist = publist[["publisher_hash"]]
publist.sort_values(by="publisher_hash", inplace=True)
pub_csv_path = f"{working_dir}/Needs_Review/Review_Second/publishers.csv"
await asyncio.to_thread(publist.to_csv, pub_csv_path, index=False)
print("buildPathsandPublishers completed asynchronously.")
async def buildPreflights():
working_dir = await load_env("WORKING_DIR")
hash_path = f"{working_dir}/Approved/hashes_to_add.csv"
path1 = f"{working_dir}/Approved/primary_Paths.csv"
path2 = f"{working_dir}/Approved/secondary_Paths.csv"
publishers_path = f"{working_dir}/Approved/publishers.csv"
df1 = await asyncio.to_thread(pd.read_csv, path1) if os.path.exists(path1) else pd.DataFrame()
if df1.empty:
logger.warning(f"File not found or empty: {path1}")
df2 = await asyncio.to_thread(pd.read_csv, path2) if os.path.exists(path2) else pd.DataFrame()
if df2.empty:
logger.warning(f"File not found or empty: {path2}")
approved_hashes = await asyncio.to_thread(pd.read_csv, hash_path) if os.path.exists(hash_path) else pd.DataFrame()
if approved_hashes.empty:
logger.warning(f"File not found or empty: {hash_path}")
approved_publishers = await asyncio.to_thread(pd.read_csv, publishers_path) if os.path.exists(publishers_path) else pd.DataFrame()
if approved_publishers.empty:
logger.warning(f"File not found or empty: {publishers_path}")
approved_paths = pd.concat([df1, df2], ignore_index=True) if not (df1.empty and df2.empty) else pd.DataFrame()
dataframes = {
"approved_paths": approved_paths,
"approved_hashes": approved_hashes,
"approved_publishers": approved_publishers
}
for name, df in dataframes.items():
logger.debug(f"DataFrame headers for {name}: {list(df.columns)}")
if name == "approved_paths":
df.sort_values(by="longestcfp", inplace=True)
elif name == "approved_hashes":
df.sort_values(by="filename_exec", inplace=True)
elif name == "approved_publishers":
df.sort_values(by="publisher_hash", inplace=True)
csv_path = f"{working_dir}/Preflight/{name}.csv"
html_path = f"{working_dir}/Preflight/HTML/{name}.html"
await asyncio.to_thread(df.to_csv, csv_path, index=False)
await asyncio.to_thread(formatHTML, df, html_path)
print("buildPreflights completed asynchronously.")
async def splitFilepathsGrouped(df, col="filename"):
path_task = asyncio.create_task(get_protected_value("PATH_EXCLUSION_CONST", int))
min_files_task = asyncio.create_task(get_protected_value("MIN_FILES_FOR_PATH", int))
path_exclusion_constant = await path_task
min_files_for_path = await min_files_task
def clean_split(path):
if not isinstance(path, (str, bytes, os.PathLike)):
return []
parts = os.path.normpath(path).split(os.sep)
return [p for p in parts if p]
non_string_entries = df[~df[col].apply(lambda x: isinstance(x, (str, bytes, os.PathLike)))]
if not non_string_entries.empty:
logger.warning(f"Non-string entries found in column '{col}':")
logger.debug(non_string_entries)
df = df.copy()
split_paths = df[col].apply(clean_split)
df = df[split_paths.apply(lambda parts: len(parts) >= min_files_for_path)].copy()
split_paths = split_paths[df.index]
df["group_key"] = split_paths.apply(lambda parts: os.sep.join(parts[:path_exclusion_constant]))
grouped = df.groupby("group_key")
new_rows = []
for _, group_df in grouped:
paths = group_df[col].tolist()
split_parts = [clean_split(p) for p in paths]
def longest_common_prefix(paths):
if not paths:
return []
prefix = paths[0]
for path in paths[1:]:
prefix = [a for a, b in zip(prefix, path) if a == b]
if not prefix:
break
return prefix
common_prefix = longest_common_prefix(split_parts)
prefix_str = os.sep.join(common_prefix)
for i, parts in enumerate(split_parts):
filename = parts[-1]
middle = os.sep.join(parts[len(common_prefix):-1]) if len(parts) > len(common_prefix) + 1 else ""
row = group_df.iloc[i].copy()
row["longestcfp"] = prefix_str
row["middle"] = middle
row["filename_only"] = filename
row["file_extension"] = os.path.splitext(filename)[1].lower()
new_rows.append(row)
return pd.DataFrame(new_rows).drop(columns=["group_key"])
async def calculatePath(approved_hashes, split):
dfs_by_policy = [group for _, group in approved_hashes.groupby("policy")] if split else [approved_hashes]
badpathparts = await asyncio.to_thread(load_env_json, "BAD_PATH_PARTS", "[]")
min_files_for_path = await asyncio.to_thread(get_protected_value, "MIN_FILES_FOR_PATH", int)
processed_dfs = []
for df in dfs_by_policy:
haslcp = await splitFilepathsGrouped(df, "filename_exec")
haslcp = haslcp.drop_duplicates()
forbidden = regulator(badpathparts, True)
forbidden_lcfp = haslcp["longestcfp"].str.contains(forbidden, na=False)
logger.debug("Removing forbidden filepaths for path exceptions")
print(colorText("Removing forbidden filepaths for path exceptions", "green"))
lcp_not_forbidden = haslcp[~forbidden_lcfp].copy()
lcp_not_forbidden_review = lcp_not_forbidden[[
"policyname", "longestcfp", "middle", "filename_only", "file_extension", "sha256"
]]
unique_sha_counts = (
lcp_not_forbidden_review.groupby("longestcfp")["sha256"].nunique().reset_index()
)
unique_sha_counts.columns = ["longestcfp", "unique_sha256_count"]
lcp_not_forbidden_review = lcp_not_forbidden_review.merge(
unique_sha_counts, on="longestcfp", how="left"
)
lcp_not_forbidden_review = lcp_not_forbidden_review[
lcp_not_forbidden_review["unique_sha256_count"] >= min_files_for_path
]
processed_dfs.append(lcp_not_forbidden_review)
pathExclusions = pd.concat(processed_dfs, ignore_index=True)
return pathExclusions