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.
- scanner_cli-0.1.0.dist-info/METADATA +199 -0
- scanner_cli-0.1.0.dist-info/RECORD +13 -0
- scanner_cli-0.1.0.dist-info/WHEEL +4 -0
- scanner_cli-0.1.0.dist-info/entry_points.txt +3 -0
- src/__init__.py +0 -0
- src/cli.py +402 -0
- src/detection_rule_yaml.py +42 -0
- src/migrate/__init__.py +0 -0
- src/migrate/elastic.py +235 -0
- src/sync.py +114 -0
- src/sync_git_repo.py +226 -0
- src/sync_git_repo_result.py +107 -0
- src/utils.py +21 -0
|
@@ -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,,
|
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
|
src/migrate/__init__.py
ADDED
|
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)}"
|