296 lines
11 KiB
Python
296 lines
11 KiB
Python
# 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/>.
|
|
|
|
#Local Imports
|
|
import utils.pathfunctions as pathf
|
|
import utils.utils as ct
|
|
|
|
#Standard Libary Imports:
|
|
import gc
|
|
import json
|
|
import os
|
|
|
|
#3rd Party Imports:
|
|
import pandas as pd
|
|
import requests
|
|
|
|
def aggregateHashes(executions_json) -> pd.DataFrame:
|
|
"""
|
|
Takes the executions, aggregates all the data with sha256 as primary, then returns aggregated dataframe
|
|
"""
|
|
data = json.loads(executions_json)
|
|
df = pd.DataFrame(data["response"]["exechistories"])
|
|
|
|
if df.empty:
|
|
return df
|
|
print(df)
|
|
# Aggregate by sha256, deduplicate lists, and preserve order
|
|
agg_df = df.groupby("sha256").agg(lambda x: list(dict.fromkeys(x))).reset_index()
|
|
|
|
# Add a column for the number of unique hostnames
|
|
agg_df["num_devices"] = agg_df["hostname"].apply(len)
|
|
|
|
# Sort by num_devices in descending order
|
|
agg_df = agg_df.sort_values("num_devices", ascending=False)
|
|
|
|
return agg_df
|
|
|
|
def augmentAggregatedHashes(url, agg_df: pd.DataFrame) -> pd.DataFrame:
|
|
"""
|
|
Takes output of aggregatedHashes, queries API for those hashes, flattens response while keeping one row per hash,
|
|
aggregate applications and baselines into lists, then merges results back into agg_df to create a
|
|
"""
|
|
if 'sha256' not in agg_df.columns or agg_df.empty:
|
|
print("⚠️ 'sha256' column missing or DataFrame is empty. Skipping API query.")
|
|
return agg_df.copy() # Return as-is to avoid breaking downstream logic
|
|
|
|
endpoint = url + '/v1/hash/query'
|
|
payload = {
|
|
"hashes": agg_df['sha256'].tolist()
|
|
}
|
|
|
|
headers = {"X-APIKey": os.getenv('APIKEY')}
|
|
payload = json.dumps(payload)
|
|
|
|
response = requests.post(endpoint, headers=headers, data=payload, verify=False)
|
|
data = response.json()
|
|
results = data.get("response", {}).get("results", [])
|
|
|
|
rows = []
|
|
for res in results:
|
|
row = {"sha256": res.get("sha256"), "result": res.get("result")}
|
|
|
|
if "data" in res:
|
|
d = res["data"]
|
|
for key in ["filename", "filepath", "description", "filesize", "md5",
|
|
"productname", "productversion", "publisher", "createtime", "modtime",
|
|
"sha128", "sha384", "sha512", "datetime"]:
|
|
row[key] = d.get(key)
|
|
|
|
row["applications"] = d.get("applications", [])
|
|
row["baselines"] = d.get("baselines", [])
|
|
|
|
reputation = d.get("reputation", {})
|
|
for k, v in reputation.items():
|
|
row[f"reputation_{k}"] = v
|
|
|
|
rows.append(row)
|
|
|
|
df_api = pd.DataFrame(rows)
|
|
|
|
if 'sha256' not in df_api.columns:
|
|
print("⚠️ API response missing 'sha256'. Skipping merge.")
|
|
return agg_df.copy()
|
|
|
|
df = agg_df.merge(df_api, on="sha256", how="left")
|
|
|
|
# Only include columns that exist to avoid KeyErrors
|
|
expected_columns = ['policy','sha256', 'filename_x', 'description', 'productname', 'productversion',
|
|
'publisher_y', 'publisher_x', 'netdomain', 'hostname', 'username',
|
|
'reputation_lastseen', 'reputation_scannermatch', 'reputation_scannercount',
|
|
'reputation_status', 'reputation_threatlevel', 'reputation_threatname',
|
|
'reputation_timestamp', 'pprocess', 'gprocess', 'commandline']
|
|
|
|
available_columns = [col for col in expected_columns if col in df.columns]
|
|
aug_df = df[available_columns]
|
|
|
|
return aug_df
|
|
|
|
def categorizeHashes(df: pd.DataFrame, threat_tolerance: int, untrusted_publishers, pups: list) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
if untrusted_publishers is None: untrusted_publishers = []
|
|
if pups is None: pups = []
|
|
|
|
def reputationtool(row):
|
|
val = row["reputation_scannermatch"]
|
|
if pd.isna(val) or val == "N/A":
|
|
return row["publisher"] == "Not Signed"
|
|
try:
|
|
return int(val) > threat_tolerance
|
|
except (ValueError, TypeError):
|
|
return row["publisher"] == "Not Signed"
|
|
|
|
df["reputation_flag"] = df.apply(reputationtool, axis=1)
|
|
|
|
mask_needsreview = (
|
|
((df["publisher"] == "Not Signed") & df["reputation_flag"]) |
|
|
(df["reputation_status"] == "UNKNOWN")
|
|
)
|
|
|
|
mask_approved = (
|
|
(
|
|
(df["publisher"] != "Not Signed") &
|
|
~df["publisher"].str.contains(pathf.regulator(untrusted_publishers), case=False, na=False) &
|
|
~df["reputation_status"].isna() &
|
|
~df["description"].str.contains(pathf.regulator(pups), case=False, na=False)
|
|
) |
|
|
(
|
|
(df["publisher"] == "Not Signed") &
|
|
~df["reputation_flag"] &
|
|
~df["publisher"].str.contains(pathf.regulator(untrusted_publishers), case=False, na=False) &
|
|
~df["reputation_status"].isna() &
|
|
~df["description"].str.contains(pathf.regulator(pups), case=False, na=False)
|
|
)
|
|
)
|
|
|
|
needsreview_df = df[mask_needsreview]
|
|
approved_df = df[mask_approved]
|
|
unapproved_df = df[~(mask_needsreview | mask_approved)]
|
|
return needsreview_df, approved_df, unapproved_df
|
|
|
|
def combineHashAndHist(hash_path, condensed_path):
|
|
# Load both datasets
|
|
condensed_combo = pd.read_parquet(condensed_path)
|
|
df = pd.read_parquet(hash_path)
|
|
# Merge on sha256
|
|
df = pd.merge(condensed_combo, df, on='sha256', how='inner')
|
|
# Rename and reorder columns
|
|
df = df.rename(columns={'publisher_x': 'publisher'})
|
|
df = df.rename(columns={'policy_x': 'policy'})
|
|
df = df[['policy','sha256', 'publisher', 'description', 'filename', 'hostname', 'username',
|
|
'productname', 'productversion', 'reputation_lastseen', 'reputation_scannermatch',
|
|
'reputation_scannercount', 'reputation_status', 'reputation_threatlevel',
|
|
'reputation_threatname', 'reputation_timestamp', 'pprocess', 'gprocess', 'commandline']]
|
|
df = df.sort_values(by='filename')
|
|
|
|
# Overwrite the original hash file
|
|
df.to_parquet(hash_path, index=False)
|
|
|
|
# Cleanup
|
|
del df
|
|
del condensed_combo
|
|
gc.collect()
|
|
|
|
def combineHashes(url, parquet_files) -> pd.DataFrame:
|
|
combined_hashes = pd.DataFrame()
|
|
hashes = []
|
|
|
|
for file_path in parquet_files:
|
|
try:
|
|
hash_df = pd.read_parquet(file_path)
|
|
pathf.inspect_parquet(file_path)
|
|
|
|
if not hash_df.empty:
|
|
hashes.append(hash_df)
|
|
else:
|
|
print(f"⚠️ Dataframe is empty: {file_path}")
|
|
except Exception as e:
|
|
print(f"❌ Error reading Parquet file '{file_path}': {e}")
|
|
|
|
if hashes:
|
|
combined_hashes = pd.concat(hashes, ignore_index=True)
|
|
print(f"✅ Combined {len(combined_hashes)} hashes from {len(hashes)} files.")
|
|
else:
|
|
print("⚠️ No valid dataframes to combine.")
|
|
|
|
combined_hashes = combined_hashes.drop_duplicates(subset=['sha256'])
|
|
augmented_combo = augmentAggregatedHashes(url, combined_hashes)
|
|
|
|
numeric_reputation_cols = [
|
|
'reputation_scannermatch',
|
|
'reputation_scannercount',
|
|
'reputation_threatlevel'
|
|
]
|
|
|
|
for col in numeric_reputation_cols:
|
|
if col in augmented_combo.columns:
|
|
augmented_combo[col] = pd.to_numeric(augmented_combo[col].replace('N/A', pd.NA), errors='coerce')
|
|
|
|
augmented_combo = augmented_combo.rename(columns={'publisher_x': 'publisher'})
|
|
augmented_combo = augmented_combo[['sha256', 'publisher', 'description', 'productname', 'productversion',
|
|
'reputation_lastseen', 'reputation_scannermatch', 'reputation_scannercount',
|
|
'reputation_status', 'reputation_threatlevel', 'reputation_threatname',
|
|
'reputation_timestamp']]
|
|
augmented_combo = augmented_combo.sort_values(by=['publisher', 'description', 'productname'])
|
|
del combined_hashes
|
|
gc.collect()
|
|
print(ct.colorText("Hash reputation info added to dataframe", "green"))
|
|
return augmented_combo
|
|
|
|
def condenseExecutions(parquet_paths):
|
|
combined_df = pd.DataFrame()
|
|
valid_files = []
|
|
|
|
for file_path in parquet_paths:
|
|
try:
|
|
df = pd.read_parquet(file_path)
|
|
# Optional: pathf.inspect_parquet(file_path)
|
|
if not df.empty:
|
|
combined_df = pd.concat([combined_df, df], ignore_index=True)
|
|
valid_files.append(file_path)
|
|
print(f"✅ Loaded {len(df)} rows from {file_path}")
|
|
else:
|
|
print(f"⚠️ DataFrame from '{file_path}' is empty.")
|
|
except Exception as e:
|
|
print(f"❌ Error reading Parquet file '{file_path}': {e}")
|
|
|
|
if not combined_df.empty:
|
|
print(f"✅ Combined {len(combined_df)} rows from {len(valid_files)} files.")
|
|
else:
|
|
print("⚠️ No valid dataframes to combine.")
|
|
|
|
return combined_df
|
|
|
|
def divideSortedHashExecutions(unknown_parq, good_parq, bad_parq, condensed_parq, pups) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
# Run combineHashAndHist on each file
|
|
combineHashAndHist(unknown_parq, condensed_parq)
|
|
combineHashAndHist(good_parq, condensed_parq)
|
|
combineHashAndHist(bad_parq, condensed_parq)
|
|
|
|
# Load data
|
|
unknown = pd.read_parquet(unknown_parq)
|
|
good = pd.read_parquet(good_parq)
|
|
bad = pd.read_parquet(bad_parq)
|
|
|
|
# Build regex pattern once
|
|
pattern = pathf.regulator(pups)
|
|
|
|
# Move matching rows from unknown and good to bad
|
|
bad = pd.concat([
|
|
bad,
|
|
unknown[unknown["filename"].str.contains(pattern, na=False)],
|
|
good[good["filename"].str.contains(pattern, na=False)]
|
|
], ignore_index=True)
|
|
|
|
# Remove matching rows from unknown and good
|
|
unknown = unknown[~unknown["filename"].str.contains(pattern, na=False)]
|
|
good = good[~good["filename"].str.contains(pattern, na=False)]
|
|
|
|
return unknown, good, bad
|
|
|
|
def generatePreflights(hashes, primary_path, secondary_path):
|
|
all_hashes = pd.read_parquet(hashes)
|
|
|
|
primarypathexclusions = ct.tryToReadCSV(primary_path)
|
|
secondarypathexclusions = ct.tryToReadCSV(secondary_path)
|
|
|
|
pathexclusions = pd.concat([primarypathexclusions, secondarypathexclusions], ignore_index=True)
|
|
|
|
allowbyhash = all_hashes[~all_hashes['sha256'].isin(pathexclusions['sha256'])]
|
|
|
|
allowbyhash.sort_values(by=["filename"])
|
|
|
|
return pathexclusions, allowbyhash
|
|
|
|
def generatePublist(all_hashes, bad_publisher_list):
|
|
all_approved_hashes = ct.tryToReadParquet(all_hashes)
|
|
|
|
#Drop all not signed, only keep unique values
|
|
publist = all_approved_hashes[all_approved_hashes['publisher'] != "Not Signed"].drop_duplicates(subset=['publisher'])
|
|
#Remove Bad publisher if somehow they made it this far
|
|
pattern = pathf.regulator(bad_publisher_list)
|
|
publist = publist[~publist["publisher"].str.contains(pattern, na=False)]
|
|
|
|
return publist
|
|
|