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