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

Enhance URL scanning process by tracking attempted scans and handling permanent failures

Browse files
src/phising_detection/features/batch_url_scanner.py CHANGED
@@ -85,35 +85,56 @@ def load_and_balance_feature_groups(
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(
@@ -151,12 +172,61 @@ def filter_already_scanned(
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
 
@@ -167,9 +237,12 @@ def submit_url_batch(
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}")
@@ -182,8 +255,17 @@ def submit_url_batch(
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
@@ -191,7 +273,9 @@ def submit_url_batch(
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(
@@ -200,7 +284,7 @@ def retrieve_scan_results(
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
 
@@ -212,12 +296,15 @@ def retrieve_scan_results(
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()
@@ -237,11 +324,16 @@ def retrieve_scan_results(
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
@@ -250,13 +342,16 @@ def retrieve_scan_results(
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(
@@ -384,12 +479,14 @@ def main(
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
@@ -430,7 +527,7 @@ def main(
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",
@@ -440,7 +537,7 @@ def main(
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
@@ -449,6 +546,7 @@ def main(
449
  )
450
  else:
451
  scan_results = []
 
452
  logger.warning("No URLs were successfully submitted")
453
 
454
  # Process and upload results
@@ -464,6 +562,45 @@ def main(
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
 
85
  def get_already_scanned_urls(
86
  project,
87
  feature_group_name: str,
88
+ version: int,
89
+ attempted_fg_name: str = None,
90
+ attempted_fg_version: int = 1
91
  ) -> set:
92
  """
93
+ Retrieve URLs that have already been scanned or attempted from feature groups.
94
 
95
  Args:
96
  project: Hopsworks project object
97
+ feature_group_name: Name of output feature group (successful scans)
98
  version: Feature group version
99
+ attempted_fg_name: Name of attempted scans tracking feature group (optional)
100
+ attempted_fg_version: Version of attempted scans feature group
101
 
102
  Returns:
103
+ Set of URLs that have already been scanned or attempted (empty set if FG doesn't exist)
104
  """
105
+ all_attempted_urls = set()
106
+
107
+ # Check successful scans
108
  try:
109
+ logger.info(f"Checking for successful scans in {feature_group_name} v{version}")
110
  existing_df = hw.read_feature_group(project, feature_group_name, version)
111
 
112
  if 'url' in existing_df.columns:
113
  scanned_urls = set(existing_df['url'].dropna().unique())
114
+ logger.info(f"Found {len(scanned_urls)} successfully scanned URLs")
115
+ all_attempted_urls.update(scanned_urls)
116
  else:
117
  logger.warning(f"Feature group exists but no 'url' column found")
 
118
 
119
  except Exception as e:
120
  logger.info(f"Output feature group not found or error reading it: {e}")
121
+
122
+ # Check attempted scans (including failed ones)
123
+ if attempted_fg_name:
124
+ try:
125
+ logger.info(f"Checking for attempted scans in {attempted_fg_name} v{attempted_fg_version}")
126
+ attempted_df = hw.read_feature_group(project, attempted_fg_name, attempted_fg_version)
127
+
128
+ if 'url' in attempted_df.columns:
129
+ attempted_urls = set(attempted_df['url'].dropna().unique())
130
+ logger.info(f"Found {len(attempted_urls)} attempted URLs (including failures)")
131
+ all_attempted_urls.update(attempted_urls)
132
+
133
+ except Exception as e:
134
+ logger.info(f"Attempted scans feature group not found: {e}")
135
+
136
+ logger.info(f"Total URLs to skip (successful + attempted): {len(all_attempted_urls)}")
137
+ return all_attempted_urls
138
 
139
 
140
  def filter_already_scanned(
 
172
  return filtered_df
173
 
174
 
175
+ def record_attempted_scans(
176
+ project,
177
+ urls: List[str],
178
+ statuses: List[str],
179
+ uuids: List[str] = None,
180
+ feature_group_name: str = "attempted_scans",
181
+ version: int = 1
182
+ ):
183
+ """
184
+ Record attempted scans (both successful and failed) to prevent re-trying.
185
+
186
+ Args:
187
+ project: Hopsworks project object
188
+ urls: List of URLs that were attempted
189
+ statuses: List of status strings ('submitted', 'success', 'failed', 'timeout')
190
+ uuids: Optional list of scan UUIDs
191
+ feature_group_name: Name of tracking feature group
192
+ version: Feature group version
193
+ """
194
+ if not urls:
195
+ return
196
+
197
+ import datetime
198
+
199
+ # Create DataFrame of attempted scans
200
+ attempted_df = pd.DataFrame({
201
+ 'url': urls,
202
+ 'status': statuses,
203
+ 'timestamp': [datetime.datetime.now()] * len(urls)
204
+ })
205
+
206
+ logger.info(f"Recording {len(attempted_df)} attempted scans")
207
+
208
+ try:
209
+ hw.upload_dataframe_to_feature_group(
210
+ project=project,
211
+ df=attempted_df,
212
+ feature_group_name=feature_group_name,
213
+ version=version,
214
+ description="Tracking of all attempted URL scans (successful and failed)",
215
+ primary_key=["url"],
216
+ online_enabled=False,
217
+ write_options={"wait_for_job": False} # Don't wait, just record async
218
+ )
219
+ logger.info(f"Recorded attempted scans to {feature_group_name}")
220
+ except Exception as e:
221
+ logger.warning(f"Failed to record attempted scans: {e}")
222
+
223
+
224
  def submit_url_batch(
225
  client: URLScanClient,
226
  urls: List[str],
227
  visibility: str = "public",
228
  delay_between_submissions: float = 1.0
229
+ ) -> tuple[List[Dict[str, Any]], List[Dict[str, str]]]:
230
  """
231
  Submit a batch of URLs for scanning (without waiting for results).
232
 
 
237
  delay_between_submissions: Delay in seconds between submissions to respect rate limits
238
 
239
  Returns:
240
+ Tuple of (submissions list, permanent_failures list)
241
+ - submissions: List of submission dicts with 'url', 'uuid', 'api'
242
+ - permanent_failures: List of {'url', 'error'} for non-retryable failures
243
  """
244
  submissions = []
245
+ permanent_failures = []
246
 
247
  for i, url in enumerate(urls, 1):
248
  logger.info(f"Submitting URL {i}/{len(urls)}: {url}")
 
255
  logger.info(f"Successfully submitted: {url} (UUID: {submission.get('uuid')})")
256
 
257
  except URLScanError as e:
258
+ error_msg = str(e).lower()
259
+ # Check if this is a permanent failure or temporary (rate limit)
260
+ if "rate limit" in error_msg or "429" in error_msg:
261
+ logger.warning(f"Rate limit hit for {url} - will retry later")
262
+ # Don't add to permanent failures - this can be retried
263
+ elif "bad request" in error_msg or "invalid" in error_msg:
264
+ logger.error(f"Permanent failure for {url}: {e}")
265
+ permanent_failures.append({'url': url, 'error': str(e)})
266
+ else:
267
+ logger.error(f"Failed to submit {url}: {e}")
268
+ # Unknown error - don't record as permanent for safety
269
  continue
270
 
271
  # Rate limiting: wait between submissions
 
273
  time.sleep(delay_between_submissions)
274
 
275
  logger.info(f"Submitted {len(submissions)}/{len(urls)} URLs successfully")
276
+ if permanent_failures:
277
+ logger.info(f"Permanent failures: {len(permanent_failures)}")
278
+ return submissions, permanent_failures
279
 
280
 
281
  def retrieve_scan_results(
 
284
  max_wait: int = 300,
285
  poll_interval: int = 10,
286
  initial_wait: int = 30
287
+ ) -> tuple[List[Dict[str, Any]], List[Dict[str, str]]]:
288
  """
289
  Retrieve results for submitted scans.
290
 
 
296
  initial_wait: Time to wait before first poll attempt (seconds)
297
 
298
  Returns:
299
+ Tuple of (results list, permanent_failures list)
300
+ - results: List of scan results (successful retrievals only)
301
+ - permanent_failures: List of {'url', 'error'} for non-retryable failures (excludes timeouts)
302
  """
303
  logger.info(f"Waiting {initial_wait} seconds for scans to complete...")
304
  time.sleep(initial_wait)
305
 
306
  results = []
307
+ permanent_failures = []
308
  pending_submissions = submissions.copy()
309
 
310
  start_time = time.time()
 
324
  logger.info(f"Retrieved result for {url} (UUID: {uuid})")
325
 
326
  except URLScanError as e:
327
+ error_msg = str(e).lower()
328
+ if "not found or not ready" in error_msg:
329
  # Scan not ready yet, keep in pending list
330
  still_pending.append(submission)
331
+ elif "dns" in error_msg or "domain" in error_msg or "unreachable" in error_msg:
332
+ # Permanent DNS/domain failures - won't work on retry
333
+ logger.error(f"Permanent failure for {url} (UUID: {uuid}): {e}")
334
+ permanent_failures.append({'url': url, 'error': str(e)})
335
  else:
336
+ # Other error - log but don't record as permanent for safety
337
  logger.error(f"Failed to retrieve result for {url} (UUID: {uuid}): {e}")
338
 
339
  pending_submissions = still_pending
 
342
  logger.info(f"Still waiting for {len(pending_submissions)} scans. Waiting {poll_interval}s...")
343
  time.sleep(poll_interval)
344
 
345
+ # Timeouts are NOT permanent failures - scans might just be slow
346
  if pending_submissions:
347
+ logger.warning(f"Timeout: {len(pending_submissions)} scans did not complete in time (will retry later)")
348
  for submission in pending_submissions:
349
  logger.warning(f" - {submission.get('url')} (UUID: {submission.get('uuid')})")
350
 
351
  logger.info(f"Successfully retrieved {len(results)}/{len(submissions)} scan results")
352
+ if permanent_failures:
353
+ logger.info(f"Permanent failures: {len(permanent_failures)}")
354
+ return results, permanent_failures
355
 
356
 
357
  def process_and_upload_batch(
 
479
  sample_size=sample_size
480
  )
481
 
482
+ # Check for already scanned URLs (including failed attempts)
483
  logger.info("Checking for already scanned URLs...")
484
  scanned_urls = get_already_scanned_urls(
485
  project=project,
486
  feature_group_name=output_fg_name,
487
+ version=output_version,
488
+ attempted_fg_name="attempted_scans", # Track failed scans too
489
+ attempted_fg_version=1
490
  )
491
 
492
  # Filter out already scanned URLs
 
527
 
528
  # Phase 1: Submit all URLs for scanning
529
  logger.info(f"Submitting {len(batch_urls)} URLs for scanning...")
530
+ submissions, submission_failures = submit_url_batch(
531
  client=urlscan_client,
532
  urls=batch_urls,
533
  visibility="public",
 
537
  # Phase 2: Retrieve scan results
538
  if submissions:
539
  logger.info(f"Retrieving results for {len(submissions)} submitted scans...")
540
+ scan_results, retrieval_failures = retrieve_scan_results(
541
  client=urlscan_client,
542
  submissions=submissions,
543
  max_wait=300, # 5 minutes total wait time
 
546
  )
547
  else:
548
  scan_results = []
549
+ retrieval_failures = []
550
  logger.warning("No URLs were successfully submitted")
551
 
552
  # Process and upload results
 
562
  else:
563
  logger.warning(f"No successful scans in batch {batch_num + 1}, skipping upload")
564
 
565
+ # Record ONLY successful scans and permanent failures (not timeouts or rate limits)
566
+ successful_urls = {result.get('original_url') or result.get('task', {}).get('url')
567
+ for result in scan_results}
568
+
569
+ attempted_urls = []
570
+ attempted_statuses = []
571
+ attempted_uuids = []
572
+
573
+ # Record successful scans
574
+ for result in scan_results:
575
+ url = result.get('original_url') or result.get('task', {}).get('url')
576
+ uuid = result.get('task', {}).get('uuid')
577
+ attempted_urls.append(url)
578
+ attempted_statuses.append('success')
579
+ attempted_uuids.append(uuid)
580
+
581
+ # Record permanent failures from submission (invalid URLs, etc.)
582
+ for failure in submission_failures:
583
+ attempted_urls.append(failure['url'])
584
+ attempted_statuses.append('failed_permanent')
585
+ attempted_uuids.append(None)
586
+
587
+ # Record permanent failures from retrieval (DNS errors, etc.)
588
+ for failure in retrieval_failures:
589
+ attempted_urls.append(failure['url'])
590
+ attempted_statuses.append('failed_permanent')
591
+ attempted_uuids.append(None)
592
+
593
+ # Only record if we have something to record
594
+ if attempted_urls:
595
+ record_attempted_scans(
596
+ project=project,
597
+ urls=attempted_urls,
598
+ statuses=attempted_statuses,
599
+ uuids=attempted_uuids,
600
+ feature_group_name="attempted_scans",
601
+ version=1
602
+ )
603
+
604
  # Wait between batches to respect rate limits
605
  if batch_num < total_batches - 1:
606
  wait_time = 10