scanner-cli 0.1.0__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,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: scanner-cli
3
+ Version: 0.1.0
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
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Dist: PyYAML (>=6.0.2,<7.0.0)
15
+ Requires-Dist: click (>=8.1.7,<9.0.0)
16
+ Requires-Dist: pydantic (>=2.0,<3.0)
17
+ Requires-Dist: scanner-client (>=0.1.0,<0.2.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # scanner-cli
21
+
22
+ This is a Python CLI for the Scanner API.
23
+
24
+ ## Usage
25
+
26
+ To install the CLI, run
27
+
28
+ ```
29
+ pip install scanner-cli
30
+ ```
31
+
32
+ ## Environment Variables
33
+
34
+ For various commands, you will need to supply some Scanner configuration values.
35
+
36
+ For `run-tests`, `validate`, and `sync`, you will need these values:
37
+ - Scanner API URL
38
+ - Scanner API key
39
+
40
+ For `sync`, you will also need this value:
41
+ - Scanner Team ID
42
+
43
+ You can find these values in **Settings > General** and **Settings > API Keys**
44
+ in your Scanner account.
45
+
46
+ You can either set these values as environment variables:
47
+
48
+ ```
49
+ # required for run-tests, validate, sync commands:
50
+ export SCANNER_API_URL=<Scanner API URL>
51
+ export SCANNER_API_KEY=<Scanner API key>
52
+
53
+ # required for sync command:
54
+ export SCANNER_TEAM_ID=<Scanner Team ID>
55
+ ```
56
+
57
+ or provide them as arguments to the CLI:
58
+
59
+ ```
60
+ scanner-cli <command> \
61
+ --api-url=<Scanner API URL> \
62
+ --api-key=<Scanner API key> \
63
+ --team-id=<Scanner Team ID> \
64
+ ...
65
+ ```
66
+
67
+ ## Commands
68
+
69
+ Available commands are
70
+ - `run-tests` - run tests on detection rules as code
71
+ - `validate` - validate detection rules as code
72
+ - `sync` - sync detection rules to Scanner
73
+ - `migrate-elastic-rules` - migrate Elastic SIEM detection rules to Scanner YAML rules
74
+
75
+ ### `run-tests` and `validate`
76
+
77
+ To validate or run tests on files:
78
+
79
+ ```
80
+ scanner-cli validate -f detections/errors.yaml -f detections/unauthorized_logins.yaml
81
+ scanner-cli run-tests -f detections/errors.yaml -f detections/unauthorized_logins.yaml
82
+ ```
83
+
84
+ To validate or run tests on directories:
85
+
86
+ ```
87
+ scanner-cli validate -d detections
88
+ scanner-cli run-tests -d detections
89
+ ```
90
+
91
+ To recursively validate or run-tests on a directory, use `-r` or `--recursive`:
92
+
93
+ ```
94
+ scanner-cli validate -r -d detections
95
+ scanner-cli run-tests -r -d detections
96
+
97
+ scanner-cli validate --recursive -d detections
98
+ scanner-cli run-tests --recursive -d detections
99
+ ```
100
+
101
+ This will only validate or run tests on YAML files with the correct schema header.
102
+
103
+ A file or directory must be provided. Multiple files and/or directories can be provided.
104
+
105
+ ### `sync`
106
+
107
+ This command syncs detection rules to your Scanner account.
108
+
109
+ You can sync individual files or full directories.
110
+
111
+ ```
112
+ scanner-cli sync -f detections/errors.yaml -f detections/unauthorized_logins.yaml
113
+ scanner-cli sync -r -d detections
114
+ ```
115
+
116
+ #### `sync_key`
117
+
118
+ Each detection rule must have the `sync_key` field defined. This is a unique
119
+ identifier for the rule and can be any string you wish, as long as it is
120
+ unique.
121
+
122
+ #### `event_sink_keys`
123
+
124
+ If your detection rules have `event_sink_keys` defined, you must provide a sync
125
+ configuration file in YAML format using the `--sync-config-file` flag.
126
+
127
+ This file allows you to map `event_sink_keys` in your detection rules (eg.
128
+ `low_severity_alerts`, `high_severity_alerts`, etc.) to specific Scanner event
129
+ sinks (eg. "Custom webhook", "Slack alerts channel", etc.)
130
+
131
+ ```
132
+ scanner-cli sync --sync-config-file sync_config.yaml -r -d detections/
133
+ ```
134
+
135
+ Where is what the sync configuration file looks like:
136
+ ```
137
+ event_sink_keys:
138
+ low_severity_alerts:
139
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
140
+ medium_severity_alerts:
141
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
142
+ - sink_id: "62741ace-ea22-4255-8a36-b921a282d61e"
143
+ high_severity_alerts:
144
+ - sink_id: "5098b2bd-065c-4c0d-9f11-685e88808fc2"
145
+ - sink_id: "62741ace-ea22-4255-8a36-b921a282d61e"
146
+ - sink_id: "2313eb73-0020-4814-9b35-864e3d3439e0"
147
+ ```
148
+
149
+ In this example, the `low_severity_alerts` event sink key is mapped to a single
150
+ event sink, while the `medium_severity_alerts` and `high_severity_alerts` event
151
+ sink keys are mapped to multiple event sinks.
152
+
153
+ For instance, you might send low severity alerts to a SOAR webhook, but send
154
+ medium and high severity alerts to a high-priority Slack channel and multiple
155
+ webhooks.
156
+
157
+ To find the Sink ID for a specific event sink, visit **Settings > Event Sinks**
158
+ and click on the event sink you want to use.
159
+
160
+ ### `migrate-elastic-rules`
161
+
162
+ This command migrates an `ndjson` file containing Elastic SIEM detection rules to Scanner YAML rules.
163
+
164
+ For each rule in the `ndjson` file, a YAML file will be created in the output directory.
165
+
166
+ ```
167
+ scanner-cli migrate-elastic-rules --elastic-files-rule elastic_rules.ndjson --output-dir scanner_rules/
168
+ ```
169
+
170
+ Optionally, you can provide a migrate config file in YAML format.
171
+
172
+ The migrate config file allows you to map Elastic Data View IDs to Scanner
173
+ query terms. This way, the queries in your Scanner detection rules can be more
174
+ selective about which logs they check against.
175
+
176
+ ```
177
+ scanner-cli migrate-elastic-rules \
178
+ --migrate-config-file elastic_to_scanner_config.yaml \
179
+ --elastic-files-rule elastic_rules.ndjson \
180
+ --output-dir scanner_rules/
181
+ ```
182
+
183
+ Here is an example migrate config file with a few mappings:
184
+ - Map from an Elastic Data View ID to a Scanner index.
185
+ - Map from an Elastic Data View wildcard name to a Scanner query for a specific
186
+ source type.
187
+ - Map from an Elastic Data View wildcard name to a Scanner query with multiple
188
+ terms.
189
+
190
+ ```
191
+ data_view_id_to_query_term:
192
+ e9874b58-5cee-40e0-8b49-5c3739572ea2: |-
193
+ @index={ 0f75b7fd-ea1e-421a-b4ee-007ac8570a20 | "application_logs" }
194
+ log-sources:aws:cloudtrail:*: |-
195
+ %ingest.source_type="aws:cloudtrail"
196
+ log-sources:non-prod-app-logs:*: |-
197
+ my_env=("staging" or "dev") and my_type="app_logs"
198
+ ```
199
+
@@ -0,0 +1,13 @@
1
+ src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ src/cli.py,sha256=rShrleZukvvGZGP_d0BCzLu4LD_Ri0NHa6Eu0mxNBbM,13231
3
+ src/detection_rule_yaml.py,sha256=z_GDtBLcfteib5N5XvRRY7Enk8xOFEE2G_FLNgRsnqI,1177
4
+ src/migrate/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ src/migrate/elastic.py,sha256=y_cn_3Y_t8NHwYcpAQQLfnCkEBSULoU_vxxGwk47QAc,8681
6
+ src/sync.py,sha256=ch5pAIOpRADleaVnxNql2dRwzYiwiEvJxCRbZY1oaJ0,4070
7
+ src/sync_git_repo.py,sha256=o5tWV_LDpvetuleSIaue6xdjhUCWc2Stfhutf5wSf1k,8297
8
+ src/sync_git_repo_result.py,sha256=N1id87RjdzNZSQvWwpbaaq_DBVexF0Td2-tdHVq-HUo,3637
9
+ src/utils.py,sha256=E5tJmAJrIQdYhAX60ZYqtqvHCh3lu4G32mzpEip4r4g,859
10
+ scanner_cli-0.1.0.dist-info/METADATA,sha256=YRfnM2xZJri81eLWU2LtqMizgF_NkvtJSAzrKU8zd1o,6023
11
+ scanner_cli-0.1.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
12
+ scanner_cli-0.1.0.dist-info/entry_points.txt,sha256=vqXMrIG6N6pY66bNf0y-gbUxbU8v5dXvuL3mV832Fh8,43
13
+ scanner_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.3.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ scanner-cli=src.cli:cli
3
+
src/__init__.py ADDED
File without changes
src/cli.py ADDED
@@ -0,0 +1,402 @@
1
+ """Contains code for Python CLI"""
2
+
3
+ import os
4
+ from typing import Any, Callable, Optional
5
+
6
+ import click
7
+
8
+ from scanner_client.scanner import Scanner
9
+
10
+ import src.migrate.elastic as elastic_cmd
11
+ import src.sync as sync_cmd
12
+ import src.sync_git_repo as sync_git_repo_cmd
13
+ from src.detection_rule_yaml import validate_and_read_file
14
+ from src.utils import format_exception_message
15
+
16
+ _DEFAULT_CLICK_OPTIONS = [
17
+ click.option(
18
+ "--api-url",
19
+ envvar="SCANNER_API_URL",
20
+ help="The API URL of your Scanner instance. Go to Settings > API Keys in Scanner to find your API URL.",
21
+ ),
22
+ click.option(
23
+ "--api-key",
24
+ envvar="SCANNER_API_KEY",
25
+ help="Scanner API key. Go to Settings > API Keys in Scanner to find your API keys or to create a new API key.",
26
+ ),
27
+ click.option(
28
+ "-f",
29
+ "--file",
30
+ "file_paths",
31
+ help="Detection rule file. This must be .yml or .yaml file with the correct schema header.",
32
+ multiple=True,
33
+ ),
34
+ click.option(
35
+ "-d",
36
+ "--dir",
37
+ "directories",
38
+ help="Directory of detection rule files. Only .yml or .yaml files with the correct schema header will be processed.",
39
+ multiple=True,
40
+ ),
41
+ click.option(
42
+ "-r",
43
+ "recursive",
44
+ is_flag=True,
45
+ show_default=True,
46
+ default=False,
47
+ help="Recursively search directory for valid YAML files.",
48
+ ),
49
+ ]
50
+
51
+
52
+ def _default_click_options(func) -> Callable[..., Any]:
53
+ for option in reversed(_DEFAULT_CLICK_OPTIONS):
54
+ func = option(func)
55
+
56
+ return func
57
+
58
+
59
+ def _is_valid_file(file_path: str) -> bool:
60
+ try:
61
+ validate_and_read_file(file_path)
62
+ return True
63
+ except:
64
+ return False
65
+
66
+
67
+ def _get_valid_files_in_directory(directory: str, recursive: bool) -> list[str]:
68
+ if not os.path.exists(directory):
69
+ raise click.exceptions.ClickException(
70
+ message=(f"Directory {directory} not found.")
71
+ )
72
+
73
+ # Skip symlinks under directory recursion. The user pointed at a dir,
74
+ # not specific files — a symlink in there (e.g. via an attacker PR)
75
+ # could read sensitive content outside the dir. `followlinks=False`
76
+ # blocks symlinked sub-dirs; the per-file `islink` check blocks
77
+ # symlinked files. Explicit `-f` paths bypass this gate because the
78
+ # user vouched for the file by name.
79
+ files: list[str] = []
80
+ for root, _dirs, filenames in os.walk(directory, followlinks=False):
81
+ for name in filenames:
82
+ full = os.path.join(root, name)
83
+ if not os.path.islink(full) and _is_valid_file(full):
84
+ files.append(full)
85
+ if not recursive:
86
+ break
87
+ return files
88
+
89
+
90
+ def _get_valid_files(
91
+ file_paths: tuple[str, ...], directories: tuple[str, ...], recursive: bool
92
+ ) -> list[str]:
93
+ files = [f for f in file_paths if _is_valid_file(f)]
94
+
95
+ for d in directories:
96
+ files.extend(_get_valid_files_in_directory(d, recursive))
97
+
98
+ return files
99
+
100
+
101
+ def _validate_default_options(
102
+ api_url: Optional[str],
103
+ api_key: Optional[str],
104
+ file_paths: tuple[str, ...],
105
+ directories: tuple[str, ...],
106
+ ) -> tuple[str, str, tuple[str, ...], tuple[str, ...]]:
107
+ if api_url is None:
108
+ raise click.exceptions.UsageError(
109
+ message=(
110
+ "Pass --api-url option or set `SCANNER_API_URL` environment variable."
111
+ )
112
+ )
113
+
114
+ if api_key is None:
115
+ raise click.exceptions.UsageError(
116
+ message=(
117
+ "Pass --api-key option or set `SCANNER_API_KEY` environment variable."
118
+ )
119
+ )
120
+
121
+ if not file_paths and not directories:
122
+ raise click.exceptions.UsageError(
123
+ message=("Either --file or --dir must be provided.")
124
+ )
125
+
126
+ return (api_url, api_key, file_paths, directories)
127
+
128
+
129
+ @click.group()
130
+ def cli():
131
+ """Python CLI for Scanner API"""
132
+
133
+
134
+ @cli.command()
135
+ @_default_click_options
136
+ def validate(
137
+ api_url: Optional[str],
138
+ api_key: Optional[str],
139
+ file_paths: tuple[str, ...],
140
+ directories: tuple[str, ...],
141
+ recursive: bool,
142
+ ):
143
+ """Validate detection rule files"""
144
+ api_url, api_key, file_paths, directories = _validate_default_options(
145
+ api_url, api_key, file_paths, directories
146
+ )
147
+
148
+ scanner_client = Scanner(api_url, api_key)
149
+
150
+ files = _get_valid_files(file_paths, directories, recursive)
151
+ click.echo(f'Validating {len(files)} {"file" if len(files) == 1 else "files"}')
152
+
153
+ any_failures: bool = False
154
+
155
+ for file in files:
156
+ try:
157
+ result = scanner_client.detection_rule_yaml.validate(
158
+ validate_and_read_file(file)
159
+ )
160
+
161
+ if result.is_valid:
162
+ if result.warning:
163
+ click.echo(
164
+ f"{file}: "
165
+ + click.style("OK. ", fg="green")
166
+ + click.style(f"Warning: {result.warning}", fg="yellow")
167
+ )
168
+ else:
169
+ click.echo(f"{file}: " + click.style("OK", fg="green"))
170
+ else:
171
+ any_failures = True
172
+ click.echo(f"{file}: " + click.style(f"{result.error}", fg="red"))
173
+ except Exception as e:
174
+ any_failures = True
175
+ error_msg = format_exception_message(
176
+ e, "An exception occurred when attempting to validate file"
177
+ )
178
+ click.echo(f"{file}: " + click.style(error_msg, fg="red"))
179
+
180
+ if any_failures:
181
+ # To make it so the CLI exits with a non-zero exit code
182
+ raise click.ClickException(
183
+ "`validate` failed for one or more files. See https://docs.scanner.dev/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-rules-as-code/writing-detection-rules for requirements."
184
+ )
185
+ else:
186
+ click.secho(
187
+ "All specified detection rule files are valid. Use `run-tests` to run the detection rule tests.",
188
+ bold=True,
189
+ )
190
+
191
+
192
+ @cli.command()
193
+ @_default_click_options
194
+ def run_tests(
195
+ api_url: Optional[str],
196
+ api_key: Optional[str],
197
+ file_paths: tuple[str, ...],
198
+ directories: tuple[str, ...],
199
+ recursive: bool,
200
+ ):
201
+ """Run detection rule tests"""
202
+ api_url, api_key, file_paths, directories = _validate_default_options(
203
+ api_url, api_key, file_paths, directories
204
+ )
205
+
206
+ scanner_client = Scanner(api_url, api_key)
207
+
208
+ files = _get_valid_files(file_paths, directories, recursive)
209
+ click.echo(
210
+ f'Running tests on {len(files)} {"file" if len(files) == 1 else "files"}'
211
+ )
212
+
213
+ any_validation_errors: bool = False
214
+ any_test_failures: bool = False
215
+
216
+ for file in files:
217
+ click.secho(f"{file}", bold=True)
218
+ try:
219
+ rule_yaml = validate_and_read_file(file)
220
+
221
+ # Check for validation errors
222
+ validation_result = scanner_client.detection_rule_yaml.validate(rule_yaml)
223
+ if not validation_result.is_valid:
224
+ any_validation_errors = True
225
+ click.secho(f"Validation error: {validation_result.error}", fg="red")
226
+ click.echo("")
227
+ continue
228
+
229
+ if validation_result.warning:
230
+ click.secho(f"Warning: {validation_result.warning}", fg="yellow")
231
+
232
+ # Run detection rule tests
233
+ run_tests_response = scanner_client.detection_rule_yaml.run_tests(rule_yaml)
234
+ results = run_tests_response.results.to_dict()
235
+
236
+ if len(results) == 0:
237
+ click.secho("No tests found", fg="yellow")
238
+ else:
239
+ for name, status in results.items():
240
+ if status == "Passed":
241
+ click.echo(f"{name}: " + click.style("OK", fg="green"))
242
+ else:
243
+ any_test_failures = True
244
+ click.echo(f"{name}: " + click.style("Test failed", fg="red"))
245
+ except Exception as e:
246
+ any_test_failures = True
247
+ error_msg = format_exception_message(
248
+ e, "An exception occurred when attempting to run tests"
249
+ )
250
+ click.secho(error_msg, fg="red")
251
+
252
+ click.echo("")
253
+
254
+ error_messages = []
255
+ if any_validation_errors:
256
+ error_messages.append(
257
+ "Validation failed for one or more files. See https://docs.scanner.dev/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-rules-as-code/writing-detection-rules for requirements."
258
+ )
259
+
260
+ if any_test_failures:
261
+ error_messages.append(
262
+ "`run-tests` failed for one or more files. See https://docs.scanner.dev/scanner/using-scanner-complete-feature-reference/detections-and-alerting/detection-rules/detection-rules-as-code/cli#failing-tests for more information."
263
+ )
264
+
265
+ if error_messages:
266
+ # To make it so the CLI exits with a non-zero exit code
267
+ raise click.ClickException("\n".join(error_messages))
268
+
269
+
270
+ @cli.command()
271
+ @_default_click_options
272
+ @click.option(
273
+ "--team-id",
274
+ envvar="SCANNER_TEAM_ID",
275
+ help="The team ID to which you want to sync the Scanner rules. Go to Settings > General to find the Team ID.",
276
+ required=True,
277
+ )
278
+ @click.option(
279
+ "--sync-config-file",
280
+ help=(
281
+ "Optional. The path to the sync configuration file the CLI will use to "
282
+ "sync detection rules to Scanner (eg. contains event_sink_keys mappings, etc)."
283
+ ),
284
+ required=False,
285
+ )
286
+ def sync(
287
+ api_url: Optional[str],
288
+ api_key: Optional[str],
289
+ file_paths: tuple[str, ...],
290
+ directories: tuple[str, ...],
291
+ recursive: bool,
292
+ team_id: str,
293
+ sync_config_file: Optional[str],
294
+ ):
295
+ """Sync detection rules to Scanner"""
296
+ api_url, api_key, file_paths, directories = _validate_default_options(
297
+ api_url, api_key, file_paths, directories
298
+ )
299
+ if team_id is None:
300
+ raise click.exceptions.UsageError(
301
+ message=(
302
+ "Pass --team-id option or set `SCANNER_TEAM_ID` environment variable."
303
+ )
304
+ )
305
+
306
+ scanner_client: Scanner = Scanner(api_url, api_key)
307
+ files: list[str] = _get_valid_files(file_paths, directories, recursive)
308
+ # Note: In the Scanner UI, the tenant_id is called Team ID.
309
+ sync_cmd.sync(scanner_client, files, team_id, sync_config_file)
310
+
311
+
312
+ @cli.command(
313
+ name="sync-git-repo",
314
+ short_help="Sync an entire git repo to Scanner.",
315
+ )
316
+ @click.option(
317
+ "--api-url",
318
+ envvar="SCANNER_API_URL",
319
+ help="The API URL of your Scanner instance. Go to Settings > API Keys in Scanner to find your API URL.",
320
+ )
321
+ @click.option(
322
+ "--api-key",
323
+ envvar="SCANNER_API_KEY",
324
+ help="Scanner API key. Go to Settings > API Keys in Scanner to find your API keys or to create a new API key.",
325
+ )
326
+ @click.option(
327
+ "--push-key",
328
+ required=True,
329
+ help="The push key identifying the target GithubRepoSyncSource on Scanner. Configure one under Settings > Detection Rule Sync.",
330
+ )
331
+ @click.option(
332
+ "--json",
333
+ "json_output",
334
+ is_flag=True,
335
+ default=False,
336
+ help="Emit a single JSON object on stdout instead of human-readable output. Exit code is non-zero iff sync failed.",
337
+ )
338
+ @click.argument(
339
+ "path",
340
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
341
+ default=".",
342
+ )
343
+ def sync_git_repo(
344
+ api_url: Optional[str],
345
+ api_key: Optional[str],
346
+ push_key: str,
347
+ json_output: bool,
348
+ path: str,
349
+ ):
350
+ """Sync an entire git repo to Scanner via the push-sync zipball endpoint.
351
+
352
+ PATH is a directory inside a git repo (defaults to the current directory).
353
+ The current branch (must not be detached) and commit SHA (with `+dirty`
354
+ suffix if the working tree is dirty) are uploaded alongside the zip; the
355
+ server reconciles detection rules against the `--push-key` sync source.
356
+ """
357
+ if api_url is None:
358
+ raise click.exceptions.UsageError(
359
+ message=(
360
+ "Pass --api-url option or set `SCANNER_API_URL` environment variable."
361
+ )
362
+ )
363
+ if api_key is None:
364
+ raise click.exceptions.UsageError(
365
+ message=(
366
+ "Pass --api-key option or set `SCANNER_API_KEY` environment variable."
367
+ )
368
+ )
369
+
370
+ scanner_client: Scanner = Scanner(api_url, api_key)
371
+ sync_git_repo_cmd.sync_git_repo(scanner_client, path, push_key, json_output)
372
+
373
+
374
+ @cli.command()
375
+ @click.option(
376
+ "-c",
377
+ "--migrate-config-file",
378
+ 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).",
379
+ )
380
+ @click.option(
381
+ "-f",
382
+ "--elastic-rules-file",
383
+ help="The path to the Elastic detection rules ndjson file the CLI will migrate.",
384
+ required=True,
385
+ )
386
+ @click.option(
387
+ "-o",
388
+ "--output-dir",
389
+ help="The directory where the migrated rules will be saved. Will create one YAML file per rule.",
390
+ required=True,
391
+ )
392
+ def migrate_elastic_rules(
393
+ migrate_config_file: Optional[str], elastic_rules_file: str, output_dir: str
394
+ ):
395
+ """Migrate Elastic SIEM rules to Scanner rules"""
396
+ elastic_cmd.migrate_elastic_rules(
397
+ migrate_config_file, elastic_rules_file, output_dir
398
+ )
399
+
400
+
401
+ if __name__ == "__main__":
402
+ cli()
@@ -0,0 +1,42 @@
1
+ """Schema-header validation for detection rule YAML files.
2
+
3
+ These helpers used to live in `scanner_client.detection_rule_yaml`, but
4
+ the upstream library dropped them — schema validation is a CLI concern
5
+ (we want a friendly local error before any network round-trip), so the
6
+ canonical home is here.
7
+ """
8
+
9
+ import os
10
+
11
+
12
+ DETECTION_RULE_SCHEMA_HEADER = (
13
+ "# schema: https://scanner.dev/schema/scanner-detection-rule.v1.json"
14
+ )
15
+
16
+
17
+ def has_yaml_extension(file_path: str) -> bool:
18
+ return file_path.endswith(".yml") or file_path.endswith(".yaml")
19
+
20
+
21
+ def contains_schema(contents: str) -> bool:
22
+ return DETECTION_RULE_SCHEMA_HEADER in contents
23
+
24
+
25
+ def validate_and_read_file(file_path: str) -> str:
26
+ if not os.path.exists(file_path):
27
+ raise Exception(f"File {file_path} not found.")
28
+
29
+ if not has_yaml_extension(file_path):
30
+ raise Exception(
31
+ f"File {file_path} does not have a .yml or .yaml extension."
32
+ )
33
+
34
+ with open(file_path, "r") as f:
35
+ contents = f.read()
36
+
37
+ if not contains_schema(contents):
38
+ raise Exception(
39
+ f"File {file_path} does not contain the correct schema header."
40
+ )
41
+
42
+ return contents
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,114 @@
1
+ from typing import Any, Optional
2
+
3
+ import click
4
+ import yaml
5
+
6
+ from scanner_client.scanner import Scanner
7
+
8
+ from src.detection_rule_yaml import validate_and_read_file
9
+
10
+
11
+ def _extract_sync_key(rule_yaml: str) -> Optional[str]:
12
+ """Pull the rule's `push_key` (preferred) or legacy `sync_key` out of
13
+ the YAML so we can print it in per-file status lines. The server is
14
+ the authoritative validator — this is just for cosmetic output."""
15
+ try:
16
+ doc: Any = yaml.safe_load(rule_yaml)
17
+ except yaml.YAMLError:
18
+ return None
19
+ if not isinstance(doc, dict):
20
+ return None
21
+ key = doc.get("push_key") or doc.get("sync_key")
22
+ return key if isinstance(key, str) else None
23
+
24
+
25
+ def sync(
26
+ scanner_client: Scanner,
27
+ files: list[str],
28
+ tenant_id: str,
29
+ sync_config_file: Optional[str],
30
+ ):
31
+ click.echo(
32
+ f'Syncing {len(files)} detection rule {"file" if len(files) == 1 else "files"} to Scanner Team {tenant_id}'
33
+ )
34
+
35
+ # We don't parse the sync config ourselves — the server does, via the
36
+ # `/v1/detection_rule_yaml/upsert_by_push_key` endpoint. Keeping the
37
+ # CLI thin here means a new field doesn't require a CLI release.
38
+ sync_config_yaml: str = ""
39
+ if sync_config_file:
40
+ with open(sync_config_file, "r") as f:
41
+ sync_config_yaml = f.read()
42
+
43
+ # Server-side validate every file first so a syntax error in one rule
44
+ # aborts before we apply any of them. We slurp each rule's YAML once
45
+ # here and reuse it for the upsert pass below.
46
+ click.echo("Validating rules before syncing...")
47
+ any_failures = False
48
+ rule_yamls: dict[str, str] = {}
49
+ sync_keys: dict[str, str] = {}
50
+ for file in files:
51
+ try:
52
+ rule_yaml = validate_and_read_file(file)
53
+ result = scanner_client.detection_rule_yaml.validate(rule_yaml)
54
+ if not result.is_valid:
55
+ any_failures = True
56
+ click.echo(f"{file}: " + click.style(f"{result.error}", fg="red"))
57
+ continue
58
+
59
+ # Missing key is a hard error before we hit the server. We
60
+ # accept either `push_key` (preferred) or `sync_key` (legacy)
61
+ # — same field on the wire.
62
+ sync_key = _extract_sync_key(rule_yaml)
63
+ if not sync_key:
64
+ any_failures = True
65
+ click.secho(
66
+ f"Error: push_key (or sync_key) not found in {file}",
67
+ fg="red",
68
+ )
69
+ continue
70
+
71
+ click.echo(f"{file}: " + click.style("Valid", fg="green"))
72
+ rule_yamls[file] = rule_yaml
73
+ sync_keys[file] = sync_key
74
+ except Exception as e:
75
+ any_failures = True
76
+ click.echo(f"{file}: " + click.style(e, fg="red"))
77
+
78
+ if any_failures:
79
+ raise click.ClickException(
80
+ "validate failed for one or more files. Sync aborted."
81
+ )
82
+
83
+ click.secho("All rules are valid", fg="green")
84
+ click.echo("")
85
+
86
+ click.echo("Syncing rules to Scanner...")
87
+ num_created = 0
88
+ num_updated = 0
89
+ for file in files:
90
+ try:
91
+ resp = scanner_client.detection_rule_yaml.upsert_by_push_key(
92
+ rule_yaml=rule_yamls[file],
93
+ sync_config_yaml=sync_config_yaml,
94
+ )
95
+ label = "Created" if resp.created else "Updated"
96
+ click.echo(f"{sync_keys[file]}: " + click.style(label, fg="green"))
97
+ if resp.created:
98
+ num_created += 1
99
+ else:
100
+ num_updated += 1
101
+ except Exception as e:
102
+ any_failures = True
103
+ click.echo(click.style("Failed to sync file", fg="red") + f": {file}")
104
+ click.echo(click.style("Error", fg="red") + f": {e}")
105
+ click.echo("")
106
+ break
107
+
108
+ if any_failures:
109
+ raise click.ClickException("sync failed for one or more files")
110
+
111
+ if num_created > 0:
112
+ click.secho(f"Created {num_created} rule(s)", fg="green")
113
+ if num_updated > 0:
114
+ click.secho(f"Updated {num_updated} rule(s)", fg="green")
src/sync_git_repo.py ADDED
@@ -0,0 +1,226 @@
1
+ """sync-git-repo command implementation.
2
+
3
+ Packages a git working tree into a zip archive and uploads it to Scanner's
4
+ push-sync endpoint. The caller supplies the `push_key` that identifies the
5
+ target `GithubRepoSyncSource` on the server side — this lets the CLI stay
6
+ oblivious to per-repo Scanner config; we just ship bytes.
7
+ """
8
+
9
+ import io
10
+ import os
11
+ import subprocess
12
+ import zipfile
13
+
14
+ import click
15
+
16
+ from scanner_client.scanner import Scanner
17
+
18
+ from src.sync_git_repo_result import (
19
+ SyncGitRepoFailure,
20
+ SyncGitRepoFailureStatus,
21
+ SyncGitRepoResult,
22
+ SyncGitRepoWarning,
23
+ )
24
+
25
+
26
+ def _run_git(repo_path: str, *args: str) -> str:
27
+ """Run `git -C repo_path <args>` and return stdout, raising
28
+ `UsageError` if git is missing or the command fails."""
29
+ try:
30
+ result = subprocess.run(
31
+ ["git", "-C", repo_path, *args],
32
+ capture_output=True,
33
+ text=True,
34
+ )
35
+ except FileNotFoundError:
36
+ raise click.exceptions.UsageError("`git` not found in PATH.")
37
+ if result.returncode != 0:
38
+ raise click.exceptions.UsageError(
39
+ f"git {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}"
40
+ )
41
+ return result.stdout
42
+
43
+
44
+ def _resolve_git_root(path: str) -> str:
45
+ return _run_git(path, "rev-parse", "--show-toplevel").strip()
46
+
47
+
48
+ def _get_branch(repo_root: str) -> str:
49
+ branch = _run_git(repo_root, "rev-parse", "--abbrev-ref", "HEAD").strip()
50
+ if branch == "HEAD":
51
+ raise click.exceptions.UsageError(
52
+ "Repository is in a detached HEAD state. Check out a branch before "
53
+ "running `sync-git-repo`."
54
+ )
55
+ return branch
56
+
57
+
58
+ def _get_commit_sha(repo_root: str) -> str:
59
+ sha = _run_git(repo_root, "rev-parse", "HEAD").strip()
60
+ # `git status --porcelain` is empty iff the working tree (incl. index) is
61
+ # clean. Untracked files count as dirty too — matches what a user
62
+ # eyeballing `git status` would call "dirty".
63
+ porcelain = _run_git(repo_root, "status", "--porcelain")
64
+ if porcelain.strip():
65
+ sha = f"{sha}+dirty"
66
+ return sha
67
+
68
+
69
+ def _list_trackable_files(repo_root: str) -> list[str]:
70
+ """Return the repo-relative paths of every file `git status` would
71
+ consider part of the project: tracked + untracked, minus anything
72
+ gitignored. `-z` keeps us safe against filenames with spaces or
73
+ newlines."""
74
+ try:
75
+ result = subprocess.run(
76
+ [
77
+ "git",
78
+ "-C",
79
+ repo_root,
80
+ "ls-files",
81
+ "--cached",
82
+ "--others",
83
+ "--exclude-standard",
84
+ "-z",
85
+ ],
86
+ capture_output=True,
87
+ )
88
+ except FileNotFoundError:
89
+ raise click.exceptions.UsageError("`git` not found in PATH.")
90
+ if result.returncode != 0:
91
+ raise click.exceptions.UsageError(
92
+ f"git ls-files failed: {result.stderr.decode(errors='replace').strip()}"
93
+ )
94
+ raw = result.stdout
95
+ if not raw:
96
+ return []
97
+ # `-z` emits NUL-terminated entries with a trailing NUL.
98
+ # `surrogateescape` round-trips non-UTF-8 bytes through Python's
99
+ # filesystem-string convention (PEP 383), so paths flow back into
100
+ # `os.path` / `open` / `zipfile.write` cleanly — and a repo with
101
+ # exotic filename encodings doesn't abort the whole sync at the
102
+ # listing step.
103
+ return [p.decode(errors="surrogateescape") for p in raw.split(b"\0") if p]
104
+
105
+
106
+ def _build_zipball_bytes(repo_root: str) -> bytes:
107
+ """Return the bytes of a zip containing every file `git ls-files`
108
+ considers part of the project (tracked + untracked, minus gitignored).
109
+
110
+ Uses the working tree — not `git archive HEAD` — so uncommitted and
111
+ untracked files are included. We set "+dirty" on the commit SHA to indicate
112
+ that the working tree is dirty.
113
+
114
+ The single top-level directory matches GitHub's `/zipball/<ref>`
115
+ layout that the upload endpoint expects. The short-sha slot is
116
+ cosmetic for us (commit SHA travels out-of-band as a query param),
117
+ so we pin it to a sentinel rather than computing one.
118
+ """
119
+ prefix = "repo-0000000/"
120
+ buf = io.BytesIO()
121
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
122
+ for rel in _list_trackable_files(repo_root):
123
+ full = os.path.join(repo_root, rel)
124
+ # Skip symlinks. `zipfile.write` follows them and embeds the
125
+ # target's contents, which would silently exfiltrate files
126
+ # outside the repo (e.g. an attacker-controlled PR adding a
127
+ # symlink to /etc/passwd or ~/.aws/credentials and a CI run
128
+ # that pipes the upload to Scanner).
129
+ if os.path.islink(full):
130
+ continue
131
+ # `ls-files` can list deleted-but-still-indexed paths; skip
132
+ # them so zipfile doesn't raise on a missing file.
133
+ if not os.path.isfile(full):
134
+ continue
135
+ zf.write(full, arcname=prefix + rel)
136
+ return buf.getvalue()
137
+
138
+
139
+ def sync_git_repo(
140
+ scanner_client: Scanner,
141
+ path: str,
142
+ push_key: str,
143
+ json_output: bool,
144
+ ) -> None:
145
+ repo_root = _resolve_git_root(path)
146
+ branch = _get_branch(repo_root)
147
+ commit_sha = _get_commit_sha(repo_root)
148
+
149
+ if not json_output:
150
+ click.echo(f"Repo root: {repo_root}")
151
+ click.echo(f"Push key: {push_key}")
152
+ click.echo(f"Branch: {branch}")
153
+ click.echo(f"Commit SHA: {commit_sha}")
154
+
155
+ zipball_bytes = _build_zipball_bytes(repo_root)
156
+
157
+ if not json_output:
158
+ click.echo(f"Uploading zipball ({len(zipball_bytes)} bytes)...")
159
+ resp = scanner_client.github_sync.upload_zipball(
160
+ zipball_bytes=zipball_bytes,
161
+ push_key=push_key,
162
+ branch=branch,
163
+ commit_sha=commit_sha,
164
+ )
165
+
166
+ if json_output:
167
+ result = SyncGitRepoResult(
168
+ push_key=push_key,
169
+ branch=branch,
170
+ commit_sha=commit_sha,
171
+ detection_rules_synced=resp.detection_rules_synced,
172
+ detection_rules_deleted=resp.detection_rules_deleted,
173
+ failures=[
174
+ SyncGitRepoFailure(
175
+ file_path=f.file_path,
176
+ check_status=SyncGitRepoFailureStatus(f.check_status.value),
177
+ # `reason` is `None | str | Unset`; only str is real content.
178
+ reason=f.reason if isinstance(f.reason, str) else None,
179
+ )
180
+ for f in resp.failures
181
+ ],
182
+ warnings=[
183
+ SyncGitRepoWarning(
184
+ file_path=w.file_path,
185
+ detection_rule_id=str(w.detection_rule_id),
186
+ messages=w.messages,
187
+ )
188
+ for w in resp.warnings
189
+ ],
190
+ )
191
+ click.echo(result.model_dump_json(indent=2))
192
+ if resp.failures:
193
+ # `Exit` is Click's mechanism for setting an exit code without
194
+ # printing an error message — stdout stays a clean JSON blob
195
+ # for automation, but `$?` still signals failure.
196
+ raise click.exceptions.Exit(code=1)
197
+ return
198
+
199
+ if resp.warnings:
200
+ click.secho("Warnings:", fg="yellow")
201
+ for warning in resp.warnings:
202
+ click.secho(f" {warning.file_path}:", fg="yellow")
203
+ for message in warning.messages:
204
+ click.secho(f" - {message}", fg="yellow")
205
+
206
+ # `failures` non-empty means *nothing* synced (whole-zip transaction),
207
+ # so reporting it as a hard error matches reality.
208
+ if resp.failures:
209
+ n = len(resp.failures)
210
+ click.secho(
211
+ f"Failed to sync {n} rule file{'' if n == 1 else 's'}:",
212
+ fg="red",
213
+ )
214
+ for failure in resp.failures:
215
+ line = f" {failure.file_path} [{failure.check_status.value}]"
216
+ # `reason` is `None | str | Unset`; only str is real content.
217
+ if isinstance(failure.reason, str):
218
+ line += f": {failure.reason}"
219
+ click.secho(line, fg="red")
220
+ raise click.ClickException("Sync aborted server-side; no rules were applied.")
221
+
222
+ click.secho(
223
+ f"Synced {resp.detection_rules_synced} rule(s), "
224
+ f"deleted {resp.detection_rules_deleted} rule(s)",
225
+ fg="green",
226
+ )
@@ -0,0 +1,107 @@
1
+ """User-facing schema for the JSON emitted by `scanner-cli sync-git-repo --json`.
2
+
3
+ This is a *first-class CLI contract* — independent of the underlying
4
+ `scanner-client-python` wire types. Downstream consumers (TS, Python, etc.)
5
+ should treat the committed `schemas/sync-git-repo-result.schema.json` as
6
+ the canonical definition; this module is just where we generate it from.
7
+
8
+ When you change a field here, run `scripts/dump_schema.py` and commit the
9
+ regenerated schema in the same PR.
10
+ """
11
+
12
+ from enum import Enum
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class SyncGitRepoFailureStatus(str, Enum):
18
+ """Why a detection-rule file in the uploaded zip didn't sync.
19
+
20
+ Mirrors the server-side `DetectionRuleSyncStatus` set, but is owned by
21
+ the CLI — we may extend it independently if the CLI grows pre-upload
22
+ checks of its own. New values may be added in a non-breaking way; old
23
+ values will not be removed without a CLI major-version bump.
24
+ """
25
+
26
+ ERROR_IN_QUERY = "ErrorInQuery"
27
+ ERROR_IN_RULE = "ErrorInRule"
28
+ FAILED_TESTS = "FailedTests"
29
+
30
+
31
+ class SyncGitRepoFailure(BaseModel):
32
+ """A single rule file that prevented the sync."""
33
+
34
+ file_path: str = Field(
35
+ description=(
36
+ "Repo-relative path of the failing detection-rule YAML."
37
+ ),
38
+ )
39
+ check_status: SyncGitRepoFailureStatus = Field(
40
+ description="Categorical reason this file failed.",
41
+ )
42
+ reason: str | None = Field(
43
+ default=None,
44
+ description=(
45
+ "Free-form detail (e.g. parse error message, failing test "
46
+ "names). May be absent when `check_status` is self-explanatory."
47
+ ),
48
+ )
49
+
50
+
51
+ class SyncGitRepoWarning(BaseModel):
52
+ """A non-fatal advisory attached to one synced rule."""
53
+
54
+ file_path: str = Field(
55
+ description="Repo-relative path of the rule the warning pertains to.",
56
+ )
57
+ detection_rule_id: str = Field(
58
+ description=(
59
+ "UUID of the resulting Scanner detection rule, as a string."
60
+ ),
61
+ )
62
+ messages: list[str] = Field(
63
+ description="Human-readable warning messages.",
64
+ )
65
+
66
+
67
+ class SyncGitRepoResult(BaseModel):
68
+ """Top-level shape of `sync-git-repo --json` output.
69
+
70
+ `failures` non-empty implies the sync was rejected as a whole — i.e.
71
+ `detection_rules_synced` and `detection_rules_deleted` are both 0 — so
72
+ consumers can treat the failure list as the authoritative signal.
73
+ """
74
+
75
+ push_key: str = Field(
76
+ description="The `--push-key` value the CLI uploaded under.",
77
+ )
78
+ branch: str = Field(
79
+ description="Git branch the upload was associated with.",
80
+ )
81
+ commit_sha: str = Field(
82
+ description=(
83
+ "Commit SHA of HEAD at upload time. Carries a `+dirty` suffix "
84
+ "iff the working tree had uncommitted or untracked changes "
85
+ "(which were nevertheless included in the uploaded zip)."
86
+ ),
87
+ )
88
+ detection_rules_synced: int = Field(
89
+ description="Number of rules created or updated by the upload.",
90
+ )
91
+ detection_rules_deleted: int = Field(
92
+ description=(
93
+ "Number of rules removed because their file no longer exists "
94
+ "in the uploaded repo."
95
+ ),
96
+ )
97
+ failures: list[SyncGitRepoFailure] = Field(
98
+ default_factory=list,
99
+ description=(
100
+ "Per-file failures. Non-empty means the entire sync was "
101
+ "aborted server-side and no rules were applied."
102
+ ),
103
+ )
104
+ warnings: list[SyncGitRepoWarning] = Field(
105
+ default_factory=list,
106
+ description="Non-fatal advisories from a successful sync.",
107
+ )
src/utils.py ADDED
@@ -0,0 +1,21 @@
1
+ """Utility functions for the Scanner CLI."""
2
+
3
+
4
+ def format_exception_message(e: Exception, base_msg: str) -> str:
5
+ """Format exception message with better error details for API responses."""
6
+ if hasattr(e, 'args') and len(e.args) > 0:
7
+ response = e.args[0]
8
+ status_code = getattr(response, 'status_code', None)
9
+ status_text = f" (HTTP {status_code})" if status_code else ""
10
+
11
+ if hasattr(response, 'content'):
12
+ if response.content:
13
+ return f"{base_msg}{status_text}: {response.content!r}"
14
+ else:
15
+ return f"{base_msg}: Empty response{status_text or ' (status code: unknown)'}"
16
+ elif status_code:
17
+ return f"{base_msg}: HTTP {status_code}"
18
+ else:
19
+ return f"{base_msg}: {str(response)}"
20
+ else:
21
+ return f"{base_msg}: {str(e)}"