scanner-cli 0.1.0rc6__py3-none-any.whl → 0.1.0rc7__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,197 @@
1
+ Metadata-Version: 2.1
2
+ Name: scanner-cli
3
+ Version: 0.1.0rc7
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
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Dist: PyYAML (>=6.0.2,<7.0.0)
14
+ Requires-Dist: click (>=8.1.7,<9.0.0)
15
+ Requires-Dist: scanner-client (>=0.1.0rc8,<0.2.0)
16
+ Description-Content-Type: text/markdown
17
+
18
+ # scanner-cli
19
+
20
+ This is a Python CLI for the Scanner API.
21
+
22
+ ## Usage
23
+
24
+ To install the CLI, run
25
+
26
+ ```
27
+ pip install scanner-cli
28
+ ```
29
+
30
+ ## Environment Variables
31
+
32
+ For various commands, you will need to supply some Scanner configuration values.
33
+
34
+ For `run-tests`, `validate`, and `sync`, you will need these values:
35
+ - Scanner API URL
36
+ - Scanner API key
37
+
38
+ For `sync`, you will also need this value:
39
+ - Scanner Team ID
40
+
41
+ You can find these values in **Settings > General** and **Settings > API Keys**
42
+ in your Scanner account.
43
+
44
+ You can either set these values as environment variables:
45
+
46
+ ```
47
+ # required for run-tests, validate, sync commands:
48
+ export SCANNER_API_URL=<Scanner API URL>
49
+ export SCANNER_API_KEY=<Scanner API key>
50
+
51
+ # required for sync command:
52
+ export SCANNER_TEAM_ID=<Scanner Team ID>
53
+ ```
54
+
55
+ or provide them as arguments to the CLI:
56
+
57
+ ```
58
+ scanner-cli <command> \
59
+ --api-url=<Scanner API URL> \
60
+ --api-key=<Scanner API key> \
61
+ --team-id=<Scanner Team ID> \
62
+ ...
63
+ ```
64
+
65
+ ## Commands
66
+
67
+ Available commands are
68
+ - `run-tests` - run tests on detection rules as code
69
+ - `validate` - validate detection rules as code
70
+ - `sync` - sync detection rules to Scanner
71
+ - `migrate-elastic-rules` - migrate Elastic SIEM detection rules to Scanner YAML rules
72
+
73
+ ### `run-tests` and `validate`
74
+
75
+ To validate or run tests on files:
76
+
77
+ ```
78
+ scanner-cli validate -f detections/errors.yaml -f detections/unauthorized_logins.yaml
79
+ scanner-cli run-tests -f detections/errors.yaml -f detections/unauthorized_logins.yaml
80
+ ```
81
+
82
+ To validate or run tests on directories:
83
+
84
+ ```
85
+ scanner-cli validate -d detections
86
+ scanner-cli run-tests -d detections
87
+ ```
88
+
89
+ To recursively validate or run-tests on a directory, use `-r` or `--recursive`:
90
+
91
+ ```
92
+ scanner-cli validate -r -d detections
93
+ scanner-cli run-tests -r -d detections
94
+
95
+ scanner-cli validate --recursive -d detections
96
+ scanner-cli run-tests --recursive -d detections
97
+ ```
98
+
99
+ This will only validate or run tests on YAML files with the correct schema header.
100
+
101
+ A file or directory must be provided. Multiple files and/or directories can be provided.
102
+
103
+ ### `sync`
104
+
105
+ This command syncs detection rules to your Scanner account.
106
+
107
+ You can sync individual files or full directories.
108
+
109
+ ```
110
+ scanner-cli sync -f detections/errors.yaml -f detections/unauthorized_logins.yaml
111
+ scanner-cli sync -r -d detections
112
+ ```
113
+
114
+ #### `sync_key`
115
+
116
+ Each detection rule must have the `sync_key` field defined. This is a unique
117
+ identifier for the rule and can be any string you wish, as long as it is
118
+ unique.
119
+
120
+ #### `event_sink_keys`
121
+
122
+ If your detection rules have `event_sink_keys` defined, you must provide a sync
123
+ configuration file in YAML format using the `--sync-config-file` flag.
124
+
125
+ This file allows you to map `event_sink_keys` in your detection rules (eg.
126
+ `low_severity_alerts`, `high_severity_alerts`, etc.) to specific Scanner event
127
+ sinks (eg. "Custom webhook", "Slack alerts channel", etc.)
128
+
129
+ ```
130
+ scanner-cli sync --sync-config-file sync_config.yaml -r -d detections/
131
+ ```
132
+
133
+ Where is what the sync configuration file looks like:
134
+ ```
135
+ event_sink_keys:
136
+ low_severity_alerts:
137
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
138
+ medium_severity_alerts:
139
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
140
+ - sink_id: "62741ace-ea22-4255-8a36-b921a282d61e"
141
+ high_severity_alerts:
142
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
143
+ - sink_id: "62741ace-ea22-4255-8a36-b921a282d61e"
144
+ - sink_id: "2313eb73-0020-4814-9b35-864e3d3439e0"
145
+ ```
146
+
147
+ In this example, the `low_severity_alerts` event sink key is mapped to a single
148
+ event sink, while the `medium_severity_alerts` and `high_severity_alerts` event
149
+ sink keys are mapped to multiple event sinks.
150
+
151
+ For instance, you might send low severity alerts to a SOAR webhook, but send
152
+ medium and high severity alerts to a high-priority Slack channel and multiple
153
+ webhooks.
154
+
155
+ To find the Sink ID for a specific event sink, visit **Settings > Event Sinks**
156
+ and click on the event sink you want to use.
157
+
158
+ ### `migrate-elastic-rules`
159
+
160
+ This command migrates an `ndjson` file containing Elastic SIEM detection rules to Scanner YAML rules.
161
+
162
+ For each rule in the `ndjson` file, a YAML file will be created in the output directory.
163
+
164
+ ```
165
+ scanner-cli migrate-elastic-rules --elastic-files-rule elastic_rules.ndjson --output-dir scanner_rules/
166
+ ```
167
+
168
+ Optionally, you can provide a migrate config file in YAML format.
169
+
170
+ The migrate config file allows you to map Elastic Data View IDs to Scanner
171
+ query terms. This way, the queries in your Scanner detection rules can be more
172
+ selective about which logs they check against.
173
+
174
+ ```
175
+ scanner-cli migrate-elastic-rules \
176
+ --migrate-config-file elastic_to_scanner_config.yaml \
177
+ --elastic-files-rule elastic_rules.ndjson \
178
+ --output-dir scanner_rules/
179
+ ```
180
+
181
+ Here is an example migrate config file with a few mappings:
182
+ - Map from an Elastic Data View ID to a Scanner index.
183
+ - Map from an Elastic Data View wildcard name to a Scanner query for a specific
184
+ source type.
185
+ - Map from an Elastic Data View wildcard name to a Scanner query with multiple
186
+ terms.
187
+
188
+ ```
189
+ data_view_id_to_query_term:
190
+ e9874b58-5cee-40e0-8b49-5c3739572ea2: |-
191
+ @index={ 0f75b7fd-ea1e-421a-b4ee-007ac8570a20 | "application_logs" }
192
+ log-sources:aws:cloudtrail:*: |-
193
+ %ingest.source_type="aws:cloudtrail"
194
+ log-sources:non-prod-app-logs:*: |-
195
+ my_env=("staging" or "dev") and my_type="app_logs"
196
+ ```
197
+
@@ -0,0 +1,9 @@
1
+ src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ src/cli.py,sha256=z0MT8XdVjccgfYLhbkl2S4mjMiIqfG-LZzwnfVgJctM,7781
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.0rc7.dist-info/METADATA,sha256=bIzAcU3Dg2bt75c77WpdL6c1Y2CB7CgMvAUg7FNSLhY,5941
7
+ scanner_cli-0.1.0rc7.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
8
+ scanner_cli-0.1.0rc7.dist-info/entry_points.txt,sha256=vqXMrIG6N6pY66bNf0y-gbUxbU8v5dXvuL3mV832Fh8,43
9
+ scanner_cli-0.1.0rc7.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.9.0
2
+ Generator: poetry-core 1.9.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
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,16 +112,18 @@ 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
117
  """ Validate detection rules """
115
- _validate_shared_options(api_url, api_key, file_paths, directories)
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)
@@ -126,23 +131,32 @@ def validate(api_url: str, api_key: str, file_paths: str, directories: str, recu
126
131
  if result.is_valid:
127
132
  click.echo(f"{file}: " + click.style("Valid", 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:
137
+ any_failures = True
131
138
  click.echo(f"{file}: " + click.style(e, fg="red"))
132
139
 
140
+ if any_failures:
141
+ # To make it so the CLI exits with a non-zero exit code
142
+ raise click.ClickException(
143
+ "validate failed for one or more files"
144
+ )
133
145
 
134
146
 
135
147
  @cli.command()
136
- @_click_options
148
+ @_default_click_options
137
149
  def run_tests(api_url: str, api_key: str, file_paths: str, directories: str, recursive: bool):
138
150
  """ Run detection rule tests """
139
- _validate_shared_options(api_url, api_key, file_paths, directories)
151
+ _validate_default_options(api_url, api_key, file_paths, directories)
140
152
 
141
153
  scanner_client = Scanner(api_url, api_key)
142
154
 
143
155
  files = _get_valid_files(file_paths, directories, recursive)
144
156
  click.echo(f'Running tests on {len(files)} {"file" if len(files) == 1 else "files"}')
145
157
 
158
+ any_failures: bool = False
159
+
146
160
  for file in files:
147
161
  try:
148
162
  response = scanner_client.detection_rule_yaml.run_tests(file)
@@ -156,14 +170,82 @@ def run_tests(api_url: str, api_key: str, file_paths: str, directories: str, rec
156
170
  if status == "Passed":
157
171
  click.echo(f"{name}: " + click.style("Passed", fg="green"))
158
172
  else:
173
+ any_failures = True
159
174
  click.echo(f"{name}: " + click.style("Failed", fg="red"))
160
175
 
161
176
  click.echo("")
162
177
  except Exception as e:
178
+ any_failures = True
163
179
  click.secho(f"{file}", bold=True)
164
180
  click.secho(e, fg="red")
165
181
  click.echo("")
166
182
 
183
+ if any_failures:
184
+ # To make it so the CLI exits with a non-zero exit code
185
+ raise click.ClickException(
186
+ "run-tests failed for one or more files"
187
+ )
188
+
189
+
190
+ @cli.command()
191
+ @_default_click_options
192
+ @click.option(
193
+ "--team-id",
194
+ envvar="SCANNER_TEAM_ID",
195
+ help="The team ID to which you want to sync the Scanner rules. Go to Settings > General to find the Team ID.",
196
+ required=True,
197
+ )
198
+ @click.option(
199
+ "--sync-config-file",
200
+ 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).",
201
+ required=False,
202
+ )
203
+ def sync(
204
+ api_url: str,
205
+ api_key: str,
206
+ file_paths: str,
207
+ directories: str,
208
+ recursive: bool,
209
+ team_id: str,
210
+ sync_config_file: Optional[str]
211
+ ):
212
+ """ Sync detection rules to Scanner """
213
+ _validate_default_options(api_url, api_key, file_paths, directories)
214
+ if team_id is None:
215
+ raise click.exceptions.UsageError(
216
+ message=(
217
+ "Pass --team-id option or set `SCANNER_TEAM_ID` environment variable."
218
+ )
219
+ )
220
+
221
+ scanner_client: Scanner = Scanner(api_url, api_key)
222
+ files: list[str] = _get_valid_files(file_paths, directories, recursive)
223
+ # Note: In the Scanner UI, the tenant_id is called Team ID.
224
+ sync_cmd.sync(scanner_client, files, team_id, sync_config_file)
225
+
226
+
227
+ @cli.command()
228
+ @click.option(
229
+ "-c",
230
+ "--migrate-config-file",
231
+ 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).",
232
+ )
233
+ @click.option(
234
+ "-f",
235
+ "--elastic-rules-file",
236
+ help="The path to the Elastic detection rules ndjson file the CLI will migrate.",
237
+ required=True,
238
+ )
239
+ @click.option(
240
+ "-o",
241
+ "--output-dir",
242
+ help="The directory where the migrated rules will be saved. Will create one YAML file per rule.",
243
+ required=True,
244
+ )
245
+ def migrate_elastic_rules(migrate_config_file: Optional[str], elastic_rules_file: str, output_dir: str):
246
+ """ Migrate Elastic SIEM rules to Scanner rules """
247
+ elastic_cmd.migrate_elastic_rules(migrate_config_file, elastic_rules_file, output_dir)
248
+
167
249
 
168
250
  if __name__ == "__main__":
169
251
  cli()
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,,