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.
- stratum/__init__.py +5 -0
- stratum/_bundled/plugins/catalog/azure.py +525 -0
- stratum/_bundled/plugins/catalog/digitalocean.py +523 -0
- stratum/_bundled/plugins/catalog/gcp.py +563 -0
- stratum/_bundled/plugins/catalog/index.json +93 -0
- stratum/_bundled/plugins/catalog/linode.py +463 -0
- stratum/_bundled/plugins/catalog/proxmox.py +487 -0
- stratum/_bundled/profiles/templates/alma9-cis-l1-aws.yaml +84 -0
- stratum/_bundled/profiles/templates/amazon-linux-2023-cis-l1-aws.yaml +109 -0
- stratum/_bundled/profiles/templates/debian12-cis-l1-aws.yaml +84 -0
- stratum/_bundled/profiles/templates/debian12-cis-l1-digitalocean.yaml +69 -0
- stratum/_bundled/profiles/templates/debian12-cis-l1-gcp.yaml +71 -0
- stratum/_bundled/profiles/templates/generic-hardening-blueprint.yaml +85 -0
- stratum/_bundled/profiles/templates/rocky9-cis-l1-aws.yaml +90 -0
- stratum/_bundled/profiles/templates/rocky9-cis-l1-azure.yaml +78 -0
- stratum/_bundled/profiles/templates/rocky9-cis-l1-digitalocean.yaml +76 -0
- stratum/_bundled/profiles/templates/rocky9-cis-l1-gcp.yaml +81 -0
- stratum/_bundled/profiles/templates/rocky9-cis-l1-linode.yaml +73 -0
- stratum/_bundled/profiles/templates/rocky9-cis-l1-proxmox.yaml +79 -0
- stratum/_bundled/profiles/templates/ubuntu22-cis-l1-aws.yaml +120 -0
- stratum/_bundled/profiles/templates/ubuntu22-cis-l1-azure.yaml +84 -0
- stratum/_bundled/profiles/templates/ubuntu22-cis-l1-digitalocean.yaml +75 -0
- stratum/_bundled/profiles/templates/ubuntu22-cis-l1-gcp.yaml +87 -0
- stratum/_bundled/profiles/templates/ubuntu22-cis-l1-linode.yaml +82 -0
- stratum/_bundled/profiles/templates/ubuntu22-cis-l1-proxmox.yaml +89 -0
- stratum/api/__init__.py +2 -0
- stratum/api/agent.py +105 -0
- stratum/api/api_keys.py +37 -0
- stratum/api/auditor.py +353 -0
- stratum/api/blueprints.py +284 -0
- stratum/api/builder.py +815 -0
- stratum/api/integrations.py +594 -0
- stratum/api/pipeline.py +263 -0
- stratum/api/plugins.py +190 -0
- stratum/api/registry.py +37 -0
- stratum/api/ui.py +518 -0
- stratum/api/webhooks.py +76 -0
- stratum/config.py +56 -0
- stratum/core/__init__.py +2 -0
- stratum/core/agent.py +591 -0
- stratum/core/api_keys.py +79 -0
- stratum/core/auditor.py +356 -0
- stratum/core/auth.py +98 -0
- stratum/core/blueprint.py +201 -0
- stratum/core/builder.py +203 -0
- stratum/core/llm/__init__.py +14 -0
- stratum/core/llm/anthropic_backend.py +73 -0
- stratum/core/llm/base.py +56 -0
- stratum/core/llm/bedrock_backend.py +195 -0
- stratum/core/llm/factory.py +124 -0
- stratum/core/llm/openai_backend.py +284 -0
- stratum/core/notifications.py +152 -0
- stratum/core/os_catalog.py +1818 -0
- stratum/core/parser.py +150 -0
- stratum/core/playbook_gen.py +412 -0
- stratum/core/registry.py +238 -0
- stratum/core/report.py +407 -0
- stratum/main.py +127 -0
- stratum/openscap/__init__.py +2 -0
- stratum/openscap/parser.py +115 -0
- stratum/openscap/scanner.py +94 -0
- stratum/paths.py +20 -0
- stratum/plugins/__init__.py +2 -0
- stratum/plugins/base_provider.py +52 -0
- stratum/plugins/loader.py +145 -0
- stratum/plugins/registry.py +57 -0
- stratum/plugins/subprocess_provider.py +184 -0
- stratum/static/css/tailwind.css +2075 -0
- stratum/static/js/htmx.min.js +1 -0
- stratum/templates/agent/index.html +366 -0
- stratum/templates/auditor/compare.html +110 -0
- stratum/templates/auditor/history.html +104 -0
- stratum/templates/auditor/index.html +60 -0
- stratum/templates/auditor/report.html +298 -0
- stratum/templates/auditor/results.html +100 -0
- stratum/templates/auditor/scan_image.html +110 -0
- stratum/templates/auditor/scan_image_results.html +196 -0
- stratum/templates/auditor/scanner/steps/step1_target.html +209 -0
- stratum/templates/auditor/scanner/steps/step2_benchmark.html +172 -0
- stratum/templates/auditor/scanner/steps/step3_review.html +136 -0
- stratum/templates/auditor/scanner/wizard.html +114 -0
- stratum/templates/base.html +156 -0
- stratum/templates/blueprints/index.html +314 -0
- stratum/templates/blueprints/studio.html +245 -0
- stratum/templates/builder/index.html +64 -0
- stratum/templates/builder/run.html +27 -0
- stratum/templates/builder/steps/step1_os.html +178 -0
- stratum/templates/builder/steps/step2_storage.html +512 -0
- stratum/templates/builder/steps/step3_users.html +224 -0
- stratum/templates/builder/steps/step4_hardening.html +237 -0
- stratum/templates/builder/steps/step5_review.html +335 -0
- stratum/templates/builder/wizard.html +145 -0
- stratum/templates/index.html +183 -0
- stratum/templates/integrations/index.html +85 -0
- stratum/templates/integrations/partials/available_plugins.html +34 -0
- stratum/templates/integrations/partials/aws.html +244 -0
- stratum/templates/integrations/partials/azure.html +118 -0
- stratum/templates/integrations/partials/gcp.html +106 -0
- stratum/templates/integrations/partials/generic.html +111 -0
- stratum/templates/integrations/partials/install_success.html +7 -0
- stratum/templates/integrations/partials/remove_success.html +7 -0
- stratum/templates/partials/blueprint_yaml.html +10 -0
- stratum/templates/partials/job_status.html +143 -0
- stratum/templates/partials/provider_fields.html +36 -0
- stratum/templates/partials/report_table.html +59 -0
- stratum/templates/settings/api_keys.html +159 -0
- stratum/templates/settings/webhooks.html +216 -0
- stratumoss-0.5.2.dist-info/METADATA +722 -0
- stratumoss-0.5.2.dist-info/RECORD +111 -0
- stratumoss-0.5.2.dist-info/WHEEL +4 -0
- stratumoss-0.5.2.dist-info/licenses/LICENSE +170 -0
stratum/__init__.py
ADDED
|
@@ -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()
|