Skip to content

Commit ee9d68d

Browse files
committed
feat: output results to json file
1 parent 56fe4dd commit ee9d68d

File tree

3 files changed

+165
-54
lines changed

3 files changed

+165
-54
lines changed

preflight_check/preflight_check/app.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def configure(
5858
assert isinstance(monitored_subscriptions_input, str)
5959
assert isinstance(region_names, str)
6060
assert isinstance(use_nat_gateway, bool)
61+
assert isinstance(integration_type, models.IntegrationType)
6162
scanning_subscription = self._subscriptions.get_subscription(
6263
scanning_subscription_input.strip()
6364
)
@@ -88,13 +89,16 @@ def configure(
8889
use_nat_gateway=use_nat_gateway,
8990
)
9091

91-
def run(self) -> None:
92+
def run(self, output_path: str) -> None:
9293
"""Run the preflight check"""
94+
if not self.deployment_config:
95+
raise RuntimeError("Deployment config not set")
9396
usage_quota_limits = self._get_usage_quota_limits()
9497
permissions = self._get_permissions()
9598
preflight_check = PreflightCheck(
9699
self.deployment_config, usage_quota_limits, permissions)
97100
cli.print_preflight_check(preflight_check)
101+
cli.output_preflight_check_results_file(preflight_check, path=output_path)
98102

99103
def _prompt_deployment_config(self) -> None:
100104
available_subscriptions = self._subscriptions.get_subscriptions()
@@ -212,6 +216,10 @@ def main(
212216
help="Use NAT Gateway for optimized networking (recommended for 1000+ VMs)",
213217
),
214218
] = None,
219+
output_path: Annotated[
220+
str,
221+
typer.Option("--output-path", "-o", help="Path to output the preflight check results"),
222+
] = "./preflight_report.json",
215223
) -> None:
216224
"""
217225
Preflight check for Azure Agentless Scanner deployment.
@@ -220,7 +228,7 @@ def main(
220228
app = App()
221229
app.configure(integration_type, scanning_subscription,
222230
monitored_subscriptions, regions, use_nat_gateway)
223-
app.run()
231+
app.run(output_path)
224232
except typer.Exit:
225233
raise
226234
except Exception as e:

preflight_check/preflight_check/cli.py

Lines changed: 145 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
from pathlib import Path
13

24
from rich.box import HEAVY_EDGE
35
from rich.console import Console
@@ -16,42 +18,38 @@ def prompt_scanning_subscription(available_subs: list[Subscription]) -> Subscrip
1618
print_subscriptions(available_subs)
1719
console.print("\n[bold]Scanning Subscription[/bold]")
1820
console.print(
19-
"[dim]What subscription do you want to deploy the AWLS scanner resources to?[/dim]")
20-
sub_id = Prompt.ask("Subscription ID",
21-
choices=[sub.id for sub in available_subs],
22-
show_choices=False)
21+
"[dim]What subscription do you want to deploy the AWLS scanner resources to?[/dim]"
22+
)
23+
sub_id = Prompt.ask(
24+
"Subscription ID", choices=[sub.id for sub in available_subs], show_choices=False
25+
)
2326

2427
# Find the subscription in our list of available subscriptions
2528
for sub in available_subs:
2629
if sub.id == sub_id:
27-
console.print(
28-
f"[green]Selected scanning subscription: {sub.name}[/green]")
30+
console.print(f"[green]Selected scanning subscription: {sub.name}[/green]")
2931
return sub
3032
raise ValueError(f"Subscription {sub_id} not found")
3133

3234

33-
def prompt_monitored_subscriptions(available_subs: list[Subscription]) -> tuple[list[Subscription], IntegrationType]:
35+
def prompt_monitored_subscriptions(
36+
available_subs: list[Subscription],
37+
) -> tuple[list[Subscription], IntegrationType]:
3438
"""Prompt user to choose which subscriptions to monitor"""
3539
console.print("\n[bold]Monitored Subscriptions[/bold]")
3640
console.print("[dim]Choose which subscriptions to be monitored.[/dim]")
3741

38-
scan_scope = Prompt.ask(
39-
"Scan scope",
40-
choices=["all", "exclude", "specify"],
41-
default="all"
42-
)
42+
scan_scope = Prompt.ask("Scan scope", choices=["all", "exclude", "specify"], default="all")
4343

4444
if scan_scope == "all":
4545
console.print("[dim]All subscriptions will be monitored.[/dim]")
4646
return (available_subs, IntegrationType.TENANT)
4747

4848
if scan_scope == "exclude":
49-
console.print(
50-
"[dim]Select subscriptions to exclude from monitoring.[/dim]")
49+
console.print("[dim]Select subscriptions to exclude from monitoring.[/dim]")
5150
print_subscriptions(available_subs)
5251

53-
excluded_ids = Prompt.ask(
54-
"Excluded Subscription IDs (comma-separated)")
52+
excluded_ids = Prompt.ask("Excluded Subscription IDs (comma-separated)")
5553
excluded_subs = []
5654

5755
for sub_id in [s.strip() for s in excluded_ids.split(",")]:
@@ -60,21 +58,18 @@ def prompt_monitored_subscriptions(available_subs: list[Subscription]) -> tuple[
6058
excluded_subs.append(sub)
6159
break
6260
else:
63-
console.print(
64-
f"[yellow]Warning: Subscription {sub_id} not found[/yellow]")
61+
console.print(f"[yellow]Warning: Subscription {sub_id} not found[/yellow]")
6562

6663
monitored_subs = [s for s in available_subs if s not in excluded_subs]
67-
console.print(
68-
"\n[bold]The following subscriptions will be excluded:[/bold]")
64+
console.print("\n[bold]The following subscriptions will be excluded:[/bold]")
6965
print_subscriptions(excluded_subs)
7066
return (monitored_subs, IntegrationType.TENANT)
7167

7268
# specify
7369
console.print("[dim]Select specific subscriptions to monitor.[/dim]")
7470
print_subscriptions(available_subs)
7571

76-
specified_ids = Prompt.ask(
77-
"Specified Subscription IDs (comma-separated)")
72+
specified_ids = Prompt.ask("Specified Subscription IDs (comma-separated)")
7873
specified_subs = []
7974

8075
for sub_id in [s.strip() for s in specified_ids.split(",")]:
@@ -83,11 +78,9 @@ def prompt_monitored_subscriptions(available_subs: list[Subscription]) -> tuple[
8378
specified_subs.append(sub)
8479
break
8580
else:
86-
console.print(
87-
f"[yellow]Warning: Subscription {sub_id} not found[/yellow]")
81+
console.print(f"[yellow]Warning: Subscription {sub_id} not found[/yellow]")
8882

89-
console.print(
90-
"\n[bold]Only the following subscriptions will be monitored:[/bold]")
83+
console.print("\n[bold]Only the following subscriptions will be monitored:[/bold]")
9184
print_subscriptions(specified_subs)
9285
return (specified_subs, IntegrationType.SUBSCRIPTION)
9386

@@ -96,9 +89,11 @@ def prompt_nat_gateway() -> bool:
9689
"""Prompt user about NAT Gateway usage"""
9790
console.print("\n[bold]Network Configuration[/bold]")
9891
console.print(
99-
"[dim]We recommend deploying AWLS with a NAT Gateway, but you can opt out if you prefer.[/dim]")
92+
"[dim]We recommend deploying AWLS with a NAT Gateway, but you can opt out if you prefer.[/dim]"
93+
)
10094
console.print(
101-
"[dim]For more details on deploying with/without a NAT Gateway, please refer to the Lacework FortiCNAPP docs:\nhttps://docs.fortinet.com/document/lacework-forticnapp/24.4.0/new-features/13869/integrating-agentless-workload-security-with-azure?preview_token=44849b8a6bf658c3c2b3#:~:text=deploy%20the%20Agentless%20scanning%20integration%20with%20a%20NAT%20gateway.[/dim]")
95+
"[dim]For more details on deploying with/without a NAT Gateway, please refer to the Lacework FortiCNAPP docs:\nhttps://docs.fortinet.com/document/lacework-forticnapp/24.4.0/new-features/13869/integrating-agentless-workload-security-with-azure?preview_token=44849b8a6bf658c3c2b3#:~:text=deploy%20the%20Agentless%20scanning%20integration%20with%20a%20NAT%20gateway.[/dim]"
96+
)
10297
return Confirm.ask("Use NAT Gateway?", default=True)
10398

10499

@@ -164,8 +159,7 @@ def print_vm_counts(subscriptions: list[Subscription]) -> None:
164159
console.print(table)
165160

166161
if subscriptions_with_no_vms:
167-
console.print(
168-
"\n[yellow]Warning: The following subscriptions have no VMs:[/yellow]")
162+
console.print("\n[yellow]Warning: The following subscriptions have no VMs:[/yellow]")
169163
print_subscriptions(subscriptions_with_no_vms)
170164

171165

@@ -178,26 +172,21 @@ def prompt_regions(subscriptions: list[Subscription]) -> list[str]:
178172
# Get unique regions across all subscriptions
179173
detected_regions = {region_name for sub in subscriptions for region_name in sub.regions}
180174
console.print("\n[bold]Deployment Regions[/bold]")
181-
console.print(
182-
"[dim]Enter the Azure regions that you'd like to monitor.[/dim]")
175+
console.print("[dim]Enter the Azure regions that you'd like to monitor.[/dim]")
183176
console.print("[dim]The scanner will be deployed in these regions.[/dim]")
184177

185-
regions_default = ",".join(
186-
sorted(detected_regions)) if detected_regions else None
187-
regions_input = Prompt.ask(
188-
"Azure regions (comma-separated)",
189-
default=regions_default
190-
)
178+
regions_default = ",".join(sorted(detected_regions)) if detected_regions else None
179+
regions_input = Prompt.ask("Azure regions (comma-separated)", default=regions_default)
191180
assert isinstance(regions_input, str)
192181
selected_regions = []
193182
for region_input in regions_input.strip().split(","):
194183
if region_input.strip() in detected_regions:
195184
selected_regions.append(region_input.strip())
196185
else:
197-
console.print(
198-
f"[yellow]Warning: Region {region_input.strip()} not found[/yellow]")
186+
console.print(f"[yellow]Warning: Region {region_input.strip()} not found[/yellow]")
199187
console.print(
200-
f"[green]The following regions will be monitored:[/green] {', '.join(selected_regions)}")
188+
f"[green]The following regions will be monitored:[/green] {', '.join(selected_regions)}"
189+
)
201190
return selected_regions
202191

203192

@@ -208,7 +197,8 @@ def print_quota_checks(quota_checks: QuotaChecks) -> None:
208197

209198
console.print("\n[bold]Usage Quota Limits[/bold]")
210199
console.print(
211-
f"Here are the configured and required usage quota limits for subscription [bold]{scanning_subscription.name}[/bold]:\n")
200+
f"Here are the configured and required usage quota limits for subscription [bold]{scanning_subscription.name}[/bold]:\n"
201+
)
212202

213203
# Create quota requirements table
214204
table = Table(show_header=True, header_style="bold", box=HEAVY_EDGE)
@@ -246,7 +236,7 @@ def print_quota_checks(quota_checks: QuotaChecks) -> None:
246236
str(check.configured_limit),
247237
str(check.current_usage),
248238
str(check.required_quota),
249-
status
239+
status,
250240
)
251241
# Add a blank row between regions (unless it's the last region)
252242
if region_name != region_names[-1]:
@@ -256,13 +246,12 @@ def print_quota_checks(quota_checks: QuotaChecks) -> None:
256246

257247
# Print summary and help message
258248
if quota_checks.all_checks_pass():
259-
console.print(
260-
"\n[green]✅ All configured usage quota limits are sufficient![/green]")
249+
console.print("\n[green]✅ All configured usage quota limits are sufficient![/green]")
261250
else:
251+
console.print("\n[yellow]⚠️ Some configured usage quota limits are not sufficient.[/yellow]")
262252
console.print(
263-
"\n[yellow]⚠️ Some configured usage quota limits are not sufficient.[/yellow]")
264-
console.print(
265-
"Please request quota increases at: https://portal.azure.com/#blade/Microsoft_Azure_Capacity/QuotaRequestBlade")
253+
"Please request quota increases at: https://portal.azure.com/#blade/Microsoft_Azure_Capacity/QuotaRequestBlade"
254+
)
266255

267256

268257
def print_auth_checks(auth_checks: AuthChecks) -> None:
@@ -271,12 +260,11 @@ def print_auth_checks(auth_checks: AuthChecks) -> None:
271260
monitored_subscriptions_auth_checks = auth_checks.monitored_subscriptions
272261

273262
# print scanning subscription auth check
274-
console.print("\n[bold]Scanning Subscription Authorization Checks[/bold]")
263+
console.print("\n[bold]Scanning Subscription Permission Checks[/bold]")
275264
print_auth_check_table([scanning_subscription_auth_check])
276265

277266
# print monitored subscriptions auth checks
278-
console.print(
279-
"\n[bold]Monitored Subscriptions Authorization Checks[/bold]")
267+
console.print("\n[bold]Monitored Subscriptions Permission Checks[/bold]")
280268
print_auth_check_table(monitored_subscriptions_auth_checks)
281269

282270

@@ -304,8 +292,113 @@ def print_auth_check_table(auth_checks: list[AuthCheck]) -> None:
304292
console.print(table)
305293

306294

307-
308295
def print_preflight_check(preflight_check: PreflightCheck) -> None:
309296
"""Print the preflight check results"""
310297
print_quota_checks(preflight_check.usage_quota_checks)
311298
print_auth_checks(preflight_check.auth_checks)
299+
# summarize results
300+
console.print("\n[bold]Preflight Check Summary[/bold]")
301+
if preflight_check.usage_quota_checks.all_checks_pass():
302+
console.print("[green bold]✅ All usage quota limits are sufficient![/green bold]")
303+
else:
304+
console.print("\n[yellow bold]Some usage quota limits are not sufficient.[/yellow bold]")
305+
for region_name, quota_checks in preflight_check.usage_quota_checks.quota_checks.items():
306+
console.print(f"\t[yellow]{region_name}[/yellow]")
307+
for quota_check in quota_checks:
308+
if not quota_check.success:
309+
console.print(
310+
f"\t\t[yellow]- {quota_check.display_name} (Configured: {quota_check.configured_limit}, Current: {quota_check.current_usage}, Required: {quota_check.required_quota})[/yellow]"
311+
)
312+
# print auth checks summary
313+
if preflight_check.auth_checks.all_checks_pass():
314+
console.print("[green bold]✅ All permission checks passed![/green bold]")
315+
else:
316+
console.print("\n[yellow bold]Some permission checks did not pass.[/yellow bold]")
317+
console.print(
318+
"[yellow]The authenticated principal is missing the following permissions on the following subscriptions:[/yellow]"
319+
)
320+
for auth_check in [
321+
preflight_check.auth_checks.scanning_subscription,
322+
*preflight_check.auth_checks.monitored_subscriptions,
323+
]:
324+
if not auth_check.success:
325+
console.print(f"\t[bold]{auth_check.subscription.name}[/bold]:")
326+
for missing_permission in auth_check.missing_permissions:
327+
console.print(
328+
f"\t\t[yellow]- {missing_permission.required_permission}[/yellow]"
329+
)
330+
331+
332+
def output_preflight_check_results_file(
333+
preflight_check: PreflightCheck, path_str: str = "./preflight_report.json"
334+
) -> None:
335+
"""Output the preflight check results to a file"""
336+
path = Path(path_str)
337+
results = {
338+
"deployment_config": {
339+
"integration_level": preflight_check.deployment_config.integration_type.value,
340+
"scanning_subscription": f"/subscriptions/{preflight_check.deployment_config.scanning_subscription.id}",
341+
"monitored_subscriptions": [
342+
f"/subscriptions/{sub.id}"
343+
for sub in preflight_check.deployment_config.monitored_subscriptions
344+
],
345+
"regions": preflight_check.deployment_config.regions,
346+
"use_nat_gateway": preflight_check.deployment_config.use_nat_gateway,
347+
},
348+
"vm_count": {
349+
f"/subscriptions/{sub.id}": {
350+
region_name: region.vm_count for region_name, region in sub.regions.items()
351+
}
352+
for sub in preflight_check.deployment_config.monitored_subscriptions
353+
},
354+
"success": preflight_check.usage_quota_checks.all_checks_pass()
355+
and preflight_check.auth_checks.all_checks_pass(),
356+
"permissions_check": {
357+
"success": preflight_check.auth_checks.all_checks_pass(),
358+
"scanning_subscription": {
359+
"scope": f"/subscriptions/{preflight_check.auth_checks.scanning_subscription.subscription.id}",
360+
"success": preflight_check.auth_checks.scanning_subscription.success,
361+
"missing_permissions": [
362+
check.required_permission
363+
for check in preflight_check.auth_checks.scanning_subscription.missing_permissions
364+
],
365+
},
366+
"monitored_subscriptions": [
367+
{
368+
"scope": f"/subscriptions/{auth_check.subscription.id}",
369+
"success": auth_check.success,
370+
"missing_permissions": [
371+
check.required_permission for check in auth_check.missing_permissions
372+
],
373+
}
374+
for auth_check in preflight_check.auth_checks.monitored_subscriptions
375+
],
376+
},
377+
"usage_quota_check": {
378+
"success": preflight_check.usage_quota_checks.all_checks_pass(),
379+
"subscription": f"/subscriptions/{preflight_check.usage_quota_checks.subscription.id}",
380+
"quota_checks": [
381+
{
382+
"region": region_name,
383+
"quotas": [
384+
{
385+
"name": check.name,
386+
"display_name": check.display_name,
387+
"required_limit": check.required_quota,
388+
"current_usage": check.current_usage,
389+
"configured_limit": check.configured_limit,
390+
"success": check.success,
391+
}
392+
for check in checks
393+
],
394+
"success": all(check.success for check in checks),
395+
}
396+
for region_name, checks in preflight_check.usage_quota_checks.quota_checks.items()
397+
],
398+
},
399+
}
400+
401+
with open(path, "w") as f:
402+
json.dump(results, f, indent=2)
403+
404+
console.print(f"\nPreflight check results written to [bold]{path}[/bold]\n")

preflight_check/preflight_check/core/auth_check.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ def required_permissions(self) -> list[str]:
5151
def success(self) -> bool:
5252
return all(check.is_granted for check in self.checked_permissions)
5353

54+
@property
55+
def missing_permissions(self) -> list[RequiredPermissionCheck]:
56+
return [check for check in self.checked_permissions if not check.is_granted]
57+
5458

5559
class MonitoredSubscriptionAuthCheck(AuthCheck):
5660
"""Defines the permissions required on a monitored subscription"""
@@ -64,6 +68,12 @@ def required_permissions(self) -> list[str]:
6468
"Microsoft.Authorization/roleDefinitions/write",
6569
"Microsoft.Authorization/roleDefinitions/delete",
6670
"Microsoft.Authorization/roleDefinitions/read",
71+
"Microsoft.Storage/storageAccounts/blobServices/read",
72+
"Microsoft.Storage/storageAccounts/blobServices/write",
73+
"Microsoft.Storage/storageAccounts/blobServices/delete",
74+
"Microsoft.Storage/storageAccounts/blobServices/containers/read",
75+
"Microsoft.Storage/storageAccounts/blobServices/containers/write",
76+
"Microsoft.Storage/storageAccounts/blobServices/containers/delete",
6777
]
6878

6979

0 commit comments

Comments
 (0)