praetorian-cli 2.3.4__tar.gz → 2.3.5__tar.gz

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 (122) hide show
  1. {praetorian_cli-2.3.4/praetorian_cli.egg-info → praetorian_cli-2.3.5}/PKG-INFO +1 -1
  2. praetorian_cli-2.3.5/praetorian_cli/handlers/export.py +102 -0
  3. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/main.py +1 -0
  4. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/chariot.py +2 -0
  5. praetorian_cli-2.3.5/praetorian_cli/sdk/entities/reports.py +194 -0
  6. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/webhook.py +1 -1
  7. praetorian_cli-2.3.5/praetorian_cli/sdk/test/test_report.py +242 -0
  8. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_risk.py +2 -2
  9. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_z_cli.py +11 -11
  10. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5/praetorian_cli.egg-info}/PKG-INFO +1 -1
  11. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli.egg-info/SOURCES.txt +3 -0
  12. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/setup.cfg +1 -1
  13. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/LICENSE +0 -0
  14. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/MANIFEST.in +0 -0
  15. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/README.md +0 -0
  16. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/__init__.py +0 -0
  17. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/__init__.py +0 -0
  18. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/add.py +0 -0
  19. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/aegis.py +0 -0
  20. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/agent.py +0 -0
  21. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/chariot.py +0 -0
  22. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/cli_decorators.py +0 -0
  23. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/configure.py +0 -0
  24. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/delete.py +0 -0
  25. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/enrich.py +0 -0
  26. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/get.py +0 -0
  27. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/imports.py +0 -0
  28. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/link.py +0 -0
  29. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/list.py +0 -0
  30. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/script.py +0 -0
  31. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/search.py +0 -0
  32. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/ssh_utils.py +0 -0
  33. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/test.py +0 -0
  34. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/unlink.py +0 -0
  35. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/update.py +0 -0
  36. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/handlers/utils.py +0 -0
  37. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/scripts/__init__.py +0 -0
  38. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/scripts/commands/__init__.py +0 -0
  39. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/scripts/commands/nmap-example.py +0 -0
  40. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/scripts/utils.py +0 -0
  41. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/__init__.py +0 -0
  42. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/__init__.py +0 -0
  43. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/accounts.py +0 -0
  44. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/ad.py +0 -0
  45. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/aegis.py +0 -0
  46. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/agents.py +0 -0
  47. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/assets.py +0 -0
  48. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/attributes.py +0 -0
  49. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/capabilities.py +0 -0
  50. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/configurations.py +0 -0
  51. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/credentials.py +0 -0
  52. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/definitions.py +0 -0
  53. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/files.py +0 -0
  54. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/integrations.py +0 -0
  55. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/jobs.py +0 -0
  56. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/keys.py +0 -0
  57. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/preseeds.py +0 -0
  58. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/risks.py +0 -0
  59. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/scanners.py +0 -0
  60. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/schedules.py +0 -0
  61. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/schema.py +0 -0
  62. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/search.py +0 -0
  63. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/seeds.py +0 -0
  64. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/settings.py +0 -0
  65. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/statistics.py +0 -0
  66. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/entities/webpage.py +0 -0
  67. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/guard.py +0 -0
  68. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/keychain.py +0 -0
  69. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/mcp_server.py +0 -0
  70. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/model/__init__.py +0 -0
  71. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/model/aegis.py +0 -0
  72. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/model/globals.py +0 -0
  73. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/model/query.py +0 -0
  74. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/model/utils.py +0 -0
  75. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/__init__.py +0 -0
  76. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/pytest.ini +0 -0
  77. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_account.py +0 -0
  78. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_agent.py +0 -0
  79. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_asset.py +0 -0
  80. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_attribute.py +0 -0
  81. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_capabilities.py +0 -0
  82. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_configuration.py +0 -0
  83. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_conversation.py +0 -0
  84. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_definition.py +0 -0
  85. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_extend.py +0 -0
  86. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_file.py +0 -0
  87. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_job.py +0 -0
  88. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_key.py +0 -0
  89. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_mcp.py +0 -0
  90. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_preseed.py +0 -0
  91. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_search.py +0 -0
  92. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_seed.py +0 -0
  93. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_setting.py +0 -0
  94. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_webhook.py +0 -0
  95. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/test_webpage.py +0 -0
  96. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/ui_mocks.py +0 -0
  97. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/sdk/test/utils.py +0 -0
  98. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/__init__.py +0 -0
  99. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/__init__.py +0 -0
  100. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/__init__.py +0 -0
  101. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/cp.py +0 -0
  102. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/help.py +0 -0
  103. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/info.py +0 -0
  104. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/job.py +0 -0
  105. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/job_helpers.py +0 -0
  106. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/list.py +0 -0
  107. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/proxy.py +0 -0
  108. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/schedule.py +0 -0
  109. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/schedule_helpers.py +0 -0
  110. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/set.py +0 -0
  111. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/commands/ssh.py +0 -0
  112. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/constants.py +0 -0
  113. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/menu.py +0 -0
  114. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/theme.py +0 -0
  115. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/aegis/utils.py +0 -0
  116. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/conversation/__init__.py +0 -0
  117. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli/ui/conversation/textual_chat.py +0 -0
  118. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli.egg-info/dependency_links.txt +0 -0
  119. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli.egg-info/entry_points.txt +0 -0
  120. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli.egg-info/requires.txt +0 -0
  121. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/praetorian_cli.egg-info/top_level.txt +0 -0
  122. {praetorian_cli-2.3.4 → praetorian_cli-2.3.5}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: praetorian-cli
3
- Version: 2.3.4
3
+ Version: 2.3.5
4
4
  Summary: For interacting with the Guard API
5
5
  Home-page: https://github.com/praetorian-inc/praetorian-cli
6
6
  Author: Praetorian
@@ -0,0 +1,102 @@
1
+ import os
2
+
3
+ import click
4
+
5
+ from praetorian_cli.handlers.chariot import chariot
6
+ from praetorian_cli.handlers.cli_decorators import cli_handler
7
+
8
+
9
+ @chariot.group()
10
+ def export():
11
+ """ Export data from Guard """
12
+ pass
13
+
14
+
15
+ @export.command()
16
+ @cli_handler
17
+ @click.option('--title', required=True, help='Report title')
18
+ @click.option('--client-name', required=True, help='Client organization name')
19
+ @click.option('--status-filter', multiple=True, default=('O', 'T'),
20
+ help='Risk status filter (repeatable). Default: O T')
21
+ @click.option('--risk-keys', multiple=True, default=(),
22
+ help='Specific risk keys to include (repeatable). Default: all')
23
+ @click.option('--target', default='', help='Target/scope (e.g., example.com)')
24
+ @click.option('--start-date', default='', help='Engagement start date (ISO format)')
25
+ @click.option('--end-date', default='', help='Engagement end date (ISO format)')
26
+ @click.option('--report-date', default='', help='Report date (ISO format). Default: today')
27
+ @click.option('--draft/--no-draft', default=False, help='Add DRAFT watermark')
28
+ @click.option('--version', 'report_version', default='1.0', help='Report version string')
29
+ @click.option('--format', 'export_format', type=click.Choice(['pdf', 'zip']),
30
+ default='pdf', help='Export format')
31
+ @click.option('--group-by', type=click.Choice(['attack_surface', 'tag']),
32
+ default='attack_surface', help='Finding grouping strategy')
33
+ @click.option('--shared/--no-shared', default=False,
34
+ help='Copy report to customer shared files')
35
+ @click.option('--executive-summary', default='',
36
+ help='Path to executive summary .md file in Guard storage')
37
+ @click.option('--narratives', default='',
38
+ help='Path to narratives .md file in Guard storage')
39
+ @click.option('--appendix', default='',
40
+ help='Path to appendix .md file in Guard storage')
41
+ @click.option('--output', '-o', default=os.getcwd(),
42
+ help='Local directory to save the downloaded report')
43
+ @click.option('--timeout', default=300, type=int,
44
+ help='Max seconds to wait for report generation. Default: 300')
45
+ @click.option('--no-download', is_flag=True, default=False,
46
+ help='Skip downloading the file; just print the job result')
47
+ def report(chariot, title, client_name, status_filter, risk_keys,
48
+ target, start_date, end_date, report_date, draft,
49
+ report_version, export_format, group_by, shared,
50
+ executive_summary, narratives, appendix, output,
51
+ timeout, no_download):
52
+ """ Generate and download a PDF or ZIP report.
53
+
54
+ Requires Praetorian engineer access. Initiates report generation,
55
+ polls until the job completes, then downloads the file.
56
+
57
+ The customer email is derived automatically from --account (the customer
58
+ you are viewing) or the logged-in user's email.
59
+
60
+ \b
61
+ Example usages:
62
+ guard --account customer@acme.com export report \\
63
+ --title "Pentest Report" --client-name "Acme"
64
+
65
+ guard --account customer@acme.com export report \\
66
+ --title "Q1 Assessment" --client-name "Acme" \\
67
+ --format zip --draft \\
68
+ --status-filter O --status-filter T --status-filter R
69
+ """
70
+ customer_email = chariot.reports.customer_email()
71
+
72
+ body = chariot.reports.build_export_body(
73
+ title=title,
74
+ client_name=client_name,
75
+ customer_email=customer_email,
76
+ status_filter=status_filter,
77
+ risk_keys=risk_keys,
78
+ target=target,
79
+ start_date=start_date,
80
+ end_date=end_date,
81
+ report_date=report_date,
82
+ draft=draft,
83
+ version=report_version,
84
+ export_format=export_format,
85
+ group_by=group_by,
86
+ shared_output=shared,
87
+ executive_summary_path=executive_summary,
88
+ narratives_path=narratives,
89
+ appendix_path=appendix,
90
+ )
91
+
92
+ click.echo(f'Starting {export_format.upper()} report generation for {customer_email}...')
93
+ job = chariot.reports.export(body, timeout=timeout)
94
+ click.echo('Report generation complete.')
95
+
96
+ if no_download:
97
+ click.echo(f'Output path: {chariot.reports.output_path(job)}')
98
+ return
99
+
100
+ click.echo(f'Downloading {chariot.reports.output_path(job)}...')
101
+ local_path = chariot.reports.download(job, output)
102
+ click.echo(f'Saved to {local_path}')
@@ -5,6 +5,7 @@ import praetorian_cli.handlers.aegis
5
5
  import praetorian_cli.handlers.agent
6
6
  import praetorian_cli.handlers.delete
7
7
  import praetorian_cli.handlers.enrich
8
+ import praetorian_cli.handlers.export
8
9
  import praetorian_cli.handlers.get
9
10
  import praetorian_cli.handlers.imports
10
11
  import praetorian_cli.handlers.link
@@ -15,6 +15,7 @@ from praetorian_cli.sdk.entities.integrations import Integrations
15
15
  from praetorian_cli.sdk.entities.jobs import Jobs
16
16
  from praetorian_cli.sdk.entities.keys import Keys
17
17
  from praetorian_cli.sdk.entities.preseeds import Preseeds
18
+ from praetorian_cli.sdk.entities.reports import Reports
18
19
  from praetorian_cli.sdk.entities.risks import Risks
19
20
  from praetorian_cli.sdk.entities.scanners import Scanners
20
21
  from praetorian_cli.sdk.entities.schedules import Schedules
@@ -37,6 +38,7 @@ class Chariot:
37
38
  self.assets = Assets(self)
38
39
  self.seeds = Seeds(self)
39
40
  self.preseeds = Preseeds(self)
41
+ self.reports = Reports(self)
40
42
  self.risks = Risks(self)
41
43
  self.accounts = Accounts(self)
42
44
  self.integrations = Integrations(self)
@@ -0,0 +1,194 @@
1
+ from datetime import date
2
+ from time import sleep, time
3
+
4
+
5
+ class Reports:
6
+ """ The methods in this class are to be accessed from sdk.reports, where sdk
7
+ is an instance of Chariot. """
8
+
9
+ POLL_INTERVAL = 5
10
+ DEFAULT_TIMEOUT = 300
11
+
12
+ def __init__(self, api):
13
+ self.api = api
14
+
15
+ def customer_email(self):
16
+ """
17
+ Derive the customer email from the current SDK context.
18
+
19
+ Uses the --account (assumed account) if set, otherwise falls back
20
+ to the logged-in user's email. This mirrors the frontend logic of
21
+ ``friend || me``.
22
+
23
+ :return: The customer email address
24
+ :rtype: str
25
+ :raises Exception: If no customer email can be determined
26
+ """
27
+ email = self.api.keychain.account or self.api.keychain.username()
28
+ if not email:
29
+ raise Exception(
30
+ 'Could not determine customer email. '
31
+ 'Use --account or configure a username.'
32
+ )
33
+ return email
34
+
35
+ def build_export_body(self, title, client_name, customer_email,
36
+ status_filter=('O', 'T'), risk_keys=(),
37
+ target='', start_date='', end_date='',
38
+ report_date='', draft=False, version='1.0',
39
+ export_format='pdf', group_by='attack_surface',
40
+ shared_output=False, executive_summary_path='',
41
+ narratives_path='', appendix_path=''):
42
+ """
43
+ Build the request body for POST /export/report.
44
+
45
+ :param title: Report title
46
+ :type title: str
47
+ :param client_name: Client organization name
48
+ :type client_name: str
49
+ :param customer_email: Customer email address
50
+ :type customer_email: str
51
+ :param status_filter: Risk status codes to include
52
+ :type status_filter: tuple or list
53
+ :param risk_keys: Specific risk keys to include (empty for all)
54
+ :type risk_keys: tuple or list
55
+ :param target: Target/scope
56
+ :type target: str
57
+ :param start_date: Engagement start date (ISO format)
58
+ :type start_date: str
59
+ :param end_date: Engagement end date (ISO format)
60
+ :type end_date: str
61
+ :param report_date: Report date (ISO format). Defaults to today.
62
+ :type report_date: str
63
+ :param draft: Whether to add DRAFT watermark
64
+ :type draft: bool
65
+ :param version: Report version string
66
+ :type version: str
67
+ :param export_format: Output format ('pdf' or 'zip')
68
+ :type export_format: str
69
+ :param group_by: Finding grouping strategy ('attack_surface' or 'tag')
70
+ :type group_by: str
71
+ :param shared_output: Whether to copy to customer shared files
72
+ :type shared_output: bool
73
+ :param executive_summary_path: Path to executive summary .md in Guard storage
74
+ :type executive_summary_path: str
75
+ :param narratives_path: Path to narratives .md in Guard storage
76
+ :type narratives_path: str
77
+ :param appendix_path: Path to appendix .md in Guard storage
78
+ :type appendix_path: str
79
+ :return: Request body dict ready for POST /export/report
80
+ :rtype: dict
81
+ """
82
+ if not report_date:
83
+ report_date = date.today().isoformat()
84
+
85
+ body = {
86
+ 'status_filter': list(status_filter),
87
+ 'config': {
88
+ 'title': title,
89
+ 'client_name': client_name,
90
+ 'report_date': report_date,
91
+ 'draft': draft,
92
+ 'version': version,
93
+ },
94
+ 'shared_output': shared_output,
95
+ 'customer_email': customer_email,
96
+ 'export_format': export_format,
97
+ 'group_by': group_by,
98
+ }
99
+
100
+ if risk_keys:
101
+ body['risk_keys'] = list(risk_keys)
102
+ if target:
103
+ body['config']['target'] = target
104
+ if start_date:
105
+ body['config']['start_date'] = start_date
106
+ if end_date:
107
+ body['config']['end_date'] = end_date
108
+ if executive_summary_path:
109
+ body['executive_summary_path'] = executive_summary_path
110
+ if narratives_path:
111
+ body['narratives_path'] = narratives_path
112
+ if appendix_path:
113
+ body['appendix_path'] = appendix_path
114
+
115
+ return body
116
+
117
+ def export(self, body, timeout=DEFAULT_TIMEOUT, poll_interval=POLL_INTERVAL):
118
+ """
119
+ Start a report export job, poll until completion, and return the job.
120
+
121
+ :param body: Request body from build_export_body()
122
+ :type body: dict
123
+ :param timeout: Max seconds to wait for completion
124
+ :type timeout: int
125
+ :param poll_interval: Seconds between status polls
126
+ :type poll_interval: int
127
+ :return: The completed job dict
128
+ :rtype: dict
129
+ :raises Exception: If the job fails, times out, or no job key is returned
130
+ """
131
+ resp = self.api.post('export/report', body)
132
+
133
+ job_key = resp.get('key')
134
+ if not job_key:
135
+ raise Exception('No job key returned from export/report endpoint.')
136
+
137
+ return self.poll_job(job_key, timeout, poll_interval)
138
+
139
+ def poll_job(self, job_key, timeout=DEFAULT_TIMEOUT, poll_interval=POLL_INTERVAL):
140
+ """
141
+ Poll a report generation job until it passes, fails, or times out.
142
+
143
+ :param job_key: The job key to poll
144
+ :type job_key: str
145
+ :param timeout: Max seconds to wait
146
+ :type timeout: int
147
+ :param poll_interval: Seconds between polls
148
+ :type poll_interval: int
149
+ :return: The completed job dict
150
+ :rtype: dict
151
+ :raises Exception: If the job fails or times out
152
+ """
153
+ start_time = time()
154
+ job = None
155
+ while time() - start_time < timeout:
156
+ job = self.api.jobs.get(job_key)
157
+ if self.api.jobs.is_failed(job):
158
+ message = job.get('message', 'unknown error')
159
+ raise Exception(f'Report generation failed: {message}')
160
+ if self.api.jobs.is_passed(job):
161
+ return job
162
+ sleep(poll_interval)
163
+
164
+ raise Exception(f'Report generation timed out after {timeout} seconds.')
165
+
166
+ def output_path(self, job):
167
+ """
168
+ Extract the output file path from a completed report job.
169
+
170
+ :param job: The completed job dict
171
+ :type job: dict
172
+ :return: The output file path in Guard storage
173
+ :rtype: str
174
+ :raises Exception: If the output path cannot be determined
175
+ """
176
+ config = job.get('config', {})
177
+ path = config.get('output') or job.get('dns', '')
178
+ if not path:
179
+ raise Exception('Could not determine output file path from job.')
180
+ return path
181
+
182
+ def download(self, job, download_directory):
183
+ """
184
+ Download the report file from a completed job to a local directory.
185
+
186
+ :param job: The completed job dict
187
+ :type job: dict
188
+ :param download_directory: Local directory to save the file
189
+ :type download_directory: str
190
+ :return: The local file path where the report was saved
191
+ :rtype: str
192
+ """
193
+ path = self.output_path(job)
194
+ return self.api.files.save(path, download_directory)
@@ -48,7 +48,7 @@ class Webhook:
48
48
  ```
49
49
  """
50
50
  pin = str(uuid4())
51
- self.api.link_account('hook', pin)
51
+ self.api.link_account('hook', role='readonly', value=pin)
52
52
  return self.webhook_url(pin)
53
53
 
54
54
  def get_url(self):
@@ -0,0 +1,242 @@
1
+ from datetime import date
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ import pytest
5
+
6
+ from praetorian_cli.sdk.entities.reports import Reports
7
+
8
+
9
+ def make_reports(account=None, username=None):
10
+ """Create a Reports instance with a mocked API."""
11
+ api = MagicMock()
12
+ api.keychain.account = account
13
+ api.keychain.username.return_value = username
14
+ return Reports(api), api
15
+
16
+
17
+ class TestCustomerEmail:
18
+
19
+ def test_uses_account_when_set(self):
20
+ reports, _ = make_reports(account='customer@acme.com', username='engineer@praetorian.com')
21
+ assert reports.customer_email() == 'customer@acme.com'
22
+
23
+ def test_falls_back_to_username(self):
24
+ reports, _ = make_reports(account=None, username='engineer@praetorian.com')
25
+ assert reports.customer_email() == 'engineer@praetorian.com'
26
+
27
+ def test_raises_when_neither_set(self):
28
+ reports, _ = make_reports(account=None, username=None)
29
+ with pytest.raises(Exception, match='Could not determine customer email'):
30
+ reports.customer_email()
31
+
32
+ def test_prefers_account_over_username(self):
33
+ reports, _ = make_reports(account='customer@acme.com', username='me@praetorian.com')
34
+ assert reports.customer_email() == 'customer@acme.com'
35
+
36
+
37
+ class TestBuildExportBody:
38
+
39
+ def test_minimal_body(self):
40
+ reports, _ = make_reports()
41
+ body = reports.build_export_body(
42
+ title='Test Report',
43
+ client_name='Acme Corp',
44
+ customer_email='customer@acme.com',
45
+ )
46
+
47
+ assert body['config']['title'] == 'Test Report'
48
+ assert body['config']['client_name'] == 'Acme Corp'
49
+ assert body['customer_email'] == 'customer@acme.com'
50
+ assert body['status_filter'] == ['O', 'T']
51
+ assert body['export_format'] == 'pdf'
52
+ assert body['group_by'] == 'attack_surface'
53
+ assert body['shared_output'] is False
54
+ assert body['config']['draft'] is False
55
+ assert body['config']['version'] == '1.0'
56
+ assert 'risk_keys' not in body
57
+ assert 'target' not in body['config']
58
+ assert 'executive_summary_path' not in body
59
+
60
+ def test_defaults_report_date_to_today(self):
61
+ reports, _ = make_reports()
62
+ body = reports.build_export_body(
63
+ title='Test', client_name='Test', customer_email='test@test.com',
64
+ )
65
+ assert body['config']['report_date'] == date.today().isoformat()
66
+
67
+ def test_explicit_report_date(self):
68
+ reports, _ = make_reports()
69
+ body = reports.build_export_body(
70
+ title='Test', client_name='Test', customer_email='test@test.com',
71
+ report_date='2026-01-15',
72
+ )
73
+ assert body['config']['report_date'] == '2026-01-15'
74
+
75
+ def test_all_optional_fields(self):
76
+ reports, _ = make_reports()
77
+ body = reports.build_export_body(
78
+ title='Full Report',
79
+ client_name='Acme',
80
+ customer_email='customer@acme.com',
81
+ status_filter=['O', 'T', 'R'],
82
+ risk_keys=['#risk#example.com#sqli', '#risk#example.com#xss'],
83
+ target='example.com',
84
+ start_date='2026-01-01',
85
+ end_date='2026-03-01',
86
+ report_date='2026-03-15',
87
+ draft=True,
88
+ version='2.0',
89
+ export_format='zip',
90
+ group_by='tag',
91
+ shared_output=True,
92
+ executive_summary_path='home/exec-summary.md',
93
+ narratives_path='home/narratives.md',
94
+ appendix_path='home/appendix.md',
95
+ )
96
+
97
+ assert body['risk_keys'] == ['#risk#example.com#sqli', '#risk#example.com#xss']
98
+ assert body['config']['target'] == 'example.com'
99
+ assert body['config']['start_date'] == '2026-01-01'
100
+ assert body['config']['end_date'] == '2026-03-01'
101
+ assert body['config']['draft'] is True
102
+ assert body['config']['version'] == '2.0'
103
+ assert body['export_format'] == 'zip'
104
+ assert body['group_by'] == 'tag'
105
+ assert body['shared_output'] is True
106
+ assert body['executive_summary_path'] == 'home/exec-summary.md'
107
+ assert body['narratives_path'] == 'home/narratives.md'
108
+ assert body['appendix_path'] == 'home/appendix.md'
109
+
110
+ def test_status_filter_converts_tuple_to_list(self):
111
+ reports, _ = make_reports()
112
+ body = reports.build_export_body(
113
+ title='Test', client_name='Test', customer_email='test@test.com',
114
+ status_filter=('O', 'T', 'R'),
115
+ )
116
+ assert body['status_filter'] == ['O', 'T', 'R']
117
+ assert isinstance(body['status_filter'], list)
118
+
119
+ def test_empty_optional_strings_excluded(self):
120
+ reports, _ = make_reports()
121
+ body = reports.build_export_body(
122
+ title='Test', client_name='Test', customer_email='test@test.com',
123
+ target='', executive_summary_path='', narratives_path='', appendix_path='',
124
+ )
125
+ assert 'target' not in body['config']
126
+ assert 'executive_summary_path' not in body
127
+ assert 'narratives_path' not in body
128
+ assert 'appendix_path' not in body
129
+
130
+
131
+ class TestExport:
132
+
133
+ def test_success(self):
134
+ reports, api = make_reports()
135
+ api.post.return_value = {'key': '#job#report#123'}
136
+ api.jobs.get.return_value = {'status': 'JP', 'config': {'output': 'home/report.pdf'}}
137
+ api.jobs.is_passed.return_value = True
138
+ api.jobs.is_failed.return_value = False
139
+
140
+ job = reports.export({'customer_email': 'test@test.com'}, timeout=10, poll_interval=0)
141
+
142
+ api.post.assert_called_once_with('export/report', {'customer_email': 'test@test.com'})
143
+ assert job['config']['output'] == 'home/report.pdf'
144
+
145
+ def test_raises_on_missing_job_key(self):
146
+ reports, api = make_reports()
147
+ api.post.return_value = {}
148
+
149
+ with pytest.raises(Exception, match='No job key returned'):
150
+ reports.export({})
151
+
152
+ def test_raises_on_failure(self):
153
+ reports, api = make_reports()
154
+ api.post.return_value = {'key': '#job#report#123'}
155
+ api.jobs.get.return_value = {'status': 'JF', 'message': 'missing definitions'}
156
+ api.jobs.is_failed.return_value = True
157
+ api.jobs.is_passed.return_value = False
158
+
159
+ with pytest.raises(Exception, match='Report generation failed: missing definitions'):
160
+ reports.export({}, timeout=10, poll_interval=0)
161
+
162
+ @patch('praetorian_cli.sdk.entities.reports.time')
163
+ @patch('praetorian_cli.sdk.entities.reports.sleep')
164
+ def test_raises_on_timeout(self, mock_sleep, mock_time):
165
+ reports, api = make_reports()
166
+ api.post.return_value = {'key': '#job#report#123'}
167
+ api.jobs.get.return_value = {'status': 'JR'}
168
+ api.jobs.is_failed.return_value = False
169
+ api.jobs.is_passed.return_value = False
170
+
171
+ # Simulate time progressing past timeout
172
+ mock_time.side_effect = [0, 0, 100, 200, 301]
173
+
174
+ with pytest.raises(Exception, match='timed out after 300 seconds'):
175
+ reports.export({}, timeout=300, poll_interval=5)
176
+
177
+
178
+ class TestPollJob:
179
+
180
+ @patch('praetorian_cli.sdk.entities.reports.sleep')
181
+ def test_polls_until_passed(self, mock_sleep):
182
+ reports, api = make_reports()
183
+
184
+ # First call: running, second call: passed
185
+ api.jobs.get.side_effect = [
186
+ {'status': 'JR'},
187
+ {'status': 'JP', 'config': {'output': 'home/report.pdf'}},
188
+ ]
189
+ api.jobs.is_failed.return_value = False
190
+ api.jobs.is_passed.side_effect = [False, True]
191
+
192
+ job = reports.poll_job('#job#123', timeout=60, poll_interval=0)
193
+ assert job['status'] == 'JP'
194
+ assert api.jobs.get.call_count == 2
195
+
196
+ @patch('praetorian_cli.sdk.entities.reports.sleep')
197
+ def test_raises_on_job_failure(self, mock_sleep):
198
+ reports, api = make_reports()
199
+ api.jobs.get.return_value = {'status': 'JF', 'message': 'orator crashed'}
200
+ api.jobs.is_failed.return_value = True
201
+ api.jobs.is_passed.return_value = False
202
+
203
+ with pytest.raises(Exception, match='orator crashed'):
204
+ reports.poll_job('#job#123', timeout=60, poll_interval=0)
205
+
206
+
207
+ class TestOutputPath:
208
+
209
+ def test_from_config_output(self):
210
+ reports, _ = make_reports()
211
+ job = {'config': {'output': 'home/my-report-2026.pdf'}, 'dns': 'fallback.pdf'}
212
+ assert reports.output_path(job) == 'home/my-report-2026.pdf'
213
+
214
+ def test_falls_back_to_dns(self):
215
+ reports, _ = make_reports()
216
+ job = {'config': {}, 'dns': 'home/fallback.pdf'}
217
+ assert reports.output_path(job) == 'home/fallback.pdf'
218
+
219
+ def test_raises_when_no_path(self):
220
+ reports, _ = make_reports()
221
+ job = {'config': {}, 'dns': ''}
222
+ with pytest.raises(Exception, match='Could not determine output file path'):
223
+ reports.output_path(job)
224
+
225
+ def test_raises_when_no_config(self):
226
+ reports, _ = make_reports()
227
+ job = {}
228
+ with pytest.raises(Exception, match='Could not determine output file path'):
229
+ reports.output_path(job)
230
+
231
+
232
+ class TestDownload:
233
+
234
+ def test_delegates_to_files_save(self):
235
+ reports, api = make_reports()
236
+ api.files.save.return_value = '/tmp/my-report.pdf'
237
+
238
+ job = {'config': {'output': 'home/my-report.pdf'}}
239
+ result = reports.download(job, '/tmp')
240
+
241
+ api.files.save.assert_called_once_with('home/my-report.pdf', '/tmp')
242
+ assert result == '/tmp/my-report.pdf'
@@ -94,7 +94,7 @@ class TestRisk:
94
94
  tags = ('critical', 'needs-review')
95
95
  r = self.sdk.risks.add(self.asset_key, self.risk_name, Risk.TRIAGE_HIGH.value, tags=tags)
96
96
  risk = self.sdk.risks.get(r['key'])
97
- assert risk['tags']['tags'] == list(tags)
97
+ assert risk['tags'] == list(tags)
98
98
 
99
99
  def test_update_risk_with_tags(self):
100
100
  r = self.sdk.risks.add(self.asset_key, self.risk_name, Risk.TRIAGE_HIGH.value)
@@ -102,7 +102,7 @@ class TestRisk:
102
102
  tags = ('resolved', 'verified')
103
103
  self.sdk.risks.update(risk_key, tags=tags)
104
104
  risk = self.sdk.risks.get(risk_key)
105
- assert risk['tags']['tags'] == list(tags)
105
+ assert risk['tags'] == list(tags)
106
106
 
107
107
  def teardown_class(self):
108
108
  clean_test_entities(self.sdk, self)
@@ -119,26 +119,26 @@ class TestZCli:
119
119
  The substring checks in test_risk_cli cannot detect this structural issue.
120
120
  """
121
121
  o = make_test_values(lambda: None)
122
- self.verify(f'add asset -n {o.asset_name} -d {o.asset_dns}')
122
+ self.verify(f'add asset -i {o.asset_name} -g {o.asset_dns}')
123
123
 
124
124
  # Verify add risk with tags produces correct structure
125
125
  self.verify(f'add risk {o.risk_name} -a "{o.asset_key}" -s {AddRisk.TRIAGE_HIGH.value} -g phase_web -g needs-triage',
126
126
  ignore_stdout=True)
127
127
  risk = self.run_json(f'get risk "{o.risk_key}"')
128
- assert isinstance(risk['tags']['tags'], list), \
129
- f'Expected tags.tags to be a list, got {type(risk["tags"]["tags"])}: {risk["tags"]}'
130
- assert all(isinstance(t, str) for t in risk['tags']['tags']), \
131
- f'Expected all tags to be strings, got: {risk["tags"]["tags"]}'
132
- assert set(risk['tags']['tags']) == {'phase_web', 'needs-triage'}
128
+ assert isinstance(risk['tags'], list), \
129
+ f'Expected tags to be a list, got {type(risk["tags"])}: {risk["tags"]}'
130
+ assert all(isinstance(t, str) for t in risk['tags']), \
131
+ f'Expected all tags to be strings, got: {risk["tags"]}'
132
+ assert set(risk['tags']) == {'phase_web', 'needs-triage'}
133
133
 
134
134
  # Verify update risk with tags produces correct structure
135
135
  self.verify(f'update risk "{o.risk_key}" -g resolved -g verified', ignore_stdout=True)
136
136
  risk = self.run_json(f'get risk "{o.risk_key}"')
137
- assert isinstance(risk['tags']['tags'], list), \
138
- f'Expected tags.tags to be a list after update, got {type(risk["tags"]["tags"])}: {risk["tags"]}'
139
- assert all(isinstance(t, str) for t in risk['tags']['tags']), \
140
- f'Expected all tags to be strings after update, got: {risk["tags"]["tags"]}'
141
- assert set(risk['tags']['tags']) == {'resolved', 'verified'}
137
+ assert isinstance(risk['tags'], list), \
138
+ f'Expected tags to be a list after update, got {type(risk["tags"])}: {risk["tags"]}'
139
+ assert all(isinstance(t, str) for t in risk['tags']), \
140
+ f'Expected all tags to be strings after update, got: {risk["tags"]}'
141
+ assert set(risk['tags']) == {'resolved', 'verified'}
142
142
 
143
143
  clean_test_entities(self.sdk, o)
144
144
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: praetorian-cli
3
- Version: 2.3.4
3
+ Version: 2.3.5
4
4
  Summary: For interacting with the Guard API
5
5
  Home-page: https://github.com/praetorian-inc/praetorian-cli
6
6
  Author: Praetorian
@@ -20,6 +20,7 @@ praetorian_cli/handlers/cli_decorators.py
20
20
  praetorian_cli/handlers/configure.py
21
21
  praetorian_cli/handlers/delete.py
22
22
  praetorian_cli/handlers/enrich.py
23
+ praetorian_cli/handlers/export.py
23
24
  praetorian_cli/handlers/get.py
24
25
  praetorian_cli/handlers/imports.py
25
26
  praetorian_cli/handlers/link.py
@@ -56,6 +57,7 @@ praetorian_cli/sdk/entities/integrations.py
56
57
  praetorian_cli/sdk/entities/jobs.py
57
58
  praetorian_cli/sdk/entities/keys.py
58
59
  praetorian_cli/sdk/entities/preseeds.py
60
+ praetorian_cli/sdk/entities/reports.py
59
61
  praetorian_cli/sdk/entities/risks.py
60
62
  praetorian_cli/sdk/entities/scanners.py
61
63
  praetorian_cli/sdk/entities/schedules.py
@@ -87,6 +89,7 @@ praetorian_cli/sdk/test/test_job.py
87
89
  praetorian_cli/sdk/test/test_key.py
88
90
  praetorian_cli/sdk/test/test_mcp.py
89
91
  praetorian_cli/sdk/test/test_preseed.py
92
+ praetorian_cli/sdk/test/test_report.py
90
93
  praetorian_cli/sdk/test/test_risk.py
91
94
  praetorian_cli/sdk/test/test_search.py
92
95
  praetorian_cli/sdk/test/test_seed.py
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = praetorian-cli
3
- version = 2.3.4
3
+ version = 2.3.5
4
4
  author = Praetorian
5
5
  author_email = support@praetorian.com
6
6
  description = For interacting with the Guard API
File without changes
File without changes