{{ message }}
Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
561 lines (461 loc) · 24 KB
/
Copy pathmain.py
File metadata and controls
561 lines (461 loc) · 24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
import os
import re
import joblib
import logging
import numpy as np
import pandas as pd
import PyPDF2
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.svm import SVC
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from sklearn.preprocessing import StandardScaler
# --- Configuration ---
DATASET_PATH = 'PDFMalware2022.csv' # Path to the training dataset
MODEL_SAVE_PATH = 'malicious_pdf_detector.joblib'
SCALER_SAVE_PATH = 'feature_scaler.joblib'
FEATURE_COLUMNS_PATH = 'feature_columns.txt'
NUMERIC_COLUMNS_PATH = 'numeric_columns.txt' # Stores names of columns that were scaled
TARGET_COLUMN = 'Class' # Name of the target variable column
# --- Setup Logging ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Data Loading and Preprocessing ---
def load_data(dataset_path):
"""Loads the dataset from a CSV file."""
try:
df = pd.read_csv(dataset_path)
logging.info(f"Dataset loaded successfully from {dataset_path} with {len(df)} samples.")
return df
except FileNotFoundError:
logging.error(f"Dataset not found at {dataset_path}")
return None
except Exception as e:
logging.error(f"Error loading dataset: {e}")
return None
def preprocess_data(df):
"""
Preprocesses the dataframe: handles missing target values, identifies
feature types, imputes numeric NaNs, one-hot encodes categoricals.
Returns the processed DataFrame and lists of numeric and final feature columns.
"""
if df is None or df.empty:
logging.warning("Preprocessing skipped: Input DataFrame is empty or None.")
return None, [], []
# Ensure target column exists and drop rows with missing target
if TARGET_COLUMN not in df.columns:
logging.error(f"Target column '{TARGET_COLUMN}' not found in the dataset.")
return None, [], []
df.dropna(subset=[TARGET_COLUMN], inplace=True)
if df.empty:
logging.warning("Preprocessing stopped: No data remaining after dropping NaNs in target column.")
return None, [], []
df[TARGET_COLUMN] = df[TARGET_COLUMN].astype(int)
# Identify initial feature columns (excluding target and potential file identifiers)
potential_id_cols = ['FileName', 'filename', 'ID', 'id'] # Add other potential ID columns if needed
feature_cols_initial = [col for col in df.columns if col != TARGET_COLUMN and col not in potential_id_cols]
# Separate numeric and categorical features
numeric_cols = df[feature_cols_initial].select_dtypes(include=np.number).columns.tolist()
categorical_cols = [col for col in feature_cols_initial if col not in numeric_cols]
logging.info(f"Identified {len(numeric_cols)} numeric and {len(categorical_cols)} categorical features.")
# Impute NaNs in numeric columns with the mean
if numeric_cols:
numeric_means = df[numeric_cols].mean()
df[numeric_cols] = df[numeric_cols].fillna(numeric_means)
nan_check = df[numeric_cols].isnull().sum().sum()
if nan_check == 0:
logging.info("NaNs imputed in numeric columns using mean.")
else:
logging.warning(f"NaN imputation failed for some numeric columns ({nan_check} NaNs remain).")
# One-hot encode categorical columns
if categorical_cols:
df = pd.get_dummies(df, columns=categorical_cols, drop_first=True, dummy_na=False)
logging.info(f"Categorical columns one-hot encoded. New shape: {df.shape}")
# Define final feature columns *after* encoding
feature_cols_final = [col for col in df.columns if col != TARGET_COLUMN and col not in potential_id_cols]
return df, numeric_cols, feature_cols_final
def scale_features(df, numeric_cols):
"""
Scales numeric features using StandardScaler.
Returns the DataFrame with scaled features and the fitted scaler object.
"""
scaler = None
if not numeric_cols:
logging.info("No numeric columns provided for scaling.")
return df, None
# Ensure numeric columns actually exist in the dataframe after potential prior processing steps
numeric_cols_present = [col for col in numeric_cols if col in df.columns]
if not numeric_cols_present:
logging.warning("None of the specified numeric columns found in the DataFrame for scaling.")
return df, None
try:
scaler = StandardScaler()
# Create a copy before scaling to avoid SettingWithCopyWarning
df_scaled = df.copy()
df_scaled[numeric_cols_present] = scaler.fit_transform(df[numeric_cols_present])
logging.info(f"Scaled {len(numeric_cols_present)} numeric columns.")
return df_scaled, scaler
except Exception as e:
logging.error(f"Error during feature scaling: {e}")
return df, None # Return original df and no scaler on error
# --- Model Training and Evaluation ---
def train_models(X_train, y_train):
"""Trains multiple classification models."""
models = {
'Logistic Regression': LogisticRegression(solver='liblinear', random_state=42, max_iter=1000),
'Random Forest': RandomForestClassifier(n_estimators=100, random_state=42),
'Gradient Boosting': GradientBoostingClassifier(n_estimators=100, learning_rate=0.1, max_depth=3,
random_state=42),
'SVM': SVC(kernel='rbf', probability=True, random_state=42) # Note: SVM can be slow
}
trained_models = {}
for name, model in models.items():
logging.info(f"Training {name}...")
try:
model.fit(X_train, y_train)
trained_models[name] = model
logging.info(f"{name} trained successfully.")
except Exception as e:
logging.error(f"Error training {name}: {e}")
return trained_models
def evaluate_models(trained_models, X_data, y_data, dataset_label="Test"):
"""Evaluates trained models and logs metrics."""
logging.info(f"--- Model Evaluation on {dataset_label} Set ---")
evaluation_results = {}
for name, model in trained_models.items():
try:
y_pred = model.predict(X_data)
accuracy = accuracy_score(y_data, y_pred)
report = classification_report(y_data, y_pred, zero_division=0,
output_dict=True) # Get dict for easier parsing
confusion = confusion_matrix(y_data, y_pred).tolist() # Convert to list for easier logging/storage
logging.info(f"\n--- {name} ({dataset_label}) ---")
logging.info(f"Accuracy: {accuracy:.4f}")
logging.info(
f"Classification Report:\n{classification_report(y_data, y_pred, zero_division=0)}") # Log formatted
# string too
logging.info(f"Confusion Matrix:\n{confusion}")
evaluation_results[name] = {'accuracy': accuracy, 'report': report, 'confusion_matrix': confusion}
except Exception as e:
logging.error(f"Error evaluating {name} on {dataset_label} set: {e}")
return evaluation_results
def select_best_model(trained_models, X_test, y_test):
"""Selects the best model based on accuracy on the test set."""
best_model_name = None
best_accuracy = -1.0
best_model = None
logging.info("--- Selecting Best Model based on Test Set Accuracy ---")
if not trained_models:
logging.warning("Cannot select best model: No models were trained successfully.")
return None, None
for name, model in trained_models.items():
try:
y_pred = model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
logging.info(f"{name} Test Accuracy: {accuracy:.4f}")
if accuracy > best_accuracy:
best_accuracy = accuracy
best_model_name = name
best_model = model
except Exception as e:
logging.warning(f"Could not evaluate {name} for selection: {e}")
if best_model:
logging.info(f"Selected best model: {best_model_name} with accuracy {best_accuracy:.4f}")
else:
logging.warning("Could not select a best model.")
return best_model, best_model_name
# --- Component Saving and Loading ---
def save_components(model, scaler, feature_columns, numeric_columns):
"""Saves the model, scaler, and column lists."""
try:
joblib.dump(model, MODEL_SAVE_PATH)
logging.info(f"Best model saved to: {MODEL_SAVE_PATH}")
if scaler:
joblib.dump(scaler, SCALER_SAVE_PATH)
logging.info(f"Scaler saved to: {SCALER_SAVE_PATH}")
else:
logging.info("No scaler object to save (likely no numeric features were scaled).")
# Optionally remove old scaler file if it exists
if os.path.exists(SCALER_SAVE_PATH):
os.remove(SCALER_SAVE_PATH)
with open(FEATURE_COLUMNS_PATH, 'w') as f:
for col in feature_columns:
f.write(f"{col}\n")
logging.info(f"Feature columns saved to: {FEATURE_COLUMNS_PATH}")
with open(NUMERIC_COLUMNS_PATH, 'w') as f:
for col in numeric_columns:
f.write(f"{col}\n")
logging.info(f"Numeric columns (scaled) saved to: {NUMERIC_COLUMNS_PATH}")
except Exception as e:
logging.error(f"Error saving components: {e}")
def load_components():
"""Loads the model, scaler, feature column list, and numeric column list."""
loaded_model, loaded_scaler, loaded_feature_columns, loaded_numeric_columns = None, None, None, None
try:
if os.path.exists(MODEL_SAVE_PATH):
loaded_model = joblib.load(MODEL_SAVE_PATH)
logging.info(f"Model loaded from: {MODEL_SAVE_PATH}")
else:
logging.error(f"Model file not found at {MODEL_SAVE_PATH}. Cannot proceed.")
return None, None, None, None # Model is essential
if os.path.exists(SCALER_SAVE_PATH):
loaded_scaler = joblib.load(SCALER_SAVE_PATH)
logging.info(f"Scaler loaded from: {SCALER_SAVE_PATH}")
else:
logging.info("Scaler file not found (likely no numeric features were scaled).")
if os.path.exists(FEATURE_COLUMNS_PATH):
with open(FEATURE_COLUMNS_PATH, 'r') as f:
loaded_feature_columns = [line.strip() for line in f if line.strip()]
logging.info(f"Feature columns loaded from: {FEATURE_COLUMNS_PATH}")
else:
logging.error(f"Feature columns file not found at {FEATURE_COLUMNS_PATH}. Cannot proceed with prediction.")
# Clean up potentially loaded model if feature columns are missing
if loaded_model: loaded_model = None
if loaded_scaler: loaded_scaler = None
return None, None, None, None # Feature columns are essential
if os.path.exists(NUMERIC_COLUMNS_PATH):
with open(NUMERIC_COLUMNS_PATH, 'r') as f:
loaded_numeric_columns = [line.strip() for line in f if line.strip()]
logging.info(f"Numeric columns loaded from: {NUMERIC_COLUMNS_PATH}")
else:
logging.info("Numeric columns file not found (likely no numeric features were scaled).")
loaded_numeric_columns = []
return loaded_model, loaded_scaler, loaded_feature_columns, loaded_numeric_columns
except Exception as e:
logging.error(f"Error loading components: {e}")
return None, None, None, None
# --- PDF Feature Extraction ---
def extract_features_single_pdf(file_path):
"""
Extracts raw features from a single PDF file into a dictionary.
Returns a dictionary of features or None if an error occurs.
"""
features = {}
try:
if not os.path.exists(file_path):
logging.error(f'File not found: {file_path}')
return None
pdf_reader = PyPDF2.PdfReader(file_path)
pdfsize = os.path.getsize(file_path) / 1024 # size in KB
num_pages = len(pdf_reader.pages)
metadata = pdf_reader.metadata
metadata_size = len(str(metadata)) if metadata else 0
# Basic features matching typical column names from datasets
features['pdfsize'] = pdfsize
features['metadata size'] = metadata_size # Ensure key matches training column name
features['pages'] = num_pages
# Add more features based on your training data, e.g.:
# features['title characters'] = len(metadata.title) if metadata and metadata.title else 0
# Content-based features
js_found, acroform_found, embeddedfile_found = 0, 0, 0
try:
# Check trailer first
trailer_str = str(pdf_reader.trailer).lower() # Use lower for case-insensitivity
if '/acroform' in trailer_str: acroform_found = 1
if '/embeddedfile' in trailer_str: embeddedfile_found = 1
if '/js' in trailer_str or '/javascript' in trailer_str: js_found = 1
# Optionally search text content (can be slow and error-prone)
if not js_found:
text_content = ""
for page in pdf_reader.pages:
try:
page_text = page.extract_text()
if page_text: text_content += page_text
except Exception:
# logging.debug(f"Could not extract text from a page in {file_path}")
pass
if re.search(r'[/<\(\[\s](js|javascript)[/>\)\]\s]', text_content, re.IGNORECASE):
js_found = 1
except Exception as e:
logging.warning(f"Could not fully extract content features from {file_path}: {e}")
features['JS'] = js_found # Ensure key matches training column name
features['Acroform'] = acroform_found # Ensure key matches training column name
features['EmbeddedFile'] = embeddedfile_found # Ensure key matches training column name
logging.debug(f"Extracted features for {os.path.basename(file_path)}: {features}")
return features
except FileNotFoundError: # Already handled above, but keep for safety
logging.error(f"File not found during PDF processing: {file_path}")
return None
except PyPDF2.errors.PdfReadError as pdf_err:
logging.error(f"PyPDF2 error processing PDF {file_path}: {pdf_err}")
return None
except Exception as e:
logging.error(f"Unexpected error processing PDF {file_path}: {e}")
return None
# --- Prediction Pipeline ---
def align_features_for_prediction(raw_features_dict, loaded_feature_columns):
"""
Aligns extracted PDF features with the columns used during training.
Returns a Pandas DataFrame ready for scaling/prediction, or None on error.
"""
if not raw_features_dict or not loaded_feature_columns:
logging.error("Cannot align features: Missing raw features or feature column list.")
return None
# Create a DataFrame from the single dictionary
df = pd.DataFrame([raw_features_dict], index=[0])
try:
# Reindex to match training columns: adds missing (with 0), drops extra, orders correctly.
df_aligned = df.reindex(columns=loaded_feature_columns, fill_value=0)
return df_aligned
except Exception as e:
logging.error(f"Error aligning features: {e}")
return None
def scale_features_for_prediction(df_aligned, scaler, numeric_columns_scaled):
"""
Applies the loaded scaler to the numeric columns of the aligned feature DataFrame.
Returns the scaled DataFrame or None on error.
"""
if df_aligned is None or df_aligned.empty:
logging.error("Cannot scale: Input DataFrame is empty or None.")
return None
if not scaler or not numeric_columns_scaled:
logging.info("Skipping scaling: No scaler or numeric columns list provided.")
return df_aligned # Return unscaled but aligned data
# Identify numeric columns present in the aligned DataFrame that need scaling
numeric_cols_to_scale = [col for col in numeric_columns_scaled if col in df_aligned.columns]
if not numeric_cols_to_scale:
logging.info("Skipping scaling: No numeric columns from training found in current features.")
return df_aligned
try:
# Work on a copy to avoid modifying the original aligned df if scaling fails
df_scaled = df_aligned.copy()
numeric_data = df_scaled[numeric_cols_to_scale].fillna(0) # Ensure no NaNs before transform
scaled_values = scaler.transform(numeric_data)
df_scaled[numeric_cols_to_scale] = scaled_values
logging.debug("Numeric features scaled successfully for prediction.")
return df_scaled
except ValueError as ve:
logging.error(f"Scaler error during prediction (likely feature mismatch): {ve}. "
f"Columns expected by scaler: {getattr(scaler, 'n_features_in_', 'N/A')}, "
f"Columns provided: {len(numeric_cols_to_scale)}")
return None
except Exception as e:
logging.error(f"Error applying scaling during prediction: {e}")
return None
def predict_single_pdf(file_path, model, scaler, feature_columns, numeric_columns_scaled):
"""
Predicts if a single PDF is malicious using the full pipeline.
Returns a dictionary with prediction results or an error message.
"""
if not model or not feature_columns:
logging.error("Prediction failed: Model or feature columns not loaded.")
return {'error': 'Model or feature columns missing.'}
# 1. Extract raw features
raw_features = extract_features_single_pdf(file_path)
if raw_features is None:
return {'error': f'Failed to extract features from {os.path.basename(file_path)}.'}
# 2. Align features
aligned_df = align_features_for_prediction(raw_features, feature_columns)
if aligned_df is None:
return {'error': 'Feature alignment failed.'}
# 3. Scale features
scaled_df = scale_features_for_prediction(aligned_df, scaler, numeric_columns_scaled)
if scaled_df is None:
# Attempt prediction with unscaled data if scaling failed, but log a warning
logging.warning("Scaling failed, attempting prediction with unscaled aligned features.")
scaled_df = aligned_df # Use the aligned (but unscaled) data
# Note: This might lead to poor prediction accuracy if the model expects scaled data.
# Consider returning an error here if scaling is absolutely critical.
# return {'error': 'Feature scaling failed.'}
# 4. Predict
try:
# Ensure DataFrame columns exactly match feature_columns in order, crucial for some models
prediction_input_df = scaled_df[feature_columns]
# Final check for NaNs before prediction (should be handled by fill_value=0 and fillna(0))
if prediction_input_df.isnull().values.any():
logging.warning("NaNs found in final feature vector before prediction. Filling with 0.")
prediction_input_df = prediction_input_df.fillna(0)
prediction = model.predict(prediction_input_df)
probability = model.predict_proba(prediction_input_df)[0] if hasattr(model, 'predict_proba') else None
result = {'is_malicious': bool(prediction[0])}
if probability is not None:
# Class 1 is typically the 'malicious' class
result['malicious_probability'] = probability[1]
result['benign_probability'] = probability[0]
return result
except ValueError as e:
logging.error(f'Prediction ValueError: {e}. Check model compatibility with input shape/features. '
f'Input shape: {prediction_input_df.shape}, Expected features: {len(feature_columns)}')
return {'error': f'Prediction input shape/feature mismatch: {e}'}
except Exception as e:
logging.error(f'Prediction failed: {e}')
return {'error': f'An unexpected error occurred during prediction: {e}'}
# --- Main Execution Logic ---
def run_training():
"""Handles the model training workflow."""
logging.info("--- Starting Training and Saving Mode ---")
df_raw = load_data(DATASET_PATH)
if df_raw is None: return # Stop if loading failed
df_processed, numeric_cols, feature_cols = preprocess_data(df_raw)
if df_processed is None or df_processed.empty: return # Stop if preprocessing failed
df_scaled, scaler = scale_features(df_processed, numeric_cols)
# Continue even if scaling fails, using df_processed
if feature_cols and TARGET_COLUMN in df_scaled.columns:
X = df_scaled[feature_cols]
y = df_scaled[TARGET_COLUMN]
if X.empty or y.empty:
logging.error("No data available for training after preprocessing/scaling.")
return
# Split data
try:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
logging.info(f"Data split: Train set size {X_train.shape[0]}, Test set size {X_test.shape[0]}")
except ValueError as e:
logging.error(f"Error during train/test split (potentially too few samples for stratification): {e}")
return
# Train models
trained_models = train_models(X_train, y_train)
if not trained_models:
logging.error("No models were trained successfully.")
return
# Evaluate models
evaluate_models(trained_models, X_test, y_test, dataset_label="Test")
evaluate_models(trained_models, X_train, y_train, dataset_label="Training") # Evaluate on training data as well
# Select and save best model
best_model, best_model_name = select_best_model(trained_models, X_test, y_test)
if best_model:
# Use the numeric columns identified *before* scaling for saving
save_components(best_model, scaler, feature_cols, numeric_cols)
else:
logging.warning("Skipping saving components as no best model was selected.")
else:
logging.error("Could not prepare features (X) and target (y) for training.")
def run_prediction(pdf_path_to_analyze):
"""Handles the prediction workflow for a single PDF."""
logging.info("--- Starting Prediction Mode ---")
# Check if PDF exists before loading components
if not os.path.exists(pdf_path_to_analyze):
logging.error(f"Input PDF file not found at '{pdf_path_to_analyze}'. Cannot predict.")
return
# Load necessary components
model, scaler, features, numeric_cols = load_components()
if model and features:
logging.info(f"Attempting prediction for: {pdf_path_to_analyze}")
prediction_result = predict_single_pdf(pdf_path_to_analyze, model, scaler, features, numeric_cols)
logging.info("-" * 30)
logging.info(f"Prediction result for {os.path.basename(pdf_path_to_analyze)}:")
if 'error' in prediction_result:
logging.error(f" Error: {prediction_result['error']}")
else:
status = "MALICIOUS" if prediction_result['is_malicious'] else "BENIGN"
logging.info(f" Status: {status}")
if 'malicious_probability' in prediction_result:
logging.info(f" Malicious Probability: {prediction_result['malicious_probability']:.4f}")
logging.info("-" * 30)
else:
logging.error("Could not load necessary components (model/feature list) for prediction. "
"Ensure training was run successfully and files exist.")
# --- Main Execution Guard ---
if __name__ == "__main__":
# <<< --- Configuration: Choose Mode --- >>>
# Set perform_training to True to train and save a new model.
# Set perform_training to False to load an existing model and predict.
perform_training = False
# <<< --- Configuration: PDF for Prediction --- >>>
# Set the path to the PDF file you want to analyze when perform_training is False.
pdf_to_predict = 'good.pdf' # Example: 'path/to/your/document.pdf' or GreyHatPDF/good.pdf or GreyHatPDF/bad.pdf
# --- Execute Chosen Mode ---
if perform_training:
run_training()
else:
run_prediction(pdf_to_predict)
You can’t perform that action at this time.
