SaiBon99 commited on
Commit
eaa9a59
·
1 Parent(s): b8043cd

Add batch URL scanning script and feature extraction utilities

Browse files
src/phising_detection/data/load_phishing_urls.py CHANGED
@@ -72,7 +72,7 @@ def request_phishing_urls(links):
72
  df = pd.DataFrame({
73
  'url_id': range(len(all_urls)),
74
  'url': all_urls,
75
- 'is_phishing': 0 # 0 for legitimate URLs
76
  })
77
 
78
  return df
 
72
  df = pd.DataFrame({
73
  'url_id': range(len(all_urls)),
74
  'url': all_urls,
75
+ 'is_phishing': 1 # 0 for legitimate URLs
76
  })
77
 
78
  return df
src/phising_detection/features/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Feature extraction utilities for phishing detection."""
src/phising_detection/features/batch_url_scanner.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch URL scanning script that:
3
+ 1. Loads 2 feature groups from Hopsworks (phishing and legitimate URLs)
4
+ 2. Creates balanced dataset with equal amounts from both
5
+ 3. Scans URLs in batches of 200 with URLScan
6
+ 4. Extracts features from scan results
7
+ 5. Uploads results to Hopsworks after each batch
8
+ 6. Repeats until all URLs are scanned
9
+ """
10
+
11
+ import sys
12
+ import os
13
+ import logging
14
+ import time
15
+ from typing import List, Dict, Any
16
+ import pandas as pd
17
+
18
+ # Add src folder to path
19
+ src_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
20
+ sys.path.append(src_folder)
21
+
22
+ from api.urlscan import URLScanClient, URLScanError
23
+ from features.urlscan_features import extract_features_to_dataframe
24
+ from utils import hopsworks_utils as hw
25
+
26
+ # Configure logging
27
+ logging.basicConfig(
28
+ level=logging.INFO,
29
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
30
+ )
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def load_and_balance_feature_groups(
35
+ project,
36
+ fg1_name: str,
37
+ fg1_version: int,
38
+ fg2_name: str,
39
+ fg2_version: int,
40
+ sample_size: int = None
41
+ ) -> pd.DataFrame:
42
+ """
43
+ Load two feature groups and create balanced dataset with equal samples.
44
+
45
+ Args:
46
+ project: Hopsworks project object
47
+ fg1_name: Name of first feature group (e.g., phishing URLs)
48
+ fg1_version: Version of first feature group
49
+ fg2_name: Name of second feature group (e.g., legitimate URLs)
50
+ fg2_version: Version of second feature group
51
+ sample_size: Number of samples from each group (if None, uses minimum)
52
+
53
+ Returns:
54
+ Balanced DataFrame with equal samples from both groups
55
+ """
56
+ logger.info(f"Loading feature group: {fg1_name} v{fg1_version}")
57
+ df1 = hw.read_feature_group(project, fg1_name, fg1_version)
58
+
59
+ logger.info(f"Loading feature group: {fg2_name} v{fg2_version}")
60
+ df2 = hw.read_feature_group(project, fg2_name, fg2_version)
61
+
62
+ logger.info(f"Feature group 1 size: {len(df1)}")
63
+ logger.info(f"Feature group 2 size: {len(df2)}")
64
+
65
+ # Determine sample size
66
+ if sample_size is None:
67
+ sample_size = min(len(df1), len(df2))
68
+ else:
69
+ sample_size = min(sample_size, len(df1), len(df2))
70
+
71
+ logger.info(f"Sampling {sample_size} records from each feature group")
72
+
73
+ # Sample equal amounts from each, important this randomness can affect performens of network, upsameling would be better if we had the resources.
74
+ df1_sample = df1.sample(n=sample_size + int(0.33*sample_size), random_state=42) #to acount for offline pages in phising dataset
75
+ df2_sample = df2.sample(n=sample_size, random_state=42)
76
+
77
+ # Combine and shuffle
78
+ balanced_df = pd.concat([df1_sample, df2_sample], ignore_index=True)
79
+ balanced_df = balanced_df.sample(frac=1, random_state=42).reset_index(drop=True)
80
+
81
+ logger.info(f"Created balanced dataset with {len(balanced_df)} total URLs")
82
+ return balanced_df
83
+
84
+
85
+ def get_already_scanned_urls(
86
+ project,
87
+ feature_group_name: str,
88
+ version: int
89
+ ) -> set:
90
+ """
91
+ Retrieve URLs that have already been scanned from output feature group.
92
+
93
+ Args:
94
+ project: Hopsworks project object
95
+ feature_group_name: Name of output feature group
96
+ version: Feature group version
97
+
98
+ Returns:
99
+ Set of URLs that have already been scanned (empty set if FG doesn't exist)
100
+ """
101
+ try:
102
+ logger.info(f"Checking for existing scans in {feature_group_name} v{version}")
103
+ existing_df = hw.read_feature_group(project, feature_group_name, version)
104
+
105
+ if 'url' in existing_df.columns:
106
+ scanned_urls = set(existing_df['url'].dropna().unique())
107
+ logger.info(f"Found {len(scanned_urls)} already scanned URLs")
108
+ return scanned_urls
109
+ else:
110
+ logger.warning(f"Feature group exists but no 'url' column found")
111
+ return set()
112
+
113
+ except Exception as e:
114
+ logger.info(f"Output feature group not found or error reading it: {e}")
115
+ logger.info("Will scan all URLs")
116
+ return set()
117
+
118
+
119
+ def filter_already_scanned(
120
+ df: pd.DataFrame,
121
+ scanned_urls: set,
122
+ url_column: str = None
123
+ ) -> pd.DataFrame:
124
+ """
125
+ Filter out URLs that have already been scanned.
126
+
127
+ Args:
128
+ df: DataFrame with URLs to scan
129
+ scanned_urls: Set of already scanned URLs
130
+ url_column: Name of URL column (auto-detected if None)
131
+
132
+ Returns:
133
+ Filtered DataFrame with only unscanned URLs
134
+ """
135
+ if not scanned_urls:
136
+ logger.info("No previously scanned URLs to filter")
137
+ return df
138
+
139
+ # Auto-detect URL column
140
+ if url_column is None:
141
+ url_column = 'phishing_url' if 'phishing_url' in df.columns else 'url'
142
+
143
+ original_count = len(df)
144
+ filtered_df = df[~df[url_column].isin(scanned_urls)].reset_index(drop=True)
145
+ filtered_count = len(filtered_df)
146
+ skipped_count = original_count - filtered_count
147
+
148
+ logger.info(f"Filtered out {skipped_count} already scanned URLs")
149
+ logger.info(f"Remaining URLs to scan: {filtered_count}")
150
+
151
+ return filtered_df
152
+
153
+
154
+ def submit_url_batch(
155
+ client: URLScanClient,
156
+ urls: List[str],
157
+ visibility: str = "public",
158
+ delay_between_submissions: float = 1.0
159
+ ) -> List[Dict[str, Any]]:
160
+ """
161
+ Submit a batch of URLs for scanning (without waiting for results).
162
+
163
+ Args:
164
+ client: URLScan client instance
165
+ urls: List of URLs to scan
166
+ visibility: Scan visibility setting
167
+ delay_between_submissions: Delay in seconds between submissions to respect rate limits
168
+
169
+ Returns:
170
+ List of submission dictionaries with 'url', 'uuid', and 'api' fields
171
+ """
172
+ submissions = []
173
+
174
+ for i, url in enumerate(urls, 1):
175
+ logger.info(f"Submitting URL {i}/{len(urls)}: {url}")
176
+
177
+ try:
178
+ submission = client.submit_url(url=url, visibility=visibility)
179
+ # Add the original URL to the submission data
180
+ submission['url'] = url
181
+ submissions.append(submission)
182
+ logger.info(f"Successfully submitted: {url} (UUID: {submission.get('uuid')})")
183
+
184
+ except URLScanError as e:
185
+ logger.error(f"Failed to submit {url}: {e}")
186
+ # Continue with next URL
187
+ continue
188
+
189
+ # Rate limiting: wait between submissions
190
+ if i < len(urls):
191
+ time.sleep(delay_between_submissions)
192
+
193
+ logger.info(f"Submitted {len(submissions)}/{len(urls)} URLs successfully")
194
+ return submissions
195
+
196
+
197
+ def retrieve_scan_results(
198
+ client: URLScanClient,
199
+ submissions: List[Dict[str, Any]],
200
+ max_wait: int = 300,
201
+ poll_interval: int = 10,
202
+ initial_wait: int = 30
203
+ ) -> List[Dict[str, Any]]:
204
+ """
205
+ Retrieve results for submitted scans.
206
+
207
+ Args:
208
+ client: URLScan client instance
209
+ submissions: List of submission dictionaries from submit_url_batch
210
+ max_wait: Maximum time to wait for each scan (seconds)
211
+ poll_interval: Time between polling attempts (seconds)
212
+ initial_wait: Time to wait before first poll attempt (seconds)
213
+
214
+ Returns:
215
+ List of scan results (successful retrievals only)
216
+ """
217
+ logger.info(f"Waiting {initial_wait} seconds for scans to complete...")
218
+ time.sleep(initial_wait)
219
+
220
+ results = []
221
+ pending_submissions = submissions.copy()
222
+
223
+ start_time = time.time()
224
+
225
+ while pending_submissions and (time.time() - start_time) < max_wait:
226
+ still_pending = []
227
+
228
+ for submission in pending_submissions:
229
+ uuid = submission.get('uuid')
230
+ url = submission.get('url')
231
+
232
+ try:
233
+ result = client.get_result(uuid)
234
+ # Preserve the original submitted URL for proper matching later
235
+ result['original_url'] = url
236
+ results.append(result)
237
+ logger.info(f"Retrieved result for {url} (UUID: {uuid})")
238
+
239
+ except URLScanError as e:
240
+ if "not found or not ready" in str(e):
241
+ # Scan not ready yet, keep in pending list
242
+ still_pending.append(submission)
243
+ else:
244
+ # Other error, log and skip
245
+ logger.error(f"Failed to retrieve result for {url} (UUID: {uuid}): {e}")
246
+
247
+ pending_submissions = still_pending
248
+
249
+ if pending_submissions:
250
+ logger.info(f"Still waiting for {len(pending_submissions)} scans. Waiting {poll_interval}s...")
251
+ time.sleep(poll_interval)
252
+
253
+ if pending_submissions:
254
+ logger.warning(f"Timeout: {len(pending_submissions)} scans did not complete in time")
255
+ for submission in pending_submissions:
256
+ logger.warning(f" - {submission.get('url')} (UUID: {submission.get('uuid')})")
257
+
258
+ logger.info(f"Successfully retrieved {len(results)}/{len(submissions)} scan results")
259
+ return results
260
+
261
+
262
+ def process_and_upload_batch(
263
+ project,
264
+ scan_results: List[Dict[str, Any]],
265
+ original_df: pd.DataFrame,
266
+ feature_group_name: str,
267
+ version: int,
268
+ primary_key: List[str]
269
+ ):
270
+ """
271
+ Extract features from scan results and upload to Hopsworks.
272
+
273
+ Args:
274
+ project: Hopsworks project object
275
+ scan_results: List of URLScan result dictionaries
276
+ original_df: Original DataFrame with URL metadata (is_phishing, etc.)
277
+ feature_group_name: Name of output feature group
278
+ version: Feature group version
279
+ primary_key: Primary key columns for feature group
280
+ """
281
+ if not scan_results:
282
+ logger.warning("No scan results to process")
283
+ return
284
+
285
+ logger.info(f"Extracting features from {len(scan_results)} scan results")
286
+ features_df = extract_features_to_dataframe(scan_results)
287
+
288
+ # Merge with original data to get labels (is_phishing)
289
+ # Assuming original_df has 'url' or 'phishing_url' column
290
+ url_col = 'phishing_url' if 'phishing_url' in original_df.columns else 'url'
291
+
292
+ # Merge on URL to add is_phishing label
293
+ features_df = features_df.merge(
294
+ original_df[[url_col, 'is_phishing']],
295
+ left_on='url',
296
+ right_on=url_col,
297
+ how='left'
298
+ )
299
+
300
+ # Drop duplicate url column if exists
301
+ if url_col != 'url' and url_col in features_df.columns:
302
+ features_df = features_df.drop(columns=[url_col])
303
+
304
+ # Check for NaN values in is_phishing and log warnings
305
+ nan_count = features_df['is_phishing'].isna().sum()
306
+ if nan_count > 0:
307
+ logger.warning(f"Found {nan_count}/{len(features_df)} records with NaN is_phishing values")
308
+ logger.warning("This indicates URL mismatch between submitted and retrieved URLs")
309
+ # Show some examples of URLs that didn't match
310
+ nan_urls = features_df[features_df['is_phishing'].isna()]['url'].head(5).tolist()
311
+ logger.warning(f"Example URLs with no match: {nan_urls}")
312
+
313
+ # Drop rows with NaN is_phishing to avoid data quality issues
314
+ before_drop = len(features_df)
315
+ features_df = features_df.dropna(subset=['is_phishing'])
316
+ after_drop = len(features_df)
317
+
318
+ if before_drop != after_drop:
319
+ logger.warning(f"Dropped {before_drop - after_drop} rows with missing is_phishing labels")
320
+
321
+ if len(features_df) == 0:
322
+ logger.error("No valid records to upload after dropping NaN values")
323
+ return
324
+
325
+ logger.info(f"Uploading {len(features_df)} records to Hopsworks")
326
+
327
+ hw.upload_dataframe_to_feature_group(
328
+ project=project,
329
+ df=features_df,
330
+ feature_group_name=feature_group_name,
331
+ version=version,
332
+ description="URLScan features extracted from phishing and legitimate URLs",
333
+ primary_key=primary_key,
334
+ online_enabled=True,
335
+ write_options={"wait_for_job": True}
336
+ )
337
+
338
+ logger.info("Successfully uploaded batch to Hopsworks")
339
+
340
+
341
+ def main(
342
+ fg1_name: str = "phishing_urls",
343
+ fg1_version: int = 2,
344
+ fg2_name: str = "legitimate_urls",
345
+ fg2_version: int = 1,
346
+ output_fg_name: str = "urlscan_features",
347
+ output_version: int = 1,
348
+ batch_size: int = 200,
349
+ sample_size: int = None
350
+ ):
351
+ """
352
+ Main orchestration function.
353
+
354
+ Args:
355
+ fg1_name: Name of first feature group
356
+ fg1_version: Version of first feature group
357
+ fg2_name: Name of second feature group
358
+ fg2_version: Version of second feature group
359
+ output_fg_name: Name of output feature group
360
+ output_version: Version of output feature group
361
+ batch_size: Number of URLs to scan per batch
362
+ sample_size: Number of samples from each input group (None = all)
363
+ """
364
+ logger.info("=" * 80)
365
+ logger.info("Starting batch URL scanning pipeline")
366
+ logger.info("=" * 80)
367
+
368
+ # Connect to Hopsworks
369
+ logger.info("Connecting to Hopsworks...")
370
+ project = hw.connect_to_hopsworks()
371
+
372
+ # Initialize URLScan client
373
+ logger.info("Initializing URLScan client...")
374
+ urlscan_client = URLScanClient()
375
+
376
+ # Load and balance feature groups
377
+ logger.info("Loading and balancing feature groups...")
378
+ balanced_df = load_and_balance_feature_groups(
379
+ project=project,
380
+ fg1_name=fg1_name,
381
+ fg1_version=fg1_version,
382
+ fg2_name=fg2_name,
383
+ fg2_version=fg2_version,
384
+ sample_size=sample_size
385
+ )
386
+
387
+ # Check for already scanned URLs
388
+ logger.info("Checking for already scanned URLs...")
389
+ scanned_urls = get_already_scanned_urls(
390
+ project=project,
391
+ feature_group_name=output_fg_name,
392
+ version=output_version
393
+ )
394
+
395
+ # Filter out already scanned URLs
396
+ balanced_df = filter_already_scanned(
397
+ df=balanced_df,
398
+ scanned_urls=scanned_urls
399
+ )
400
+
401
+ # Check if there are any URLs left to scan
402
+ if len(balanced_df) == 0:
403
+ logger.info("All URLs have already been scanned. Nothing to do!")
404
+ return
405
+
406
+ # Determine URL column name
407
+ url_col = 'phishing_url' if 'phishing_url' in balanced_df.columns else 'url'
408
+ all_urls = balanced_df[url_col].tolist()
409
+
410
+ total_urls = len(all_urls)
411
+ total_batches = (total_urls + batch_size - 1) // batch_size
412
+
413
+ logger.info(f"Total URLs to scan: {total_urls}")
414
+ logger.info(f"Batch size: {batch_size}")
415
+ logger.info(f"Total batches: {total_batches}")
416
+
417
+ # Process in batches
418
+ for batch_num in range(total_batches):
419
+ start_idx = batch_num * batch_size
420
+ end_idx = min(start_idx + batch_size, total_urls)
421
+
422
+ logger.info("=" * 80)
423
+ logger.info(f"Processing batch {batch_num + 1}/{total_batches}")
424
+ logger.info(f"URLs {start_idx + 1} to {end_idx} of {total_urls}")
425
+ logger.info("=" * 80)
426
+
427
+ # Get batch of URLs
428
+ batch_urls = all_urls[start_idx:end_idx]
429
+ batch_df = balanced_df.iloc[start_idx:end_idx]
430
+
431
+ # Phase 1: Submit all URLs for scanning
432
+ logger.info(f"Submitting {len(batch_urls)} URLs for scanning...")
433
+ submissions = submit_url_batch(
434
+ client=urlscan_client,
435
+ urls=batch_urls,
436
+ visibility="public",
437
+ delay_between_submissions=1.0 # 1 second between submissions
438
+ )
439
+
440
+ # Phase 2: Retrieve scan results
441
+ if submissions:
442
+ logger.info(f"Retrieving results for {len(submissions)} submitted scans...")
443
+ scan_results = retrieve_scan_results(
444
+ client=urlscan_client,
445
+ submissions=submissions,
446
+ max_wait=300, # 5 minutes total wait time
447
+ poll_interval=10, # Check every 10 seconds
448
+ initial_wait=30 # Wait 30 seconds before first check
449
+ )
450
+ else:
451
+ scan_results = []
452
+ logger.warning("No URLs were successfully submitted")
453
+
454
+ # Process and upload results
455
+ if scan_results:
456
+ process_and_upload_batch(
457
+ project=project,
458
+ scan_results=scan_results,
459
+ original_df=batch_df,
460
+ feature_group_name=output_fg_name,
461
+ version=output_version,
462
+ primary_key=["scan_uuid"]
463
+ )
464
+ else:
465
+ logger.warning(f"No successful scans in batch {batch_num + 1}, skipping upload")
466
+
467
+ # Wait between batches to respect rate limits
468
+ if batch_num < total_batches - 1:
469
+ wait_time = 10
470
+ logger.info(f"Waiting {wait_time} seconds before next batch...")
471
+ time.sleep(wait_time)
472
+
473
+ logger.info("=" * 80)
474
+ logger.info("Batch URL scanning pipeline completed!")
475
+ logger.info("=" * 80)
476
+
477
+
478
+ if __name__ == "__main__":
479
+ # Example usage - adjust parameters as needed
480
+ main(
481
+ fg1_name="phishing_urls",
482
+ fg1_version=2,
483
+ fg2_name="legit_urls_before_scan",
484
+ fg2_version=1,
485
+ output_fg_name="urlscan_features",
486
+ output_version=1,
487
+ batch_size=300,
488
+ sample_size=None # Set to None to use all available data
489
+ )
src/phising_detection/features/urlscan_features.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Feature extraction from URLScan.io results."""
2
+
3
+ from typing import Dict, Any, Optional
4
+ import pandas as pd
5
+
6
+
7
+ def extract_domain_age(result: Dict[str, Any]) -> Optional[int]:
8
+ """
9
+ Extract domain age in days from URLScan result.
10
+
11
+ Args:
12
+ result: URLScan.io API result dictionary
13
+
14
+ Returns:
15
+ Domain age in days, or None if not available
16
+ """
17
+ try:
18
+ return result.get("page", {}).get("domainAgeDays")
19
+ except (KeyError, TypeError):
20
+ return None
21
+
22
+
23
+ def extract_secure_percentage(result: Dict[str, Any]) -> Optional[float]:
24
+ """
25
+ Extract percentage of secure requests from URLScan result.
26
+
27
+ Args:
28
+ result: URLScan.io API result dictionary
29
+
30
+ Returns:
31
+ Percentage of secure requests (0-100), or None if not available
32
+ """
33
+ try:
34
+ return result.get("stats", {}).get("securePercentage")
35
+ except (KeyError, TypeError):
36
+ return None
37
+
38
+
39
+ def extract_umbrella_rank(result: Dict[str, Any]) -> Optional[int]:
40
+ """
41
+ Extract Cisco Umbrella popularity rank from URLScan result.
42
+ Lower rank = more popular/legitimate site.
43
+
44
+ Args:
45
+ result: URLScan.io API result dictionary
46
+
47
+ Returns:
48
+ Umbrella rank, or None if not available (unranked sites)
49
+ """
50
+ try:
51
+ return result.get("page", {}).get("umbrellaRank")
52
+ except (KeyError, TypeError):
53
+ return None
54
+
55
+
56
+ def extract_tls_valid_days(result: Dict[str, Any]) -> Optional[int]:
57
+ """
58
+ Extract TLS certificate validity period in days from URLScan result.
59
+
60
+ Args:
61
+ result: URLScan.io API result dictionary
62
+
63
+ Returns:
64
+ Number of days the TLS certificate is valid for, or None if not available
65
+ """
66
+ try:
67
+ return result.get("page", {}).get("tlsValidDays")
68
+ except (KeyError, TypeError):
69
+ return None
70
+
71
+
72
+ def extract_url_length(result: Dict[str, Any]) -> Optional[int]:
73
+ """
74
+ Extract URL length from URLScan result.
75
+
76
+ Args:
77
+ result: URLScan.io API result dictionary
78
+
79
+ Returns:
80
+ Length of the URL, or None if not available
81
+ """
82
+ try:
83
+ url = result.get("task", {}).get("url")
84
+ return len(url) if url else None
85
+ except (KeyError, TypeError):
86
+ return None
87
+
88
+
89
+ def extract_subdomain_count(result: Dict[str, Any]) -> Optional[int]:
90
+ """
91
+ Extract number of subdomains from URLScan result.
92
+ Example: www.example.com has 1 subdomain, example.com has 0.
93
+
94
+ Args:
95
+ result: URLScan.io API result dictionary
96
+
97
+ Returns:
98
+ Number of subdomains, or None if not available
99
+ """
100
+ try:
101
+ domain = result.get("page", {}).get("domain")
102
+ if not domain:
103
+ return None
104
+
105
+ # Count dots and subtract 1 for TLD (e.g., example.com has 1 dot = 0 subdomains)
106
+ # www.example.com has 2 dots = 1 subdomain
107
+ parts = domain.split(".")
108
+ # Assuming TLD is last part and domain is second-to-last
109
+ # subdomain count = total parts - 2 (domain + TLD)
110
+ subdomain_count = max(0, len(parts) - 2)
111
+ return subdomain_count
112
+ except (KeyError, TypeError, AttributeError):
113
+ return None
114
+
115
+
116
+ def extract_features(result: Dict[str, Any]) -> Dict[str, Any]:
117
+ """
118
+ Extract all available features from URLScan result.
119
+
120
+ Args:
121
+ result: URLScan.io API result dictionary
122
+
123
+ Returns:
124
+ Dictionary of extracted features
125
+ """
126
+ # Extract umbrella rank and create two features from it
127
+ umbrella_rank = extract_umbrella_rank(result)
128
+ has_umbrella_rank = 1 if umbrella_rank is not None else 0
129
+ umbrella_rank_filled = umbrella_rank if umbrella_rank is not None else 999999
130
+
131
+ # Extract TLS validity and create two features from it
132
+ tls_valid_days = extract_tls_valid_days(result)
133
+ has_tls = 1 if tls_valid_days is not None else 0
134
+ tls_valid_days_filled = tls_valid_days if tls_valid_days is not None else 0
135
+
136
+ features = {
137
+ "domain_age_days": extract_domain_age(result),
138
+ "secure_percentage": extract_secure_percentage(result),
139
+ "has_umbrella_rank": has_umbrella_rank,
140
+ "umbrella_rank": umbrella_rank_filled,
141
+ "has_tls": has_tls,
142
+ "tls_valid_days": tls_valid_days_filled,
143
+ "url_length": extract_url_length(result),
144
+ "subdomain_count": extract_subdomain_count(result),
145
+ }
146
+
147
+ return features
148
+
149
+
150
+ def extract_features_to_dataframe(results: list[Dict[str, Any]]) -> pd.DataFrame:
151
+ """
152
+ Extract features from multiple URLScan results into a DataFrame.
153
+
154
+ Args:
155
+ results: List of URLScan.io API result dictionaries
156
+
157
+ Returns:
158
+ DataFrame with extracted features
159
+ """
160
+ features_list = []
161
+
162
+ for result in results:
163
+ features = extract_features(result)
164
+ # Add URL and UUID for reference
165
+ # Use original_url if available (preserves submitted URL), otherwise use task URL
166
+ features["url"] = result.get("original_url") or result.get("task", {}).get("url")
167
+ features["scan_uuid"] = result.get("task", {}).get("uuid")
168
+ features_list.append(features)
169
+
170
+ return pd.DataFrame(features_list)