scanner-cli 0.1.0rc6__py3-none-any.whl → 0.1.0rc8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.1
2
+ Name: scanner-cli
3
+ Version: 0.1.0rc8
4
+ Summary: Python command-line interface for Scanner API
5
+ Author: Scanner, Inc.
6
+ Author-email: support@scanner.dev
7
+ Requires-Python: >=3.10,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Requires-Dist: PyYAML (>=6.0.2,<7.0.0)
13
+ Requires-Dist: click (>=8.1.7,<9.0.0)
14
+ Requires-Dist: scanner-client (>=0.1.0rc8,<0.2.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ # scanner-cli
18
+
19
+ This is a Python CLI for the Scanner API.
20
+
21
+ ## Usage
22
+
23
+ To install the CLI, run
24
+
25
+ ```
26
+ pip install scanner-cli
27
+ ```
28
+
29
+ ## Environment Variables
30
+
31
+ For various commands, you will need to supply some Scanner configuration values.
32
+
33
+ For `run-tests`, `validate`, and `sync`, you will need these values:
34
+ - Scanner API URL
35
+ - Scanner API key
36
+
37
+ For `sync`, you will also need this value:
38
+ - Scanner Team ID
39
+
40
+ You can find these values in **Settings > General** and **Settings > API Keys**
41
+ in your Scanner account.
42
+
43
+ You can either set these values as environment variables:
44
+
45
+ ```
46
+ # required for run-tests, validate, sync commands:
47
+ export SCANNER_API_URL=<Scanner API URL>
48
+ export SCANNER_API_KEY=<Scanner API key>
49
+
50
+ # required for sync command:
51
+ export SCANNER_TEAM_ID=<Scanner Team ID>
52
+ ```
53
+
54
+ or provide them as arguments to the CLI:
55
+
56
+ ```
57
+ scanner-cli <command> \
58
+ --api-url=<Scanner API URL> \
59
+ --api-key=<Scanner API key> \
60
+ --team-id=<Scanner Team ID> \
61
+ ...
62
+ ```
63
+
64
+ ## Commands
65
+
66
+ Available commands are
67
+ - `run-tests` - run tests on detection rules as code
68
+ - `validate` - validate detection rules as code
69
+ - `sync` - sync detection rules to Scanner
70
+ - `migrate-elastic-rules` - migrate Elastic SIEM detection rules to Scanner YAML rules
71
+
72
+ ### `run-tests` and `validate`
73
+
74
+ To validate or run tests on files:
75
+
76
+ ```
77
+ scanner-cli validate -f detections/errors.yaml -f detections/unauthorized_logins.yaml
78
+ scanner-cli run-tests -f detections/errors.yaml -f detections/unauthorized_logins.yaml
79
+ ```
80
+
81
+ To validate or run tests on directories:
82
+
83
+ ```
84
+ scanner-cli validate -d detections
85
+ scanner-cli run-tests -d detections
86
+ ```
87
+
88
+ To recursively validate or run-tests on a directory, use `-r` or `--recursive`:
89
+
90
+ ```
91
+ scanner-cli validate -r -d detections
92
+ scanner-cli run-tests -r -d detections
93
+
94
+ scanner-cli validate --recursive -d detections
95
+ scanner-cli run-tests --recursive -d detections
96
+ ```
97
+
98
+ This will only validate or run tests on YAML files with the correct schema header.
99
+
100
+ A file or directory must be provided. Multiple files and/or directories can be provided.
101
+
102
+ ### `sync`
103
+
104
+ This command syncs detection rules to your Scanner account.
105
+
106
+ You can sync individual files or full directories.
107
+
108
+ ```
109
+ scanner-cli sync -f detections/errors.yaml -f detections/unauthorized_logins.yaml
110
+ scanner-cli sync -r -d detections
111
+ ```
112
+
113
+ #### `sync_key`
114
+
115
+ Each detection rule must have the `sync_key` field defined. This is a unique
116
+ identifier for the rule and can be any string you wish, as long as it is
117
+ unique.
118
+
119
+ #### `event_sink_keys`
120
+
121
+ If your detection rules have `event_sink_keys` defined, you must provide a sync
122
+ configuration file in YAML format using the `--sync-config-file` flag.
123
+
124
+ This file allows you to map `event_sink_keys` in your detection rules (eg.
125
+ `low_severity_alerts`, `high_severity_alerts`, etc.) to specific Scanner event
126
+ sinks (eg. "Custom webhook", "Slack alerts channel", etc.)
127
+
128
+ ```
129
+ scanner-cli sync --sync-config-file sync_config.yaml -r -d detections/
130
+ ```
131
+
132
+ Where is what the sync configuration file looks like:
133
+ ```
134
+ event_sink_keys:
135
+ low_severity_alerts:
136
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
137
+ medium_severity_alerts:
138
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
139
+ - sink_id: "62741ace-ea22-4255-8a36-b921a282d61e"
140
+ high_severity_alerts:
141
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
142
+ - sink_id: "62741ace-ea22-4255-8a36-b921a282d61e"
143
+ - sink_id: "2313eb73-0020-4814-9b35-864e3d3439e0"
144
+ ```
145
+
146
+ In this example, the `low_severity_alerts` event sink key is mapped to a single
147
+ event sink, while the `medium_severity_alerts` and `high_severity_alerts` event
148
+ sink keys are mapped to multiple event sinks.
149
+
150
+ For instance, you might send low severity alerts to a SOAR webhook, but send
151
+ medium and high severity alerts to a high-priority Slack channel and multiple
152
+ webhooks.
153
+
154
+ To find the Sink ID for a specific event sink, visit **Settings > Event Sinks**
155
+ and click on the event sink you want to use.
156
+
157
+ ### `migrate-elastic-rules`
158
+
159
+ This command migrates an `ndjson` file containing Elastic SIEM detection rules to Scanner YAML rules.
160
+
161
+ For each rule in the `ndjson` file, a YAML file will be created in the output directory.
162
+
163
+ ```
164
+ scanner-cli migrate-elastic-rules --elastic-files-rule elastic_rules.ndjson --output-dir scanner_rules/
165
+ ```
166
+
167
+ Optionally, you can provide a migrate config file in YAML format.
168
+
169
+ The migrate config file allows you to map Elastic Data View IDs to Scanner
170
+ query terms. This way, the queries in your Scanner detection rules can be more
171
+ selective about which logs they check against.
172
+
173
+ ```
174
+ scanner-cli migrate-elastic-rules \
175
+ --migrate-config-file elastic_to_scanner_config.yaml \
176
+ --elastic-files-rule elastic_rules.ndjson \
177
+ --output-dir scanner_rules/
178
+ ```
179
+
180
+ Here is an example migrate config file with a few mappings:
181
+ - Map from an Elastic Data View ID to a Scanner index.
182
+ - Map from an Elastic Data View wildcard name to a Scanner query for a specific
183
+ source type.
184
+ - Map from an Elastic Data View wildcard name to a Scanner query with multiple
185
+ terms.
186
+
187
+ ```
188
+ data_view_id_to_query_term:
189
+ e9874b58-5cee-40e0-8b49-5c3739572ea2: |-
190
+ @index={ 0f75b7fd-ea1e-421a-b4ee-007ac8570a20 | "application_logs" }
191
+ log-sources:aws:cloudtrail:*: |-
192
+ %ingest.source_type="aws:cloudtrail"
193
+ log-sources:non-prod-app-logs:*: |-
194
+ my_env=("staging" or "dev") and my_type="app_logs"
195
+ ```
196
+
@@ -0,0 +1,9 @@
1
+ src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ src/cli.py,sha256=1in8DDTlhYNXaaxvLwdYWiNHBr4VMnTBIzq59fLkhXc,9100
3
+ src/migrate/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ src/migrate/elastic.py,sha256=y_cn_3Y_t8NHwYcpAQQLfnCkEBSULoU_vxxGwk47QAc,8681
5
+ src/sync.py,sha256=WABCtvI6fpwXNJKo9lzafw3HkGYljpGjre5ywKnO0yA,7738
6
+ scanner_cli-0.1.0rc8.dist-info/METADATA,sha256=Dch4WTSbmhU152rnEBOAFDh1eYRbNlMXBi-vK3Qgd10,5890
7
+ scanner_cli-0.1.0rc8.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
8
+ scanner_cli-0.1.0rc8.dist-info/entry_points.txt,sha256=vqXMrIG6N6pY66bNf0y-gbUxbU8v5dXvuL3mV832Fh8,43
9
+ scanner_cli-0.1.0rc8.dist-info/RECORD,,
src/cli.py CHANGED
@@ -2,14 +2,17 @@
2
2
 
3
3
  import glob
4
4
  import os
5
- from typing import Any, Callable
5
+ from typing import Any, Callable, Optional
6
6
 
7
7
  import click
8
8
 
9
9
  from scanner_client import Scanner
10
10
  from scanner_client.detection_rule_yaml import validate_and_read_file
11
11
 
12
- _CLICK_OPTIONS = [
12
+ import src.migrate.elastic as elastic_cmd
13
+ import src.sync as sync_cmd
14
+
15
+ _DEFAULT_CLICK_OPTIONS = [
13
16
  click.option(
14
17
  "--api-url",
15
18
  envvar="SCANNER_API_URL",
@@ -24,14 +27,14 @@ _CLICK_OPTIONS = [
24
27
  "-f",
25
28
  "--file",
26
29
  "file_paths",
27
- help="File to validate. This must be .yml or .yaml file with the correct schema header.",
30
+ help="Detection rule file. This must be .yml or .yaml file with the correct schema header.",
28
31
  multiple=True,
29
32
  ),
30
33
  click.option(
31
34
  "-d",
32
35
  "--dir",
33
36
  "directories",
34
- help="Directory to validate. Only .yml or .yaml files with the correct schema header will be validated.",
37
+ help="Directory of detection rule files. Only .yml or .yaml files with the correct schema header will be processed.",
35
38
  multiple=True,
36
39
  ),
37
40
  click.option(
@@ -45,8 +48,8 @@ _CLICK_OPTIONS = [
45
48
  ]
46
49
 
47
50
 
48
- def _click_options(func) -> Callable[..., Any]:
49
- for option in reversed(_CLICK_OPTIONS):
51
+ def _default_click_options(func) -> Callable[..., Any]:
52
+ for option in reversed(_DEFAULT_CLICK_OPTIONS):
50
53
  func = option(func)
51
54
 
52
55
  return func
@@ -80,7 +83,7 @@ def _get_valid_files(file_paths: str, directories: str, recursive: bool) -> list
80
83
  return files
81
84
 
82
85
 
83
- def _validate_shared_options(api_url: str, api_key: str, file_paths: str, directories: str) -> None:
86
+ def _validate_default_options(api_url: str, api_key: str, file_paths: str, directories: str) -> None:
84
87
  if api_url is None:
85
88
  raise click.exceptions.UsageError(
86
89
  message=(
@@ -109,60 +112,156 @@ def cli():
109
112
 
110
113
 
111
114
  @cli.command()
112
- @_click_options
115
+ @_default_click_options
113
116
  def validate(api_url: str, api_key: str, file_paths: str, directories: str, recursive: bool):
114
- """ Validate detection rules """
115
- _validate_shared_options(api_url, api_key, file_paths, directories)
117
+ """ Validate detection rule files """
118
+ _validate_default_options(api_url, api_key, file_paths, directories)
116
119
 
117
120
  scanner_client = Scanner(api_url, api_key)
118
121
 
119
122
  files = _get_valid_files(file_paths, directories, recursive)
120
123
  click.echo(f'Validating {len(files)} {"file" if len(files) == 1 else "files"}')
121
124
 
125
+ any_failures: bool = False
126
+
122
127
  for file in files:
123
128
  try:
124
129
  result = scanner_client.detection_rule_yaml.validate(file)
125
130
 
126
131
  if result.is_valid:
127
- click.echo(f"{file}: " + click.style("Valid", fg="green"))
132
+ click.echo(f"{file}: " + click.style("OK", fg="green"))
128
133
  else:
134
+ any_failures = True
129
135
  click.echo(f"{file}: " + click.style(f"{result.error}", fg="red"))
130
136
  except Exception as e:
131
- click.echo(f"{file}: " + click.style(e, fg="red"))
132
-
137
+ any_failures = True
138
+ response = e.args[0]
139
+ click.echo(f"{file}: " + click.style(f"An exception occurred when attempting to validate file: {response.content}", fg="red"))
140
+
141
+ if any_failures:
142
+ # To make it so the CLI exits with a non-zero exit code
143
+ raise click.ClickException(
144
+ "`validate` failed for one or more files. See https://docs.scanner.dev/scanner/using-scanner/beta-features/detection-rules-as-code/writing-detection-rules for requirements."
145
+ )
146
+ else:
147
+ click.secho("All specified detection rule files are valid. Use `run-tests` to run the detection rule tests.", bold=True)
133
148
 
134
149
 
135
150
  @cli.command()
136
- @_click_options
151
+ @_default_click_options
137
152
  def run_tests(api_url: str, api_key: str, file_paths: str, directories: str, recursive: bool):
138
153
  """ Run detection rule tests """
139
- _validate_shared_options(api_url, api_key, file_paths, directories)
154
+ _validate_default_options(api_url, api_key, file_paths, directories)
140
155
 
141
156
  scanner_client = Scanner(api_url, api_key)
142
157
 
143
158
  files = _get_valid_files(file_paths, directories, recursive)
144
159
  click.echo(f'Running tests on {len(files)} {"file" if len(files) == 1 else "files"}')
145
160
 
161
+ any_validation_errors: bool = False
162
+ any_test_failures: bool = False
163
+
146
164
  for file in files:
165
+ click.secho(f"{file}", bold=True)
147
166
  try:
148
- response = scanner_client.detection_rule_yaml.run_tests(file)
149
- results = response.results.to_dict()
167
+ # Check for validation errors
168
+ validation_result = scanner_client.detection_rule_yaml.validate(file)
169
+ if not validation_result.is_valid:
170
+ any_validation_errors = True
171
+ click.secho(f"Validation error: {validation_result.error}", fg="red")
172
+ click.echo("")
173
+ continue
174
+
175
+ # Run detection rule tests
176
+ run_tests_response = scanner_client.detection_rule_yaml.run_tests(file)
177
+ results = run_tests_response.results.to_dict()
150
178
 
151
- click.secho(f"{file}", bold=True)
152
179
  if len(results) == 0:
153
180
  click.secho("No tests found", fg="yellow")
154
181
  else:
155
- for name, status in response.results.to_dict().items():
182
+ for name, status in results.items():
156
183
  if status == "Passed":
157
- click.echo(f"{name}: " + click.style("Passed", fg="green"))
184
+ click.echo(f"{name}: " + click.style("OK", fg="green"))
158
185
  else:
159
- click.echo(f"{name}: " + click.style("Failed", fg="red"))
160
-
161
- click.echo("")
186
+ any_test_failures = True
187
+ click.echo(f"{name}: " + click.style("Test failed", fg="red"))
162
188
  except Exception as e:
163
- click.secho(f"{file}", bold=True)
164
- click.secho(e, fg="red")
165
- click.echo("")
189
+ any_test_failures = True
190
+ response = e.args[0]
191
+ click.secho(f"An exception occurred when attempting to run tests: {response.content}", fg="red")
192
+
193
+ click.echo("")
194
+
195
+ error_messages = []
196
+ if any_validation_errors:
197
+ error_messages.append("Validation failed for one or more files. See https://docs.scanner.dev/scanner/using-scanner/beta-features/detection-rules-as-code/writing-detection-rules for requirements.")
198
+
199
+ if any_test_failures:
200
+ error_messages.append("`run-tests` failed for one or more files. See https://docs.scanner.dev/scanner/using-scanner/beta-features/detection-rules-as-code/cli#failing-tests for more information.")
201
+
202
+ if error_messages:
203
+ # To make it so the CLI exits with a non-zero exit code
204
+ raise click.ClickException("\n".join(error_messages))
205
+
206
+
207
+ @cli.command()
208
+ @_default_click_options
209
+ @click.option(
210
+ "--team-id",
211
+ envvar="SCANNER_TEAM_ID",
212
+ help="The team ID to which you want to sync the Scanner rules. Go to Settings > General to find the Team ID.",
213
+ required=True,
214
+ )
215
+ @click.option(
216
+ "--sync-config-file",
217
+ help="Optional. The path to the sync configuration file the CLI will use to sync detection rules to Scanner. (eg. contains event_sink_keys mappings, etc).",
218
+ required=False,
219
+ )
220
+ def sync(
221
+ api_url: str,
222
+ api_key: str,
223
+ file_paths: str,
224
+ directories: str,
225
+ recursive: bool,
226
+ team_id: str,
227
+ sync_config_file: Optional[str]
228
+ ):
229
+ """ Sync detection rules to Scanner """
230
+ _validate_default_options(api_url, api_key, file_paths, directories)
231
+ if team_id is None:
232
+ raise click.exceptions.UsageError(
233
+ message=(
234
+ "Pass --team-id option or set `SCANNER_TEAM_ID` environment variable."
235
+ )
236
+ )
237
+
238
+ scanner_client: Scanner = Scanner(api_url, api_key)
239
+ files: list[str] = _get_valid_files(file_paths, directories, recursive)
240
+ # Note: In the Scanner UI, the tenant_id is called Team ID.
241
+ sync_cmd.sync(scanner_client, files, team_id, sync_config_file)
242
+
243
+
244
+ @cli.command()
245
+ @click.option(
246
+ "-c",
247
+ "--migrate-config-file",
248
+ help="Optional. The path to the migration configuration file the CLI will use to migrate Elastic rules (eg. contains data_view_id_to_query_term mappings, etc).",
249
+ )
250
+ @click.option(
251
+ "-f",
252
+ "--elastic-rules-file",
253
+ help="The path to the Elastic detection rules ndjson file the CLI will migrate.",
254
+ required=True,
255
+ )
256
+ @click.option(
257
+ "-o",
258
+ "--output-dir",
259
+ help="The directory where the migrated rules will be saved. Will create one YAML file per rule.",
260
+ required=True,
261
+ )
262
+ def migrate_elastic_rules(migrate_config_file: Optional[str], elastic_rules_file: str, output_dir: str):
263
+ """ Migrate Elastic SIEM rules to Scanner rules """
264
+ elastic_cmd.migrate_elastic_rules(migrate_config_file, elastic_rules_file, output_dir)
166
265
 
167
266
 
168
267
  if __name__ == "__main__":
File without changes
src/migrate/elastic.py ADDED
@@ -0,0 +1,235 @@
1
+ import json
2
+ import os
3
+ from typing import Any, Optional
4
+
5
+ import click
6
+ import yaml
7
+ from yaml import Dumper
8
+
9
+
10
+ class YamlStrLiteral(str):
11
+ """
12
+ A class to represent a YAML literal string, which may have multiple lines.
13
+ Helps us to represent a string as a literal block scalar in YAML.
14
+ """
15
+ pass
16
+
17
+
18
+ def _represent_literal(dumper: Dumper, data: YamlStrLiteral) -> Any:
19
+ return dumper.represent_scalar(
20
+ yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG,
21
+ data,
22
+ style="|"
23
+ )
24
+
25
+
26
+ yaml.add_representer(YamlStrLiteral, _represent_literal)
27
+
28
+
29
+ def _validate_options(migrate_config_file: Optional[str], elastic_rules_file: str, output_dir: str):
30
+ if migrate_config_file and not os.path.exists(migrate_config_file):
31
+ raise click.exceptions.UsageError(
32
+ message=(
33
+ "Config file not found."
34
+ )
35
+ )
36
+ if not os.path.exists(elastic_rules_file):
37
+ raise click.exceptions.UsageError(
38
+ message=(
39
+ "Elastic rules file not found."
40
+ )
41
+ )
42
+ if not os.path.isdir(output_dir):
43
+ raise click.exceptions.UsageError(
44
+ message=(
45
+ "Output directory not found."
46
+ )
47
+ )
48
+
49
+
50
+ def _migrate_to_scanner_yml(elastic_detection_rule: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
51
+ name: str = elastic_detection_rule.get('name', '')
52
+ description: YamlStrLiteral = YamlStrLiteral(elastic_detection_rule.get('note', ''))
53
+ enabled: bool = elastic_detection_rule.get('enabled', True)
54
+ severity: str = _capitalize_first_letter(elastic_detection_rule.get('severity', 'unknown'))
55
+ query_text: YamlStrLiteral = YamlStrLiteral(_get_query_text_for_detection_rule(elastic_detection_rule, config))
56
+ time_range_s: int = 300
57
+ run_frequency_s: int = 60
58
+ event_sink_key: str = _migrate_severity_to_event_sink_key(severity)
59
+ file_name: str = _generate_rule_file_name(name)
60
+ scanner_rule: dict[str, Any] = {
61
+ 'sync_key': file_name,
62
+ 'name': name,
63
+ 'description': description,
64
+ 'enabled': enabled,
65
+ 'severity': severity,
66
+ 'query_text': query_text,
67
+ 'time_range_s': time_range_s,
68
+ 'run_frequency_s': run_frequency_s,
69
+ 'event_sink_keys': [event_sink_key],
70
+ }
71
+ yaml_content: str = yaml.dump(
72
+ scanner_rule,
73
+ default_flow_style=False,
74
+ sort_keys=False
75
+ )
76
+ yaml_content = yaml_content.replace('\\', '\\\\')
77
+ yaml_content = "# schema: https://scanner.dev/schema/scanner-detection-rule.v1.json\n" + yaml_content
78
+ return {
79
+ 'file_name': file_name,
80
+ 'yaml_content': yaml_content,
81
+ }
82
+
83
+
84
+ def _generate_rule_file_name(name: str) -> str:
85
+ chars = []
86
+ for c in name:
87
+ if c.isalnum():
88
+ chars.append(c.lower())
89
+ elif chars and chars[-1] != ' ':
90
+ chars.append(' ')
91
+ file_name = ''.join(chars)
92
+ file_name = file_name.strip()
93
+ file_name = file_name.replace(' ', '_')
94
+ return f"{file_name}.yml"
95
+
96
+
97
+ def _migrate_severity_to_event_sink_key(severity: str) -> str:
98
+ return f"{severity.lower()}_severity_alerts"
99
+
100
+
101
+ def _capitalize_first_letter(text: str) -> str:
102
+ if text:
103
+ return text[0].upper() + text[1:]
104
+ return text
105
+
106
+
107
+ def _get_query_text_for_detection_rule(elastic_detection_rule: dict[str, Any], config: dict[str, Any]) -> str:
108
+ query_parts: list[str] = []
109
+ data_view_id: Optional[str] = elastic_detection_rule.get('data_view_id')
110
+ data_view_id_query_term: Optional[str] = config.get('data_view_id_to_query_term', {}).get(data_view_id)
111
+ if data_view_id_query_term:
112
+ query_parts.append(data_view_id_query_term)
113
+ main_query: Optional[str] = elastic_detection_rule.get('query')
114
+ if main_query:
115
+ query_parts.append(main_query)
116
+ filters: list[dict[str, Any]] = elastic_detection_rule.get('filters', [])
117
+ for filter in filters:
118
+ query_text = _get_query_text_for_filter(filter)
119
+ if query_text:
120
+ query_parts.append(query_text)
121
+ return "\n".join(query_parts)
122
+
123
+
124
+ def _get_query_text_for_filter(f: dict[str, Any]) -> Optional[str]:
125
+ should_negate: bool = f.get('meta', {}).get('negate', False)
126
+ filter_query: dict[str, Any] = f.get('query', {})
127
+ query_text: Optional[str] = _get_query_text_for_filter_query(filter_query)
128
+ if query_text is None:
129
+ return None
130
+ if should_negate:
131
+ query_text = f"not {query_text}"
132
+ return query_text
133
+
134
+
135
+ def _get_query_text_for_filter_query(filter_query: dict[str, Any]) -> Optional[str]:
136
+ match_phrase_query: Optional[dict[str, Any]] = filter_query.get('match_phrase')
137
+ bool_query: Optional[dict[str, Any]] = filter_query.get('bool')
138
+ exists_query: Optional[dict[str, Any]] = filter_query.get('exists')
139
+ if match_phrase_query:
140
+ return _get_query_text_for_match_phrase_query(match_phrase_query)
141
+ elif bool_query:
142
+ return _get_query_text_for_bool_query(bool_query)
143
+ elif exists_query:
144
+ return _get_query_text_for_exists_query(exists_query)
145
+ return None
146
+
147
+
148
+ def _get_query_text_for_match_phrase_query(match_phrase_query: dict[str, Any]) -> str:
149
+ query_parts: list[str] = []
150
+ for field, value in match_phrase_query.items():
151
+ query_parts.append(f"{field}: \"{value}\"")
152
+ has_multiple_parts: bool = len(query_parts) > 1
153
+ query_text: str = "\n".join(query_parts)
154
+ if has_multiple_parts:
155
+ return f"({query_text})"
156
+ else:
157
+ return query_text
158
+
159
+
160
+ def _get_query_text_for_bool_query(bool_query: dict[str, Any]) -> Optional[str]:
161
+ minimum_should_match: Optional[int] = bool_query.get('minimum_should_match')
162
+ if minimum_should_match != 1:
163
+ # Print error message that this is unsupported
164
+ click.secho("Unsupported filter: Only support bool minimum_should_match = 1", fg="red")
165
+ return None
166
+ query_parts: list[str] = []
167
+ should_clauses: list[dict[str, Any]] = bool_query.get('should', [])
168
+ for should_clause in should_clauses:
169
+ query_text: Optional[str] = _get_query_text_for_filter_query(should_clause)
170
+ if query_text is None:
171
+ return None
172
+ query_parts.append(query_text)
173
+ has_multiple_parts: bool = len(query_parts) > 1
174
+ returned_query_text: str = " or ".join(query_parts)
175
+ if has_multiple_parts:
176
+ return f"({returned_query_text})"
177
+ else:
178
+ return returned_query_text
179
+
180
+
181
+ def _get_query_text_for_exists_query(exists_query: dict[str, Any]) -> Optional[str]:
182
+ field: Optional[str] = exists_query.get('field')
183
+ if field:
184
+ return f"{field}: *"
185
+ else:
186
+ return None
187
+
188
+
189
+ def migrate_elastic_rules(migrate_config_file: Optional[str], elastic_rules_file: str, output_dir: str):
190
+ _validate_options(migrate_config_file, elastic_rules_file, output_dir)
191
+
192
+ any_failures: bool = False
193
+
194
+ count: int = 0
195
+ try:
196
+ config: dict[str, Any] = {}
197
+ if migrate_config_file:
198
+ with open(migrate_config_file, 'r') as file:
199
+ config = yaml.safe_load(file)
200
+ with open(elastic_rules_file, 'r') as file:
201
+ for raw_line in file:
202
+ line: str = raw_line.strip()
203
+ if not line: # Skip empty lines
204
+ continue
205
+ try:
206
+ elastic_detection_rule: dict[str, Any] = json.loads(line)
207
+ migrated: dict[str, Any] = _migrate_to_scanner_yml(elastic_detection_rule, config)
208
+ output_file_path: str = f"{output_dir}/{migrated['file_name']}"
209
+ with open(output_file_path, 'w') as output_file:
210
+ output_file.write(migrated['yaml_content'])
211
+ count += 1
212
+ click.echo(click.style("Migrated", fg="green") + f": {output_file_path}")
213
+ except json.JSONDecodeError as e:
214
+ click.secho(f"Error parsing JSON on line: {line}", fg="red")
215
+ click.secho(f"Error details: {e}", fg="red")
216
+ click.echo("")
217
+ any_failures = True
218
+ continue
219
+ except yaml.YAMLError as e:
220
+ any_failures = True
221
+ click.secho(f"YAML Error: {e}", fg="red")
222
+ click.echo("")
223
+ except BaseException as e:
224
+ any_failures = True
225
+ click.secho(f"Error: {e}", fg="red")
226
+ click.echo("")
227
+
228
+ click.secho(f"Successfully migrated {count} rules", fg="green")
229
+ click.echo(click.style("Output directory", fg="green") + f": {output_dir}")
230
+
231
+ if any_failures:
232
+ # To make it so the CLI exits with a non-zero exit code
233
+ raise click.ClickException(
234
+ "migrate-elastic-rules failed for one or more rules"
235
+ )
src/sync.py ADDED
@@ -0,0 +1,199 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Optional
3
+ import typing
4
+
5
+ import click
6
+ import yaml
7
+ import sys
8
+
9
+ from scanner_client import Scanner, NotFound
10
+ from scanner_client.detection_rule import string_to_detection_severity, DetectionSeverity
11
+ from scanner_client.detection_rule_yaml import validate_and_read_file
12
+
13
+
14
+ @dataclass
15
+ class DetectionRuleConfig:
16
+ file: str
17
+ detection_rule: dict[str, Any]
18
+ event_sink_ids: list[str]
19
+
20
+
21
+ def _get_detection_rule_id_for_sync_key(
22
+ scanner_client: Scanner,
23
+ sync_key: str,
24
+ ) -> Optional[str]:
25
+ try:
26
+ detection_rule = scanner_client.detection_rule.get_by_sync_key(sync_key)
27
+ return detection_rule.id
28
+ except NotFound:
29
+ return None
30
+
31
+
32
+ def _read_sync_config_from_file(sync_config_file: str) -> dict[str, Any]:
33
+ with open(sync_config_file, 'r') as f:
34
+ contents = f.read()
35
+ any_sync_config: Any = yaml.safe_load(contents)
36
+ if not isinstance(any_sync_config, dict):
37
+ raise click.exceptions.UsageError(
38
+ message=(
39
+ "sync_config_file must be a YAML file with a dictionary at the root"
40
+ )
41
+ )
42
+ sync_config: dict[str, Any] = any_sync_config
43
+ return sync_config
44
+
45
+
46
+ def _get_event_sink_ids(detection_rule: dict[str, Any], sync_config: dict[str, Any]) -> list[str]:
47
+ event_sink_ids: list[str] = []
48
+ detection_rule_event_sink_keys = detection_rule.get('event_sink_keys', [])
49
+ sync_config_event_sink_keys = sync_config.get('event_sink_keys', {})
50
+ if not isinstance(sync_config_event_sink_keys, dict):
51
+ raise click.exceptions.UsageError(
52
+ message=(
53
+ "Please supply a sync-config-file where event_sink_keys is present"
54
+ )
55
+ )
56
+ for event_sink_key in detection_rule_event_sink_keys:
57
+ mapped_event_sinks = sync_config_event_sink_keys.get(event_sink_key)
58
+ if not isinstance(mapped_event_sinks, list):
59
+ raise click.exceptions.UsageError(
60
+ message=(
61
+ f"Please supply a sync-config-file where {event_sink_key} is present under event_sink_keys."
62
+ )
63
+ )
64
+ for mapped_event_sink in mapped_event_sinks:
65
+ if not isinstance(mapped_event_sink, dict):
66
+ raise click.exceptions.UsageError(
67
+ message=(
68
+ f"Event sink key {event_sink_key} must be a list of dictionaries in sync config file."
69
+ )
70
+ )
71
+ event_sink_id = mapped_event_sink.get('sink_id')
72
+ if not event_sink_id:
73
+ raise click.exceptions.UsageError(
74
+ message=(
75
+ f"Event sink key {event_sink_key} must have a sink_id in sync config file."
76
+ )
77
+ )
78
+ event_sink_ids.append(event_sink_id)
79
+ return event_sink_ids
80
+
81
+
82
+ def sync(scanner_client: Scanner, files: list[str], tenant_id: str, sync_config_file: Optional[str]):
83
+ click.echo(f'Syncing {len(files)} detection rule {"file" if len(files) == 1 else "files"} to Scanner Team {tenant_id}')
84
+
85
+ any_failures: bool = False
86
+
87
+ sync_config: dict[str, Any] = {}
88
+ if sync_config_file:
89
+ sync_config = _read_sync_config_from_file(sync_config_file)
90
+
91
+ click.echo("Validating rules before syncing...")
92
+ detection_rule_configs: list[DetectionRuleConfig] = []
93
+ for file in files:
94
+ try:
95
+ result = scanner_client.detection_rule_yaml.validate(file)
96
+
97
+ if result.is_valid:
98
+ click.echo(f"{file}: " + click.style("Valid", fg="green"))
99
+ else:
100
+ any_failures = True
101
+ click.echo(f"{file}: " + click.style(f"{result.error}", fg="red"))
102
+ continue
103
+
104
+ contents: str = validate_and_read_file(file)
105
+ detection_rule: dict[str, Any] = yaml.safe_load(contents)
106
+
107
+ if not detection_rule.get('sync_key'):
108
+ any_failures = True
109
+ click.secho(f"Error: sync_key not found in {file}", fg="red")
110
+ continue
111
+
112
+ # Will raise an exception if the event sink keys are not valid
113
+ event_sink_ids: list[str] = _get_event_sink_ids(detection_rule, sync_config)
114
+
115
+ detection_rule_configs.append(DetectionRuleConfig(
116
+ file=file,
117
+ detection_rule=detection_rule,
118
+ event_sink_ids=event_sink_ids,
119
+ ))
120
+ except Exception as e:
121
+ any_failures = True
122
+ click.echo(f"{file}: " + click.style(e, fg="red"))
123
+
124
+ if any_failures:
125
+ # To make it so the CLI exits with a non-zero exit code
126
+ raise click.ClickException(
127
+ "validate failed for one or more files. Sync aborted."
128
+ )
129
+
130
+ click.secho("All rules are valid", fg="green")
131
+ click.echo("")
132
+
133
+ click.echo("Syncing rules to Scanner...")
134
+ num_created: int = 0
135
+ num_updated: int = 0
136
+ for detection_rule_config in detection_rule_configs:
137
+ file = detection_rule_config.file
138
+ detection_rule = detection_rule_config.detection_rule
139
+ event_sink_ids = detection_rule_config.event_sink_ids
140
+
141
+ try:
142
+ # At this point, we know sync_key is present
143
+ sync_key: str = detection_rule['sync_key']
144
+ detection_rule_id: Optional[str] = _get_detection_rule_id_for_sync_key(
145
+ scanner_client,
146
+ sync_key,
147
+ )
148
+
149
+ severity: DetectionSeverity = string_to_detection_severity(
150
+ detection_rule['severity']
151
+ )
152
+
153
+ if detection_rule_id:
154
+ scanner_client.detection_rule.update(
155
+ sync_key=sync_key,
156
+ detection_rule_id=detection_rule_id,
157
+ name=detection_rule['name'],
158
+ description=detection_rule['description'],
159
+ time_range_s=detection_rule['time_range_s'],
160
+ run_frequency_s=detection_rule['run_frequency_s'],
161
+ enabled=detection_rule['enabled'],
162
+ severity=severity,
163
+ query_text=detection_rule['query_text'],
164
+ event_sink_ids=event_sink_ids,
165
+ )
166
+ click.echo(f"{sync_key}: " + click.style("Updated", fg="green"))
167
+ num_updated += 1
168
+ else:
169
+ scanner_client.detection_rule.create(
170
+ sync_key=detection_rule['sync_key'],
171
+ tenant_id=tenant_id,
172
+ name=detection_rule['name'],
173
+ description=detection_rule['description'],
174
+ time_range_s=detection_rule['time_range_s'],
175
+ run_frequency_s=detection_rule['run_frequency_s'],
176
+ enabled=detection_rule['enabled'],
177
+ severity=severity,
178
+ query_text=detection_rule['query_text'],
179
+ event_sink_ids=event_sink_ids,
180
+ )
181
+ click.echo(f"{sync_key}: " + click.style("Created", fg="green"))
182
+ num_created += 1
183
+ except Exception as e:
184
+ any_failures = True
185
+ click.echo(click.style("Failed to sync file", fg="red") + f": {file}")
186
+ click.echo(click.style("Error", fg="red") + f": {e}")
187
+ click.echo("")
188
+ break
189
+
190
+ if any_failures:
191
+ # To make it so the CLI exits with a non-zero exit code
192
+ raise click.ClickException(
193
+ "sync failed for one or more files"
194
+ )
195
+
196
+ if num_created > 0:
197
+ click.secho(f"Created {num_created} rule(s)", fg="green")
198
+ if num_updated > 0:
199
+ click.secho(f"Updated {num_updated} rule(s)", fg="green")
@@ -1,65 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: scanner-cli
3
- Version: 0.1.0rc6
4
- Summary: Python command-line interface for Scanner API
5
- Author: Scanner, Inc.
6
- Author-email: support@scanner.dev
7
- Requires-Python: >=3.10,<4.0
8
- Classifier: Programming Language :: Python :: 3
9
- Classifier: Programming Language :: Python :: 3.10
10
- Classifier: Programming Language :: Python :: 3.11
11
- Classifier: Programming Language :: Python :: 3.12
12
- Requires-Dist: click (>=8.1.7,<9.0.0)
13
- Requires-Dist: scanner-client (>=0.1.0rc5,<0.2.0)
14
- Description-Content-Type: text/markdown
15
-
16
- # scanner-cli
17
-
18
- This is a Python CLI for the Scanner API.
19
-
20
- ## Usage
21
-
22
- To install the CLI, run
23
-
24
- ```
25
- pip install scanner-cli
26
- ```
27
-
28
- You will need to provide the API URL of your Scanner instance and an API key. Go
29
- to **Settings > API Keys** to find your API URL and API key.
30
-
31
- You can either set these values as environment variables:
32
-
33
- ```
34
- export SCANNER_API_URL=<Scanner API URL>
35
- export SCANNER_API_KEY=<Scanner API key>
36
- ```
37
-
38
- or provide them as arguments to the CLI:
39
-
40
- ```
41
- scanner-cli <command> --api-url=<Scanner API URL> --api-key=<Scanner API key>
42
- ```
43
-
44
- ## Commands
45
-
46
- Available commands are
47
- - `run-tests` - run tests on detection rules as code
48
- - `validate` - validate detection rules as code
49
-
50
- To validate or run tests on files:
51
-
52
- ```
53
- scanner-cli <command> -f detections/errors.yaml -f detections/unauthorized_logins.yaml
54
- ```
55
-
56
- To validate or run tests on directories:
57
-
58
- ```
59
- scanner-cli <command> -d detections
60
- ```
61
-
62
- This will only validate or run tests on YAML files with the correct schema header.
63
-
64
- A file or directory must be provided. Multiple files and/or directories can be provided.
65
-
@@ -1,6 +0,0 @@
1
- src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- src/cli.py,sha256=EPfmwoCBiSUKGBReXF5PENDFTWKb-eIAWZkiSNwmogY,5035
3
- scanner_cli-0.1.0rc6.dist-info/METADATA,sha256=sGfEtgjuEFr833V-j6lVAn7LRDCmYKDHi8_uY6932ak,1607
4
- scanner_cli-0.1.0rc6.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
5
- scanner_cli-0.1.0rc6.dist-info/entry_points.txt,sha256=vqXMrIG6N6pY66bNf0y-gbUxbU8v5dXvuL3mV832Fh8,43
6
- scanner_cli-0.1.0rc6.dist-info/RECORD,,