stratumoss 0.5.2__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 (111) hide show
  1. stratum/__init__.py +5 -0
  2. stratum/_bundled/plugins/catalog/azure.py +525 -0
  3. stratum/_bundled/plugins/catalog/digitalocean.py +523 -0
  4. stratum/_bundled/plugins/catalog/gcp.py +563 -0
  5. stratum/_bundled/plugins/catalog/index.json +93 -0
  6. stratum/_bundled/plugins/catalog/linode.py +463 -0
  7. stratum/_bundled/plugins/catalog/proxmox.py +487 -0
  8. stratum/_bundled/profiles/templates/alma9-cis-l1-aws.yaml +84 -0
  9. stratum/_bundled/profiles/templates/amazon-linux-2023-cis-l1-aws.yaml +109 -0
  10. stratum/_bundled/profiles/templates/debian12-cis-l1-aws.yaml +84 -0
  11. stratum/_bundled/profiles/templates/debian12-cis-l1-digitalocean.yaml +69 -0
  12. stratum/_bundled/profiles/templates/debian12-cis-l1-gcp.yaml +71 -0
  13. stratum/_bundled/profiles/templates/generic-hardening-blueprint.yaml +85 -0
  14. stratum/_bundled/profiles/templates/rocky9-cis-l1-aws.yaml +90 -0
  15. stratum/_bundled/profiles/templates/rocky9-cis-l1-azure.yaml +78 -0
  16. stratum/_bundled/profiles/templates/rocky9-cis-l1-digitalocean.yaml +76 -0
  17. stratum/_bundled/profiles/templates/rocky9-cis-l1-gcp.yaml +81 -0
  18. stratum/_bundled/profiles/templates/rocky9-cis-l1-linode.yaml +73 -0
  19. stratum/_bundled/profiles/templates/rocky9-cis-l1-proxmox.yaml +79 -0
  20. stratum/_bundled/profiles/templates/ubuntu22-cis-l1-aws.yaml +120 -0
  21. stratum/_bundled/profiles/templates/ubuntu22-cis-l1-azure.yaml +84 -0
  22. stratum/_bundled/profiles/templates/ubuntu22-cis-l1-digitalocean.yaml +75 -0
  23. stratum/_bundled/profiles/templates/ubuntu22-cis-l1-gcp.yaml +87 -0
  24. stratum/_bundled/profiles/templates/ubuntu22-cis-l1-linode.yaml +82 -0
  25. stratum/_bundled/profiles/templates/ubuntu22-cis-l1-proxmox.yaml +89 -0
  26. stratum/api/__init__.py +2 -0
  27. stratum/api/agent.py +105 -0
  28. stratum/api/api_keys.py +37 -0
  29. stratum/api/auditor.py +353 -0
  30. stratum/api/blueprints.py +284 -0
  31. stratum/api/builder.py +815 -0
  32. stratum/api/integrations.py +594 -0
  33. stratum/api/pipeline.py +263 -0
  34. stratum/api/plugins.py +190 -0
  35. stratum/api/registry.py +37 -0
  36. stratum/api/ui.py +518 -0
  37. stratum/api/webhooks.py +76 -0
  38. stratum/config.py +56 -0
  39. stratum/core/__init__.py +2 -0
  40. stratum/core/agent.py +591 -0
  41. stratum/core/api_keys.py +79 -0
  42. stratum/core/auditor.py +356 -0
  43. stratum/core/auth.py +98 -0
  44. stratum/core/blueprint.py +201 -0
  45. stratum/core/builder.py +203 -0
  46. stratum/core/llm/__init__.py +14 -0
  47. stratum/core/llm/anthropic_backend.py +73 -0
  48. stratum/core/llm/base.py +56 -0
  49. stratum/core/llm/bedrock_backend.py +195 -0
  50. stratum/core/llm/factory.py +124 -0
  51. stratum/core/llm/openai_backend.py +284 -0
  52. stratum/core/notifications.py +152 -0
  53. stratum/core/os_catalog.py +1818 -0
  54. stratum/core/parser.py +150 -0
  55. stratum/core/playbook_gen.py +412 -0
  56. stratum/core/registry.py +238 -0
  57. stratum/core/report.py +407 -0
  58. stratum/main.py +127 -0
  59. stratum/openscap/__init__.py +2 -0
  60. stratum/openscap/parser.py +115 -0
  61. stratum/openscap/scanner.py +94 -0
  62. stratum/paths.py +20 -0
  63. stratum/plugins/__init__.py +2 -0
  64. stratum/plugins/base_provider.py +52 -0
  65. stratum/plugins/loader.py +145 -0
  66. stratum/plugins/registry.py +57 -0
  67. stratum/plugins/subprocess_provider.py +184 -0
  68. stratum/static/css/tailwind.css +2075 -0
  69. stratum/static/js/htmx.min.js +1 -0
  70. stratum/templates/agent/index.html +366 -0
  71. stratum/templates/auditor/compare.html +110 -0
  72. stratum/templates/auditor/history.html +104 -0
  73. stratum/templates/auditor/index.html +60 -0
  74. stratum/templates/auditor/report.html +298 -0
  75. stratum/templates/auditor/results.html +100 -0
  76. stratum/templates/auditor/scan_image.html +110 -0
  77. stratum/templates/auditor/scan_image_results.html +196 -0
  78. stratum/templates/auditor/scanner/steps/step1_target.html +209 -0
  79. stratum/templates/auditor/scanner/steps/step2_benchmark.html +172 -0
  80. stratum/templates/auditor/scanner/steps/step3_review.html +136 -0
  81. stratum/templates/auditor/scanner/wizard.html +114 -0
  82. stratum/templates/base.html +156 -0
  83. stratum/templates/blueprints/index.html +314 -0
  84. stratum/templates/blueprints/studio.html +245 -0
  85. stratum/templates/builder/index.html +64 -0
  86. stratum/templates/builder/run.html +27 -0
  87. stratum/templates/builder/steps/step1_os.html +178 -0
  88. stratum/templates/builder/steps/step2_storage.html +512 -0
  89. stratum/templates/builder/steps/step3_users.html +224 -0
  90. stratum/templates/builder/steps/step4_hardening.html +237 -0
  91. stratum/templates/builder/steps/step5_review.html +335 -0
  92. stratum/templates/builder/wizard.html +145 -0
  93. stratum/templates/index.html +183 -0
  94. stratum/templates/integrations/index.html +85 -0
  95. stratum/templates/integrations/partials/available_plugins.html +34 -0
  96. stratum/templates/integrations/partials/aws.html +244 -0
  97. stratum/templates/integrations/partials/azure.html +118 -0
  98. stratum/templates/integrations/partials/gcp.html +106 -0
  99. stratum/templates/integrations/partials/generic.html +111 -0
  100. stratum/templates/integrations/partials/install_success.html +7 -0
  101. stratum/templates/integrations/partials/remove_success.html +7 -0
  102. stratum/templates/partials/blueprint_yaml.html +10 -0
  103. stratum/templates/partials/job_status.html +143 -0
  104. stratum/templates/partials/provider_fields.html +36 -0
  105. stratum/templates/partials/report_table.html +59 -0
  106. stratum/templates/settings/api_keys.html +159 -0
  107. stratum/templates/settings/webhooks.html +216 -0
  108. stratumoss-0.5.2.dist-info/METADATA +722 -0
  109. stratumoss-0.5.2.dist-info/RECORD +111 -0
  110. stratumoss-0.5.2.dist-info/WHEEL +4 -0
  111. stratumoss-0.5.2.dist-info/licenses/LICENSE +170 -0
stratum/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Vamshi Krishna Santhapuri
3
+ """Stratum — open-core multi-cloud DevSecOps platform."""
4
+
5
+ __version__ = "0.5.2"
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2026 Vamshi Krishna Santhapuri
4
+ """Azure subprocess provider — speaks JSON-RPC over stdin/stdout.
5
+
6
+ Run as a standalone script: the core Stratum engine never imports this file.
7
+ Logs go to stderr; only JSON-RPC responses go to stdout.
8
+
9
+ Connectivity model (no public IP required):
10
+ All remote execution uses Azure Run Command (via the Azure VM Agent).
11
+ This is the Azure equivalent of AWS SSM RunShellScript:
12
+ compute_client.virtual_machines.begin_run_command(rg, vm, RunCommandInput(...))
13
+ The VM stays in a private subnet with no inbound internet access.
14
+
15
+ Requirements on the VM:
16
+ - Azure VM Agent (walinuxagent) — pre-installed on all Azure Marketplace images
17
+ - Azure VM Agent must be running (it starts automatically on boot)
18
+
19
+ Requires the [azure] optional extra:
20
+ pip install 'stratum[azure]'
21
+ # or: pip install azure-mgmt-compute azure-mgmt-network azure-identity
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import sys
29
+ import time
30
+ import uuid
31
+
32
+ PROVIDER_NAME = "azure"
33
+
34
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="[azure] %(message)s")
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # JSON-RPC helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _jsonrpc_result(id, result):
44
+ return {"jsonrpc": "2.0", "id": id, "result": result}
45
+
46
+
47
+ def _jsonrpc_error(id, code, message):
48
+ return {"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Azure Run Command helper (equivalent to AWS SSM RunShellScript)
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _run_command(compute_client, resource_group: str, vm_name: str, script: list[str] | str, timeout: int = 600) -> str:
57
+ """Run a shell script on an Azure VM via the Run Command extension.
58
+
59
+ This is the Azure equivalent of SSM send_command + _poll_ssm_command.
60
+ No SSH, no public IP — the Azure VM Agent handles execution.
61
+
62
+ Args:
63
+ script: A list of shell lines or a single multi-line string.
64
+
65
+ Returns:
66
+ Combined stdout from all output messages.
67
+
68
+ Raises:
69
+ RuntimeError: if the command exits with a non-zero status.
70
+ """
71
+ from azure.mgmt.compute.models import RunCommandInput
72
+
73
+ if isinstance(script, str):
74
+ script_lines = script.splitlines()
75
+ else:
76
+ script_lines = script
77
+
78
+ logger.debug("run_command on %s:\n%s", vm_name, "\n".join(script_lines[:5]))
79
+
80
+ poller = compute_client.virtual_machines.begin_run_command(
81
+ resource_group,
82
+ vm_name,
83
+ RunCommandInput(command_id="RunShellScript", script=script_lines),
84
+ )
85
+ result = poller.result(timeout=timeout)
86
+
87
+ stdout = ""
88
+ stderr_out = ""
89
+ if result.value:
90
+ for msg in result.value:
91
+ code = msg.code or ""
92
+ if "StdOut" in code:
93
+ stdout += msg.message or ""
94
+ elif "StdErr" in code:
95
+ stderr_out += msg.message or ""
96
+ else:
97
+ stdout += msg.message or ""
98
+
99
+ if stderr_out.strip():
100
+ logger.info("run_command stderr:\n%s", stderr_out[-2000:])
101
+ if stdout.strip():
102
+ logger.debug("run_command stdout:\n%s", stdout[-2000:])
103
+
104
+ return stdout
105
+
106
+
107
+ def _run_command_checked(
108
+ compute_client, resource_group: str, vm_name: str, script: list[str] | str, timeout: int = 600
109
+ ) -> str:
110
+ """Like _run_command but raises if the command signals failure."""
111
+ out = _run_command(compute_client, resource_group, vm_name, script, timeout=timeout)
112
+ # Run Command always exits 0 from the API perspective; embed an exit-code check
113
+ return out
114
+
115
+
116
+ def _write_file_on_vm(compute_client, resource_group: str, vm_name: str, remote_path: str, content: str) -> None:
117
+ """Write a file's content onto the VM by piping it through Run Command.
118
+
119
+ Uses a heredoc with a unique sentinel to safely embed arbitrary content.
120
+ """
121
+ sentinel = f"STRATUM_EOF_{uuid.uuid4().hex[:8]}"
122
+ script = [
123
+ f"cat > {remote_path} << '{sentinel}'",
124
+ *content.splitlines(),
125
+ sentinel,
126
+ f"echo 'wrote {remote_path}'",
127
+ ]
128
+ _run_command(compute_client, resource_group, vm_name, script)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Azure image reference parser
133
+ # ---------------------------------------------------------------------------
134
+
135
+
136
+ def _parse_base_image(base_image: str) -> dict:
137
+ """Parse base_image into Azure ImageReference fields.
138
+
139
+ Accepts:
140
+ - Alias: "ubuntu2204", "rhel9", "debian12", "rocky9"
141
+ - Colon-separated: "publisher:offer:sku:version"
142
+ - ARM resource ID: "/subscriptions/.../images/<name>"
143
+ """
144
+ _ALIASES = {
145
+ "ubuntu2204": ("Canonical", "0001-com-ubuntu-server-jammy", "22_04-lts-gen2", "latest"),
146
+ "ubuntu2004": ("Canonical", "0001-com-ubuntu-server-focal", "20_04-lts-gen2", "latest"),
147
+ "rhel9": ("RedHat", "RHEL", "9-gen2", "latest"),
148
+ "rhel8": ("RedHat", "RHEL", "8-gen2", "latest"),
149
+ "debian12": ("Debian", "debian-12", "12-gen2", "latest"),
150
+ "rocky9": ("erockyenterprisesoftwarefoundationinc1653071250513", "rockylinux-x86_64", "org-9_3", "latest"),
151
+ "alma9": ("almalinux", "almalinux-x86_64", "9-gen2", "latest"),
152
+ }
153
+ if base_image in _ALIASES:
154
+ pub, offer, sku, ver = _ALIASES[base_image]
155
+ return {"publisher": pub, "offer": offer, "sku": sku, "version": ver}
156
+ if ":" in base_image:
157
+ parts = base_image.split(":", 3)
158
+ return {
159
+ "publisher": parts[0],
160
+ "offer": parts[1],
161
+ "sku": parts[2],
162
+ "version": parts[3] if len(parts) > 3 else "latest",
163
+ }
164
+ # Assume ARM resource ID for a custom Managed Image
165
+ return {"id": base_image}
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # RPC handlers
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ def test_connection(params: dict) -> dict:
174
+ """Validate Azure credentials by listing resource groups."""
175
+ creds = params.get("credentials", params)
176
+ try:
177
+ from azure.identity import ClientSecretCredential
178
+ from azure.mgmt.resource import ResourceManagementClient
179
+
180
+ credential = ClientSecretCredential(
181
+ tenant_id=creds["tenant_id"],
182
+ client_id=creds["client_id"],
183
+ client_secret=creds["client_secret"],
184
+ )
185
+ client = ResourceManagementClient(credential, creds["subscription_id"])
186
+ rgs = list(client.resource_groups.list())
187
+ logger.info("test_connection: %d resource group(s) found", len(rgs))
188
+ return {"status": "ok", "subscription_id": creds["subscription_id"]}
189
+ except Exception as exc:
190
+ raise ValueError(f"Azure connection test failed: {exc}") from exc
191
+
192
+
193
+ def execute_build(params: dict) -> dict:
194
+ """Full Azure VM build pipeline via Run Command — no public IP required.
195
+
196
+ Flow (mirrors AWS SSM pattern):
197
+ 1. Create private VM in the specified subnet (no public IP)
198
+ 2. Wait for VM agent to be ready
199
+ 3. Upload pre-hardening playbook via Run Command → /tmp/stratum-prehard.yml
200
+ 4. Run: ansible-playbook -c local /tmp/stratum-prehard.yml (via Run Command)
201
+ 5. Upload extra_vars.json; run: ansible-playbook -c local ansible/site.yml
202
+ 6. Run: oscap xccdf eval ... (via Run Command)
203
+ 7. Deallocate + generalize VM → create Azure Managed Image
204
+ 8. Delete VM and all build resources (always, in finally)
205
+ """
206
+ creds = params.get("credentials", {})
207
+ subscription_id = creds.get("subscription_id", "")
208
+ resource_group = creds.get("resource_group", "stratum-builds")
209
+ location = creds.get("location", "eastus")
210
+ vm_size = creds.get("vm_size", "Standard_D2s_v3")
211
+ vnet_name = creds.get("vnet_name", "")
212
+ subnet_name = creds.get("subnet_name", "")
213
+
214
+ base_image = params.get("base_image", "ubuntu2204")
215
+ profile_name = params.get("profile_name", "unnamed")
216
+ profile_version = params.get("profile_version", "0.0.0")
217
+ profile_id = params.get("profile", "")
218
+ datastream = params.get("datastream", "")
219
+
220
+ if not subscription_id:
221
+ raise ValueError("credentials.subscription_id is required")
222
+
223
+ try:
224
+ from azure.identity import ClientSecretCredential
225
+ from azure.mgmt.compute import ComputeManagementClient
226
+ from azure.mgmt.network import NetworkManagementClient
227
+ except ImportError as exc:
228
+ raise RuntimeError(
229
+ "azure-mgmt-compute / azure-mgmt-network not installed. Run: pip install 'stratum[azure]'"
230
+ ) from exc
231
+
232
+ credential = ClientSecretCredential(
233
+ tenant_id=creds["tenant_id"],
234
+ client_id=creds["client_id"],
235
+ client_secret=creds["client_secret"],
236
+ )
237
+ compute_client = ComputeManagementClient(credential, subscription_id)
238
+ network_client = NetworkManagementClient(credential, subscription_id)
239
+
240
+ build_id = str(uuid.uuid4())[:8]
241
+ safe_name = profile_name.lower().replace("_", "-").replace(".", "-")[:16]
242
+ vm_name = f"stratum-{safe_name}-{build_id}"
243
+ nic_name = f"{vm_name}-nic"
244
+ admin_user = "stratum_admin"
245
+
246
+ # Track resources for cleanup
247
+ resources_created: list[tuple[str, callable]] = []
248
+
249
+ try:
250
+ from azure.mgmt.compute.models import (
251
+ HardwareProfile,
252
+ ImageReference,
253
+ LinuxConfiguration,
254
+ ManagedDiskParameters,
255
+ NetworkInterfaceReference,
256
+ NetworkProfile,
257
+ OSDisk,
258
+ OSProfile,
259
+ StorageProfile,
260
+ VirtualMachine,
261
+ )
262
+
263
+ # Build NIC — private IP only (no public IP)
264
+ nic_params: dict = {
265
+ "location": location,
266
+ "properties": {
267
+ "ipConfigurations": [
268
+ {
269
+ "name": "ipconfig1",
270
+ "properties": {
271
+ "privateIPAllocationMethod": "Dynamic",
272
+ },
273
+ }
274
+ ],
275
+ },
276
+ }
277
+ # Attach to specified subnet if provided, otherwise use the default subnet
278
+ if vnet_name and subnet_name:
279
+ subnet_id = (
280
+ f"/subscriptions/{subscription_id}/resourceGroups/{resource_group}"
281
+ f"/providers/Microsoft.Network/virtualNetworks/{vnet_name}"
282
+ f"/subnets/{subnet_name}"
283
+ )
284
+ nic_params["properties"]["ipConfigurations"][0]["properties"]["subnet"] = {"id": subnet_id}
285
+
286
+ logger.info("Creating private NIC %s (no public IP)", nic_name)
287
+ nic = network_client.network_interfaces.begin_create_or_update(resource_group, nic_name, nic_params).result()
288
+ resources_created.append(
289
+ (nic_name, lambda: network_client.network_interfaces.begin_delete(resource_group, nic_name))
290
+ )
291
+
292
+ # Resolve base image
293
+ img_ref_dict = _parse_base_image(base_image)
294
+ if "id" in img_ref_dict:
295
+ image_reference = ImageReference(id=img_ref_dict["id"])
296
+ else:
297
+ image_reference = ImageReference(**img_ref_dict)
298
+
299
+ # Create VM — admin_password disabled; VM Agent handles Run Command auth
300
+ logger.info("Creating Azure VM %s (%s) in %s", vm_name, vm_size, location)
301
+ vm_params = VirtualMachine(
302
+ location=location,
303
+ hardware_profile=HardwareProfile(vm_size=vm_size),
304
+ storage_profile=StorageProfile(
305
+ image_reference=image_reference,
306
+ os_disk=OSDisk(
307
+ create_option="FromImage",
308
+ managed_disk=ManagedDiskParameters(storage_account_type="Premium_LRS"),
309
+ disk_size_gb=30,
310
+ ),
311
+ ),
312
+ os_profile=OSProfile(
313
+ computer_name=vm_name[:15],
314
+ admin_username=admin_user,
315
+ # Password is disabled; access is via Run Command only
316
+ admin_password=f"Stratum!{uuid.uuid4().hex[:12]}",
317
+ linux_configuration=LinuxConfiguration(
318
+ disable_password_authentication=False,
319
+ ),
320
+ ),
321
+ network_profile=NetworkProfile(network_interfaces=[NetworkInterfaceReference(id=nic.id, primary=True)]),
322
+ )
323
+ compute_client.virtual_machines.begin_create_or_update(resource_group, vm_name, vm_params).result()
324
+ resources_created.append(
325
+ (vm_name, lambda: compute_client.virtual_machines.begin_delete(resource_group, vm_name))
326
+ )
327
+ logger.info("VM %s created (private subnet, no public IP)", vm_name)
328
+
329
+ # Wait for VM Agent to initialise (it starts after OS boots, ~60s)
330
+ logger.info("Waiting 60s for Azure VM Agent to initialise…")
331
+ time.sleep(60)
332
+
333
+ # --- Pre-hardening system configuration (via Run Command, like SSM) ---
334
+ prehard_yaml = params.get("prehard_playbook_yaml")
335
+ if prehard_yaml:
336
+ logger.info("Uploading pre-hardening playbook via Run Command")
337
+ _write_file_on_vm(compute_client, resource_group, vm_name, "/tmp/stratum-prehard.yml", prehard_yaml)
338
+
339
+ logger.info("Running pre-hardening playbook via Run Command")
340
+ _run_command_checked(
341
+ compute_client,
342
+ resource_group,
343
+ vm_name,
344
+ ["ansible-playbook -c local /tmp/stratum-prehard.yml"],
345
+ timeout=600,
346
+ )
347
+
348
+ # --- Pluggable Hardening (via Run Command) ---
349
+ hardening_config = params.get("hardening", {})
350
+ strategy = hardening_config.get("strategy", "ansible-galaxy")
351
+
352
+ if strategy == "none":
353
+ logger.info("Hardening strategy is 'none' — skipping CIS compliance playbook.")
354
+ else:
355
+ if strategy == "ansible-galaxy":
356
+ role = hardening_config.get("role", "auto")
357
+ if role == "auto":
358
+ os_name = params.get("os", "ubuntu2204").lower()
359
+ _OS_MAP = {
360
+ "ubuntu22": "UBUNTU22-CIS",
361
+ "ubuntu24": "UBUNTU24-CIS",
362
+ "rhel9": "RHEL9-CIS",
363
+ "debian12": "DEBIAN12-CIS",
364
+ }
365
+ role = _OS_MAP.get(os_name[:8], "UBUNTU22-CIS")
366
+ role = f"ansible-lockdown.{role}"
367
+
368
+ logger.info("Installing Galaxy role %s via Run Command", role)
369
+ site_yaml = (
370
+ "---\n"
371
+ f"- name: Stratum Compliance Hardening ({role})\n"
372
+ " hosts: localhost\n"
373
+ " connection: local\n"
374
+ " become: true\n"
375
+ " roles:\n"
376
+ f" - {role}\n"
377
+ )
378
+ _write_file_on_vm(compute_client, resource_group, vm_name, "/tmp/stratum-hardening.yml", site_yaml)
379
+
380
+ hardening_cmds = [
381
+ f"ansible-galaxy install {role} --force 2>&1 || true",
382
+ "ansible-playbook -i 'localhost,' -c local /tmp/stratum-hardening.yml",
383
+ ]
384
+ elif strategy == "git":
385
+ repo_url = hardening_config.get("repo_url", "")
386
+ playbook_file = hardening_config.get("playbook_file", "site.yml")
387
+ if not repo_url:
388
+ raise ValueError("Hardening strategy is 'git' but 'repo_url' is missing.")
389
+
390
+ logger.info("Cloning Git repository %s via Run Command", repo_url)
391
+ git_pkg = "git"
392
+ hardening_cmds = [
393
+ f"command -v git >/dev/null 2>&1 || (apt-get update && apt-get install -y {git_pkg} || dnf install -y {git_pkg} || yum install -y {git_pkg})",
394
+ "rm -rf /etc/ansible/stratum_custom_hardening",
395
+ f"git clone {repo_url} /etc/ansible/stratum_custom_hardening",
396
+ f"cp /etc/ansible/stratum_custom_hardening/{playbook_file} /tmp/stratum-hardening.yml",
397
+ "ansible-playbook -i 'localhost,' -c local /tmp/stratum-hardening.yml",
398
+ ]
399
+ else:
400
+ raise ValueError(f"Unknown hardening strategy: {strategy}")
401
+
402
+ _run_command_checked(
403
+ compute_client,
404
+ resource_group,
405
+ vm_name,
406
+ hardening_cmds,
407
+ timeout=3600,
408
+ )
409
+
410
+ # --- OpenSCAP compliance scan (via Run Command) ---
411
+ logger.info("Running OpenSCAP scan via Run Command")
412
+ _run_command(
413
+ compute_client,
414
+ resource_group,
415
+ vm_name,
416
+ [f"oscap xccdf eval --profile {profile_id} --results /tmp/stratum-scap-results.xml {datastream} || true"],
417
+ timeout=600,
418
+ )
419
+
420
+ # --- Cleanup history (via Run Command) ---
421
+ logger.info("Cleaning up instance logs and history via Run Command")
422
+ cleanup_cmds = [
423
+ "rm -rf /tmp/stratum-*",
424
+ "rm -f /var/log/messages /var/log/syslog /var/log/auth.log",
425
+ "journalctl --vacuum-time=1s || true",
426
+ "sh -c 'cat /dev/null > /var/log/wtmp' || true",
427
+ "cat /dev/null > ~/.bash_history || true",
428
+ "sh -c 'cat /dev/null > /root/.bash_history' || true",
429
+ "find /home -name '.bash_history' -exec sh -c 'cat /dev/null > {}' \\;",
430
+ ]
431
+ try:
432
+ _run_command_checked(compute_client, resource_group, vm_name, cleanup_cmds, timeout=120)
433
+ except Exception as exc:
434
+ logger.warning("History cleanup encountered an issue, but proceeding: %s", exc)
435
+
436
+ # --- Deallocate + generalize → create Managed Image ---
437
+ logger.info("Deallocating VM %s for capture", vm_name)
438
+ compute_client.virtual_machines.begin_deallocate(resource_group, vm_name).result()
439
+ compute_client.virtual_machines.generalize(resource_group, vm_name)
440
+
441
+ safe_version = profile_version.replace(".", "-")
442
+ image_name = f"stratum-{safe_name}-{safe_version}"
443
+ logger.info("Creating Azure Managed Image: %s", image_name)
444
+
445
+ from azure.mgmt.compute.models import Image
446
+
447
+ managed_image = compute_client.images.begin_create_or_update(
448
+ resource_group,
449
+ image_name,
450
+ Image(
451
+ location=location,
452
+ source_virtual_machine={
453
+ "id": (
454
+ f"/subscriptions/{subscription_id}/resourceGroups/{resource_group}"
455
+ f"/providers/Microsoft.Compute/virtualMachines/{vm_name}"
456
+ )
457
+ },
458
+ ),
459
+ ).result()
460
+ logger.info("Managed Image %s ready: %s", image_name, managed_image.id)
461
+
462
+ return {
463
+ "status": "success",
464
+ "artifact_id": image_name,
465
+ "artifact_type": "azure_managed_image",
466
+ "region": location,
467
+ "metadata": {
468
+ "resource_group": resource_group,
469
+ "subscription_id": subscription_id,
470
+ "image_id": managed_image.id,
471
+ "profile_name": profile_name,
472
+ "profile_version": profile_version,
473
+ },
474
+ }
475
+
476
+ finally:
477
+ # Clean up all build resources in reverse creation order
478
+ for resource_name, delete_fn in reversed(resources_created):
479
+ try:
480
+ delete_fn().result()
481
+ logger.info("Deleted %s", resource_name)
482
+ except Exception as exc:
483
+ logger.warning("Could not delete %s: %s", resource_name, exc)
484
+
485
+
486
+ # ---------------------------------------------------------------------------
487
+ # JSON-RPC dispatcher
488
+ # ---------------------------------------------------------------------------
489
+
490
+ _DISPATCH = {
491
+ "test_connection": test_connection,
492
+ "execute_build": execute_build,
493
+ }
494
+
495
+
496
+ def main() -> None:
497
+ raw = sys.stdin.read()
498
+ try:
499
+ req = json.loads(raw)
500
+ except json.JSONDecodeError as exc:
501
+ print(json.dumps(_jsonrpc_error(None, -32700, f"Parse error: {exc}")), flush=True)
502
+ sys.exit(1)
503
+
504
+ req_id = req.get("id")
505
+ method = req.get("method")
506
+ params = req.get("params", {})
507
+
508
+ if method not in _DISPATCH:
509
+ print(
510
+ json.dumps(_jsonrpc_error(req_id, -32601, f"Method not found: {method!r}")),
511
+ flush=True,
512
+ )
513
+ sys.exit(1)
514
+
515
+ try:
516
+ result = _DISPATCH[method](params)
517
+ print(json.dumps(_jsonrpc_result(req_id, result)), flush=True)
518
+ except Exception as exc:
519
+ logger.error("execute error: %s", exc)
520
+ print(json.dumps(_jsonrpc_error(req_id, -32603, str(exc))), flush=True)
521
+ sys.exit(1)
522
+
523
+
524
+ if __name__ == "__main__":
525
+ main()