Skip to content

Commit 6e9bc8c

Browse files
authored
Merge pull request #33 from intezer/feature/notify-alert
feat: add notify alert command
2 parents 0f9506c + a4faedc commit 6e9bc8c

File tree

10 files changed

+359
-13
lines changed

10 files changed

+359
-13
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
runs-on: ubuntu-24.04
1515
strategy:
1616
matrix:
17-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
17+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
1818

1919
steps:
2020
- uses: actions/checkout@v3

CHANGES

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
1.13.0
2+
-----
3+
- Add command for notifying alerts by CSV
4+
- Drop support for Python 3.8
5+
- Add support for Python 3.13
6+
17
1.12.0
28
-----
39
- Add command aliases with dashes (e.g., analyze-by-list) while maintaining backward compatibility with underscore versions

intezer_analyze_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.12.0'
1+
__version__ = '1.13.0'

intezer_analyze_cli/cli.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,42 @@ def upload_emails_in_directory(emails_root_directory: str, ignore_directory_coun
342342
click.echo('Unexpected error occurred, please contact us at support@intezer.com '
343343
f'and attach the log file in {utilities.log_file_path}')
344344

345+
346+
@main_cli.group('alerts', short_help='Alert management commands')
347+
def alerts():
348+
"""Alert management commands for Intezer Analyze."""
349+
pass
350+
351+
352+
@alerts.command('notify-from-csv', short_help='Notify alerts from CSV file')
353+
@click.argument('csv_path', type=click.Path(exists=True, dir_okay=False))
354+
def notify_from_csv(csv_path: str):
355+
"""Notify alerts from a CSV file containing alert IDs and environments.
356+
357+
\b
358+
CSV_PATH: Path to CSV file with 'id' and 'environment' columns.
359+
360+
\b
361+
CSV Format:
362+
The CSV file should have the following columns:
363+
- id: Alert ID (required)
364+
- environment: Environment name (required)
365+
366+
\b
367+
Examples:
368+
Notify alerts from CSV file:
369+
$ intezer-analyze alerts notify-from-csv ~/alerts.csv
370+
"""
371+
try:
372+
create_global_api()
373+
commands.notify_alerts_from_csv_command(csv_path=csv_path)
374+
except click.Abort:
375+
raise
376+
except Exception:
377+
logger.exception('Unexpected error occurred')
378+
click.echo('Unexpected error occurred, please contact us at support@intezer.com '
379+
f'and attach the log file in {utilities.log_file_path}')
380+
345381
if __name__ == '__main__':
346382
try:
347383
main_cli()

intezer_analyze_cli/commands.py

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import csv
12
import logging
23
import os
34
from io import BytesIO
5+
from typing import Dict
6+
from typing import List
47
from typing import Optional
58
from email.utils import parsedate_to_datetime
69

@@ -181,7 +184,7 @@ def index_by_txt_file_command(path: str, index_as: str, family_name: str):
181184
except sdk_errors.IntezerError as e:
182185
index_exceptions.append(f'Failed to index hash: {sha256} error: {e}')
183186
logger.exception('Failed to index hash', extra=dict(sha256=sha256))
184-
except sdk_errors.IndexFailed:
187+
except sdk_errors.IndexFailed as e:
185188
index_exceptions.append(f'Failed to index hash: {sha256} error: {e}')
186189
logger.exception('Failed to index hash', extra=dict(sha256=sha256))
187190
index_progress.update(1)
@@ -355,7 +358,7 @@ def send_phishing_emails_from_directory_command(path: str,
355358
unsupported_number = 0
356359
emails_dates = []
357360

358-
for root, dirs, files in os.walk(path):
361+
for root, _, files in os.walk(path):
359362
files = [f for f in files if not is_hidden(os.path.join(root, f))]
360363

361364
number_of_files = len(files)
@@ -385,7 +388,7 @@ def send_phishing_emails_from_directory_command(path: str,
385388
except Exception:
386389
continue
387390

388-
except sdk_errors.IntezerError as ex:
391+
except sdk_errors.IntezerError:
389392
logger.exception('Error while analyzing directory')
390393
failed_number += 1
391394
except Exception:
@@ -449,3 +452,98 @@ def _create_analysis_id_file(directory: str, analysis_id: str):
449452
logger.exception('Could not create analysis_id.txt file', extra=dict(directory=directory))
450453
click.echo(f'Could not create analysis_id.txt file in {directory}')
451454
raise
455+
456+
457+
def notify_alerts_from_csv_command(csv_path: str):
458+
"""
459+
Notify alerts from a CSV file containing alert IDs and environments.
460+
461+
:param csv_path: Path to CSV file with 'id' and 'environment' columns
462+
"""
463+
try:
464+
alerts_data = _read_alerts_from_csv(csv_path)
465+
success_number = 0
466+
failed_number = 0
467+
no_channels_number = 0
468+
469+
with click.progressbar(length=len(alerts_data),
470+
label='Notifying alerts',
471+
show_pos=True,
472+
width=0) as progressbar:
473+
for alert_data in alerts_data:
474+
alert_id = alert_data['id']
475+
environment = alert_data['environment']
476+
477+
try:
478+
alert = Alert(alert_id=alert_id, environment=environment)
479+
notified_channels = alert.notify()
480+
481+
if notified_channels:
482+
success_number += 1
483+
else:
484+
no_channels_number += 1
485+
logger.info('Alert notified but no channels configured',
486+
extra=dict(alert_id=alert_id, environment=environment))
487+
488+
except sdk_errors.AlertNotFoundError:
489+
click.echo(f'Alert {alert_id} not found')
490+
logger.info('Alert not found', extra=dict(alert_id=alert_id, environment=environment))
491+
failed_number += 1
492+
except sdk_errors.AlertInProgressError:
493+
click.echo(f'Alert {alert_id} is still in progress')
494+
logger.info('Alert in progress', extra=dict(alert_id=alert_id, environment=environment))
495+
failed_number += 1
496+
except sdk_errors.IntezerError as e:
497+
logger.exception('Error while notifying alert', extra=dict(alert_id=alert_id, environment=environment))
498+
failed_number += 1
499+
except Exception:
500+
logger.exception('Unexpected error while notifying alert', extra=dict(alert_id=alert_id, environment=environment))
501+
failed_number += 1
502+
503+
progressbar.update(1)
504+
505+
if success_number > 0:
506+
click.echo(f'{success_number} alerts notified successfully')
507+
508+
if no_channels_number > 0:
509+
click.echo(f'{no_channels_number} alerts didn\'t triggered any notification')
510+
511+
if failed_number > 0:
512+
click.echo(f'{failed_number} alerts failed to notify')
513+
514+
except IOError:
515+
click.echo(f'No read permissions for {csv_path}')
516+
logger.exception('Error reading CSV file', extra=dict(path=csv_path))
517+
raise click.Abort()
518+
except Exception:
519+
logger.exception('Unexpected error occurred while processing CSV file', extra=dict(path=csv_path))
520+
click.echo('Unexpected error occurred while processing CSV file')
521+
raise click.Abort()
522+
523+
524+
def _read_alerts_from_csv(csv_path: str) -> List[Dict[str, Optional[str]]]:
525+
"""
526+
Read alert IDs and environments from CSV file.
527+
528+
:param csv_path: Path to CSV file
529+
:return: List of dictionaries with 'id' and 'environment' keys
530+
:raises ValueError: If required columns are missing
531+
"""
532+
alerts_data = []
533+
534+
with open(csv_path, 'r', newline='', encoding='utf-8-sig') as csvfile:
535+
reader = csv.DictReader(csvfile)
536+
537+
if not reader.fieldnames or 'id' not in reader.fieldnames:
538+
raise ValueError('CSV file must contain an "id" column')
539+
540+
for row in reader:
541+
alert_id = row['id'].strip()
542+
environment = row['environment'].strip()
543+
544+
alerts_data.append({'id': alert_id, 'environment': environment})
545+
546+
if not alerts_data:
547+
raise ValueError('No valid alert data found in CSV file')
548+
549+
return alerts_data

requirements-prod.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
click==7.1.2
2-
intezer-sdk==1.21.9
2+
intezer-sdk==1.23.0

requirements-test.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
pytest>=6.2.5
2-
responses==0.17.0
1+
pytest>=8.4.1
2+
responses==0.25.8

setup.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ def rel(*xs):
1919

2020
install_requires = [
2121
'click==7.1.2',
22-
'intezer-sdk>=1.15.2,<2'
22+
'intezer-sdk>=1.23.0,<2'
2323
]
2424
tests_require = [
25-
'pytest==6.1.2',
26-
'responses==0.17.0'
25+
'pytest==8.4.1',
26+
'responses==0.25.8'
2727
]
2828

2929
with open('README.md') as f:
@@ -35,11 +35,11 @@ def rel(*xs):
3535
description='Client library for Intezer cloud service',
3636
author='Intezer Labs ltd.',
3737
classifiers=[
38-
'Programming Language :: Python :: 3.8',
3938
'Programming Language :: Python :: 3.9',
4039
'Programming Language :: Python :: 3.10',
4140
'Programming Language :: Python :: 3.11',
4241
'Programming Language :: Python :: 3.12'
42+
'Programming Language :: Python :: 3.13'
4343
],
4444
keywords='intezer',
4545
packages=['intezer_analyze_cli'],
@@ -52,6 +52,6 @@ def rel(*xs):
5252
license='Apache License v2',
5353
long_description=long_description,
5454
long_description_content_type='text/markdown',
55-
python_requires='>=3.8',
55+
python_requires='>=3.9',
5656
zip_safe=False
5757
)

tests/unit/cli_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,45 @@ def test_upload_multiple_eml_files_ignore(self, send_phishing_emails_from_direct
273273
ignore_directory_count_limit=True)
274274

275275

276+
class AlertsSpec(CliSpec):
277+
def setUp(self):
278+
super(AlertsSpec, self).setUp()
279+
280+
create_global_api_patcher = patch('intezer_analyze_cli.cli.create_global_api')
281+
self.create_global_api_patcher_mock = create_global_api_patcher.start()
282+
self.addCleanup(create_global_api_patcher.stop)
283+
284+
key_store.get_stored_api_key = MagicMock(return_value='api_key')
285+
286+
@patch('intezer_analyze_cli.commands.notify_alerts_from_csv_command')
287+
def test_alerts_notify_from_csv_success(self, notify_alerts_from_csv_command_mock):
288+
# Arrange
289+
with tempfile.TemporaryDirectory() as temp_dir:
290+
csv_file_path = os.path.join(temp_dir, 'alerts.csv')
291+
with open(csv_file_path, 'w') as f:
292+
f.write('id,environment\ntest-alert-1,production\ntest-alert-2,staging\n')
293+
294+
# Act
295+
result = self.runner.invoke(cli.main_cli,
296+
['alerts', 'notify-from-csv', csv_file_path])
297+
298+
# Assert
299+
self.assertEqual(result.exit_code, 0, result.exception)
300+
self.assertTrue(notify_alerts_from_csv_command_mock.called)
301+
notify_alerts_from_csv_command_mock.assert_called_once_with(csv_path=csv_file_path)
302+
303+
def test_alerts_notify_from_csv_file_not_exists_returns_error(self):
304+
# Arrange
305+
non_existent_file = '/non/existent/file.csv'
306+
307+
# Act
308+
result = self.runner.invoke(cli.main_cli,
309+
['alerts', 'notify-from-csv', non_existent_file])
310+
311+
# Assert
312+
self.assertEqual(result.exit_code, 2)
313+
self.assertTrue(b'does not exist' in result.stdout_bytes)
314+
276315
class CliIndexSpec(CliSpec):
277316
def setUp(self):
278317
super(CliIndexSpec, self).setUp()

0 commit comments

Comments
 (0)