csw-tools 0.3.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.
Files changed (50) hide show
  1. csw_tools/__init__.py +1 -0
  2. csw_tools/__main__.py +6 -0
  3. csw_tools/change_control.py +64 -0
  4. csw_tools/cli.py +291 -0
  5. csw_tools/commands/__init__.py +1 -0
  6. csw_tools/commands/clean_stale_labels/README.md +91 -0
  7. csw_tools/commands/clean_stale_labels/__init__.py +5 -0
  8. csw_tools/commands/clean_stale_labels/command.py +272 -0
  9. csw_tools/commands/common.py +55 -0
  10. csw_tools/commands/configure_credentials/README.md +64 -0
  11. csw_tools/commands/configure_credentials/__init__.py +5 -0
  12. csw_tools/commands/configure_credentials/command.py +94 -0
  13. csw_tools/commands/convert_labels/README.md +103 -0
  14. csw_tools/commands/convert_labels/__init__.py +5 -0
  15. csw_tools/commands/convert_labels/command.py +197 -0
  16. csw_tools/commands/create_scopes/README.md +156 -0
  17. csw_tools/commands/create_scopes/__init__.py +5 -0
  18. csw_tools/commands/create_scopes/command.py +673 -0
  19. csw_tools/commands/init/README.md +59 -0
  20. csw_tools/commands/init/__init__.py +5 -0
  21. csw_tools/commands/init/command.py +120 -0
  22. csw_tools/commands/prune_agents/README.md +169 -0
  23. csw_tools/commands/prune_agents/__init__.py +5 -0
  24. csw_tools/commands/prune_agents/api.py +145 -0
  25. csw_tools/commands/prune_agents/command.py +271 -0
  26. csw_tools/commands/prune_agents/execution.py +410 -0
  27. csw_tools/commands/prune_agents/planner.py +375 -0
  28. csw_tools/commands/prune_policy/README.md +19 -0
  29. csw_tools/commands/prune_policy/__init__.py +5 -0
  30. csw_tools/commands/prune_policy/command.py +20 -0
  31. csw_tools/commands/sync_collection_rules/README.md +19 -0
  32. csw_tools/commands/sync_collection_rules/__init__.py +5 -0
  33. csw_tools/commands/sync_collection_rules/command.py +20 -0
  34. csw_tools/config.example.toml +45 -0
  35. csw_tools/config.py +204 -0
  36. csw_tools/config_defaults.py +36 -0
  37. csw_tools/context.py +90 -0
  38. csw_tools/csw_api.py +201 -0
  39. csw_tools/dashboard.py +137 -0
  40. csw_tools/dashboard_context.py +52 -0
  41. csw_tools/interaction.py +37 -0
  42. csw_tools/keyring_store.py +39 -0
  43. csw_tools/output_logging.py +271 -0
  44. csw_tools/project-python-version.txt +1 -0
  45. csw_tools/project_info.py +121 -0
  46. csw_tools-0.3.0.dist-info/METADATA +184 -0
  47. csw_tools-0.3.0.dist-info/RECORD +50 -0
  48. csw_tools-0.3.0.dist-info/WHEEL +4 -0
  49. csw_tools-0.3.0.dist-info/entry_points.txt +3 -0
  50. csw_tools-0.3.0.dist-info/licenses/LICENSE +201 -0
csw_tools/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Shared framework for Cisco Secure Workload utilities."""
csw_tools/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run csw-tools with ``python -m csw_tools``."""
2
+
3
+ from csw_tools.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
@@ -0,0 +1,64 @@
1
+ """Backups and rollback validation for mutating commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+
9
+ from csw_tools.dashboard import Dashboard
10
+
11
+ BACKUP_FORMAT_VERSION = 1
12
+
13
+
14
+ class BackupError(ValueError):
15
+ """Raised when a backup cannot be safely created or consumed."""
16
+
17
+
18
+ def new_backup(
19
+ *, command: str, dashboard: Dashboard, operations: list[dict[str, object]]
20
+ ) -> dict[str, object]:
21
+ return {
22
+ "format_version": BACKUP_FORMAT_VERSION,
23
+ "command": command,
24
+ "dashboard": dashboard.name,
25
+ "created_at": datetime.now(UTC).isoformat(),
26
+ "operations": operations,
27
+ }
28
+
29
+
30
+ def backup_path(directory: Path, command: str) -> Path:
31
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
32
+ return directory.expanduser().resolve() / f"{command}-{timestamp}.json"
33
+
34
+
35
+ def write_backup(path: Path, backup: dict[str, object]) -> None:
36
+ """Atomically write a backup, including incremental operation results."""
37
+
38
+ try:
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ temporary = path.with_suffix(path.suffix + ".tmp")
41
+ temporary.write_text(
42
+ json.dumps(backup, indent=2, sort_keys=True) + "\n", encoding="utf-8"
43
+ )
44
+ temporary.replace(path)
45
+ except OSError as exc:
46
+ raise BackupError(f"Could not write backup: {path}") from exc
47
+
48
+
49
+ def load_backup(path: Path, *, command: str, dashboard: Dashboard) -> dict[str, object]:
50
+ try:
51
+ backup = json.loads(path.expanduser().read_text(encoding="utf-8"))
52
+ except (OSError, json.JSONDecodeError) as exc:
53
+ raise BackupError(f"Could not read backup: {path}") from exc
54
+ if not isinstance(backup, dict):
55
+ raise BackupError("Backup root must be a JSON object")
56
+ if backup.get("format_version") != BACKUP_FORMAT_VERSION:
57
+ raise BackupError("Unsupported backup format version")
58
+ if backup.get("command") != command:
59
+ raise BackupError(f"Backup was not created by '{command}'")
60
+ if backup.get("dashboard") != dashboard.name:
61
+ raise BackupError("Backup dashboard does not match the active CSW dashboard")
62
+ if not isinstance(backup.get("operations"), list):
63
+ raise BackupError("Backup operations must be a JSON array")
64
+ return backup
csw_tools/cli.py ADDED
@@ -0,0 +1,291 @@
1
+ """Root command-line interface for csw-tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import click
11
+ from rich.console import Console
12
+
13
+ from csw_tools import config as config_module
14
+ from csw_tools.commands.clean_stale_labels import (
15
+ command as clean_stale_labels_command,
16
+ )
17
+ from csw_tools.commands.configure_credentials import (
18
+ command as configure_credentials_command,
19
+ )
20
+ from csw_tools.commands.convert_labels import command as convert_labels_command
21
+ from csw_tools.commands.create_scopes import command as create_scopes_command
22
+ from csw_tools.commands.init import command as init_command
23
+ from csw_tools.commands.prune_agents import command as prune_agents_command
24
+ from csw_tools.commands.prune_policy import command as prune_policy_command
25
+ from csw_tools.commands.sync_collection_rules import (
26
+ command as sync_collection_rules_command,
27
+ )
28
+ from csw_tools.config import (
29
+ AppConfig,
30
+ ConfigError,
31
+ load_config,
32
+ resolve_setting,
33
+ )
34
+ from csw_tools.config_defaults import (
35
+ DEFAULT_DASHBOARD_VERIFY_TLS,
36
+ DEFAULT_KEYRING_SERVICE_NAME,
37
+ DEFAULT_LOG_CLI_OUTPUT,
38
+ DEFAULT_OUTPUT_DIRECTORY,
39
+ )
40
+ from csw_tools.context import AppContext
41
+ from csw_tools.dashboard import DASHBOARD, Dashboard
42
+ from csw_tools.keyring_store import KeyringStore
43
+ from csw_tools.output_logging import (
44
+ OutputLogError,
45
+ disable_failed_output_logging,
46
+ prepare_output_logging,
47
+ start_output_logging,
48
+ stop_output_logging,
49
+ )
50
+ from csw_tools.project_info import format_version_info
51
+
52
+
53
+ class OutputDirectoryPath(click.Path):
54
+ """Reject an explicitly empty directory while preserving valid '.'."""
55
+
56
+ def convert(
57
+ self,
58
+ value: str | bytes | Path,
59
+ param: click.Parameter | None,
60
+ ctx: click.Context | None,
61
+ ) -> Path:
62
+ if isinstance(value, str) and not value.strip():
63
+ self.fail("output directory cannot be empty", param, ctx)
64
+ return cast(Path, super().convert(value, param, ctx))
65
+
66
+
67
+ class FullCommandHelpGroup(click.Group):
68
+ """Wrap command summaries instead of shortening them with ellipses."""
69
+
70
+ def main(
71
+ self,
72
+ args: Sequence[str] | None = None,
73
+ prog_name: str | None = None,
74
+ complete_var: str | None = None,
75
+ standalone_mode: bool = True,
76
+ windows_expand_args: bool = True,
77
+ **extra: Any,
78
+ ) -> Any:
79
+ """Keep transcript streams active through Click's final error output."""
80
+
81
+ log_token = prepare_output_logging()
82
+ try:
83
+ return super().main(
84
+ args=args,
85
+ prog_name=prog_name,
86
+ complete_var=complete_var,
87
+ standalone_mode=standalone_mode,
88
+ windows_expand_args=windows_expand_args,
89
+ **extra,
90
+ )
91
+ except OutputLogError as exc:
92
+ disable_failed_output_logging()
93
+ error = click.ClickException(str(exc))
94
+ if not standalone_mode:
95
+ raise error from exc
96
+ error.show()
97
+ raise SystemExit(error.exit_code) from exc
98
+ finally:
99
+ stop_output_logging(log_token)
100
+
101
+ def format_commands(
102
+ self, ctx: click.Context, formatter: click.HelpFormatter
103
+ ) -> None:
104
+ rows = []
105
+ for name in self.list_commands(ctx):
106
+ command = self.get_command(ctx, name)
107
+ if command is None or command.hidden:
108
+ continue
109
+ # Preserve Click's summary and deprecation handling, but allow the
110
+ # complete summary through to the formatter's word wrapping.
111
+ summary = command.get_short_help_str(limit=len(command.help or ""))
112
+ rows.append((name, summary))
113
+ if rows:
114
+ with formatter.section("Commands"):
115
+ formatter.write_dl(rows)
116
+
117
+
118
+ def show_version(ctx: click.Context, _parameter: click.Parameter, value: bool) -> None:
119
+ """Print project and interpreter information, then exit."""
120
+
121
+ if not value or ctx.resilient_parsing:
122
+ return
123
+ try:
124
+ output = format_version_info()
125
+ except (OSError, KeyError, RuntimeError, tomllib.TOMLDecodeError) as exc:
126
+ raise click.ClickException(
127
+ f"Could not load project version information: {exc}"
128
+ ) from exc
129
+ click.echo(output)
130
+ ctx.exit()
131
+
132
+
133
+ @click.group(
134
+ cls=FullCommandHelpGroup,
135
+ context_settings={"help_option_names": ["-h", "--help"]},
136
+ )
137
+ @click.option(
138
+ "--config",
139
+ "config_path",
140
+ type=click.Path(path_type=Path, dir_okay=False),
141
+ help=(
142
+ "Use an alternate config file location instead of the per-user "
143
+ "default (config.toml)."
144
+ ),
145
+ )
146
+ @click.option(
147
+ "--keyring-service-name",
148
+ help="Override the default keyring service name (of 'csw-tools')",
149
+ )
150
+ @click.option(
151
+ "-d",
152
+ "--dashboard",
153
+ type=DASHBOARD,
154
+ help="Use this Secure Workload dashboard (SaaS name, FQDN, or HTTPS origin).",
155
+ )
156
+ @click.option(
157
+ "--dashboard-verify-tls/--no-dashboard-verify-tls",
158
+ default=None,
159
+ help="Enable or disable TLS certificate verification for dashboard APIs.",
160
+ )
161
+ @click.option(
162
+ "--output-dir",
163
+ type=OutputDirectoryPath(path_type=Path, file_okay=False),
164
+ help=(
165
+ "Directory for command-generated backups, journals, reports, and CLI "
166
+ f"logs. Default: {DEFAULT_OUTPUT_DIRECTORY}."
167
+ ),
168
+ )
169
+ @click.option(
170
+ "--log-cli-output/--no-log-cli-output",
171
+ default=None,
172
+ help=(
173
+ "Save or do not save a combined stdout/stderr transcript for the "
174
+ "selected command. Enabled by default."
175
+ ),
176
+ )
177
+ @click.option(
178
+ "--version",
179
+ is_flag=True,
180
+ is_eager=True,
181
+ expose_value=False,
182
+ callback=show_version,
183
+ help="Show project and Python version information, then exit.",
184
+ )
185
+ @click.pass_context
186
+ def cli(
187
+ ctx: click.Context,
188
+ config_path: Path | None,
189
+ keyring_service_name: str | None,
190
+ dashboard: Dashboard | None,
191
+ dashboard_verify_tls: bool | None,
192
+ output_dir: Path | None,
193
+ log_cli_output: bool | None,
194
+ ) -> None:
195
+ """A collection of automation utilities for Cisco Secure Workload (CSW)"""
196
+
197
+ try:
198
+ selected_config_path = (
199
+ (
200
+ config_path
201
+ if config_path is not None
202
+ else config_module.default_config_path()
203
+ )
204
+ .expanduser()
205
+ .resolve()
206
+ )
207
+ if ctx.invoked_subcommand == "init":
208
+ try:
209
+ config = load_config(selected_config_path, explicit=False)
210
+ except ConfigError:
211
+ # init must remain able to replace malformed configuration.
212
+ config = AppConfig()
213
+ else:
214
+ config = load_config(
215
+ selected_config_path,
216
+ explicit=config_path is not None,
217
+ )
218
+ resolved_service_name = resolve_setting(
219
+ name="keyring service name",
220
+ cli_value=keyring_service_name,
221
+ config_value=config.common.keyring_service_name,
222
+ default=DEFAULT_KEYRING_SERVICE_NAME,
223
+ )
224
+ if not resolved_service_name or not resolved_service_name.strip():
225
+ raise ConfigError("The keyring service name cannot be empty")
226
+ resolved_service_name = resolved_service_name.strip()
227
+ resolved_dashboard = resolve_setting(
228
+ name="CSW dashboard",
229
+ cli_value=dashboard,
230
+ config_value=config.common.dashboard,
231
+ )
232
+ resolved_dashboard_verify_tls = resolve_setting(
233
+ name="dashboard TLS verification",
234
+ cli_value=dashboard_verify_tls,
235
+ config_value=config.common.dashboard_verify_tls,
236
+ default=DEFAULT_DASHBOARD_VERIFY_TLS,
237
+ )
238
+ if resolved_dashboard_verify_tls is None:
239
+ raise ConfigError("Dashboard TLS verification could not be resolved")
240
+ resolved_output_dir = resolve_setting(
241
+ name="output directory",
242
+ cli_value=output_dir,
243
+ config_value=config.common.output_dir,
244
+ default=DEFAULT_OUTPUT_DIRECTORY,
245
+ )
246
+ if resolved_output_dir is None:
247
+ raise ConfigError("The output directory could not be resolved")
248
+ try:
249
+ resolved_output_dir = resolved_output_dir.expanduser().resolve()
250
+ except (OSError, RuntimeError) as exc:
251
+ raise ConfigError(
252
+ f"Could not resolve output directory: {resolved_output_dir}"
253
+ ) from exc
254
+ resolved_log_cli_output = resolve_setting(
255
+ name="CLI output logging",
256
+ cli_value=log_cli_output,
257
+ config_value=config.common.log_cli_output,
258
+ default=DEFAULT_LOG_CLI_OUTPUT,
259
+ )
260
+ if resolved_log_cli_output is None:
261
+ raise ConfigError("CLI output logging could not be resolved")
262
+ except ConfigError as exc:
263
+ raise click.ClickException(str(exc)) from exc
264
+
265
+ ctx.default_map = config.as_click_default_map()
266
+ if resolved_log_cli_output:
267
+ command_name = ctx.invoked_subcommand
268
+ assert command_name is not None
269
+ output_log = start_output_logging(resolved_output_dir, command_name)
270
+ click.echo(f"CLI output log: '{output_log.path}'", err=True)
271
+ ctx.obj = AppContext(
272
+ config=config,
273
+ config_path=selected_config_path,
274
+ keyring_service_name=resolved_service_name,
275
+ dashboard=resolved_dashboard,
276
+ dashboard_verify_tls=resolved_dashboard_verify_tls,
277
+ output_dir=resolved_output_dir,
278
+ console=Console(),
279
+ error_console=Console(stderr=True),
280
+ _keyring_factory=KeyringStore,
281
+ )
282
+
283
+
284
+ cli.add_command(configure_credentials_command)
285
+ cli.add_command(clean_stale_labels_command)
286
+ cli.add_command(convert_labels_command)
287
+ cli.add_command(create_scopes_command)
288
+ cli.add_command(init_command)
289
+ cli.add_command(prune_agents_command)
290
+ cli.add_command(prune_policy_command)
291
+ cli.add_command(sync_collection_rules_command)
@@ -0,0 +1 @@
1
+ """Click commands provided by csw-tools."""
@@ -0,0 +1,91 @@
1
+ # `csw-tools clean-stale-labels`
2
+
3
+ Status: DEV/TESTING.
4
+
5
+ Compares scope-independent static workload-label records with current CSW
6
+ inventory and removes eligible stale records.
7
+
8
+ ## Prerequisites
9
+
10
+ Select the intended dashboard and configure an API key with
11
+ `flow_inventory_query` and `user_data_upload` access. The key must be able to
12
+ see the complete relevant inventory; incomplete visibility can make an active
13
+ address appear absent.
14
+
15
+ This command requires an interactive terminal. It defaults to `--dry-run`.
16
+
17
+ ## Eligibility rules
18
+
19
+ A static-label record is eligible only when both conditions are true:
20
+
21
+ 1. No currently observed inventory IP belongs to the label record's IP address
22
+ or subnet.
23
+ 2. The record's `updatedAt` timestamp is at least `--minimum-age DAYS` old.
24
+
25
+ The default minimum age is 30 days. Numeric epoch timestamps in seconds or
26
+ milliseconds and ISO timestamps are accepted. Records without a usable
27
+ `updatedAt` value are reported and skipped.
28
+
29
+ Deletion removes the entire static-label record and all of its attributes for
30
+ that IP address or subnet; it does not remove individual keys from the record.
31
+
32
+ ## Usage
33
+
34
+ Preview with the default threshold, then apply a 60-day threshold:
35
+
36
+ ```console
37
+ csw-tools -d my-company clean-stale-labels --dry-run
38
+ csw-tools -d my-company clean-stale-labels --minimum-age 60 --apply
39
+ ```
40
+
41
+ By default the command examines all IPv4 and IPv6 static labels. Use repeatable
42
+ `--ip-range CIDR` options to limit discovery:
43
+
44
+ ```console
45
+ csw-tools -d my-company clean-stale-labels \
46
+ --ip-range 10.0.0.0/8 \
47
+ --ip-range 2001:db8::/32 \
48
+ --dry-run
49
+ ```
50
+
51
+ | Option | Meaning |
52
+ |---|---|
53
+ | `--minimum-age DAYS` | Required age before deletion; default 30 |
54
+ | `--ip-range CIDR` | Limit static-label discovery; repeat as needed |
55
+ | `--page-size INTEGER` | Current inventory records per API page; default 500 |
56
+ | `--apply` / `--dry-run` | Delete eligible records or preview; default dry-run |
57
+ | `--rollback BACKUP` | Recreate deleted records; requires `--apply` |
58
+
59
+ `minimum_age` and `page_size` can be set in the `[clean-stale-labels]`
60
+ `config.toml` table. Backups use `[common].output_dir`, whose default is
61
+ `csw-tools-outputs`. Global options such as `--output-dir` must precede the
62
+ command.
63
+
64
+ ## Plan, backup, and rollback
65
+
66
+ The plan displays each eligible IP/subnet, its timestamp, and its complete
67
+ attributes. It separately reports records skipped because their timestamp could
68
+ not be interpreted. A dry run makes no API mutations.
69
+
70
+ `--apply` writes every original attribute map to a timestamped JSON backup
71
+ before the first deletion and updates that file around each attempt. Rollback
72
+ recreates the deleted records from those saved attributes:
73
+
74
+ ```console
75
+ csw-tools -d my-company clean-stale-labels \
76
+ --apply --rollback csw-tools-outputs/clean-stale-labels-TIMESTAMP.json
77
+ ```
78
+
79
+ The backup must have been produced by this command for the same normalized
80
+ dashboard. Keep it until the resulting inventory labels have been validated.
81
+
82
+ ## Developer notes
83
+
84
+ [`command.py`](command.py) normalizes observed IPs with `ipaddress`, deduplicates
85
+ records returned by overlapping search ranges, and tests address membership
86
+ within same-family networks. `plan_cleanup()` is side-effect free apart from API
87
+ reads; deletion begins only after planning and backup creation.
88
+
89
+ Use the shared API and change-control contracts in
90
+ [DEV-GUIDELINES.md](../../../../DEV-GUIDELINES.md). Preserve the fail-safe rule
91
+ that an unparseable timestamp is never eligible.
@@ -0,0 +1,5 @@
1
+ """Remove old static labels for workloads no longer observed by CSW."""
2
+
3
+ from csw_tools.commands.clean_stale_labels.command import command
4
+
5
+ __all__ = ["command"]