hound-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hound_cli/__init__.py +1 -0
- hound_cli/__main__.py +60 -0
- hound_cli/agents.py +19 -0
- hound_cli/aws.py +340 -0
- hound_cli/contracts/__init__.py +1 -0
- hound_cli/contracts/domain.py +129 -0
- hound_cli/contracts/workflow.py +76 -0
- hound_cli/inbox/__init__.py +1 -0
- hound_cli/inbox/command.py +212 -0
- hound_cli/inbox/email.py +210 -0
- hound_cli/inbox/sms.py +190 -0
- hound_cli/reports/__init__.py +1 -0
- hound_cli/reports/close.py +115 -0
- hound_cli/reports/files.py +69 -0
- hound_cli/reports/open.py +113 -0
- hound_cli/reports/render.py +116 -0
- hound_cli/reports/reviews.py +120 -0
- hound_cli/reports/service.py +81 -0
- hound_cli/roster.py +64 -0
- hound_cli/targets/__init__.py +1 -0
- hound_cli/targets/configure.py +163 -0
- hound_cli/targets/verification.py +81 -0
- hound_cli/ui.py +5 -0
- hound_cli/workflows/__init__.py +1 -0
- hound_cli/workflows/launch.py +444 -0
- hound_cli/workflows/logs.py +197 -0
- hound_cli/workflows/selection.py +77 -0
- hound_cli/workflows/status.py +153 -0
- hound_cli/workflows/stop.py +108 -0
- hound_cli-0.1.0.dist-info/METADATA +79 -0
- hound_cli-0.1.0.dist-info/RECORD +35 -0
- hound_cli-0.1.0.dist-info/WHEEL +5 -0
- hound_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hound_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- hound_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Launch account-local Hound engagements and retests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import questionary
|
|
10
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
11
|
+
|
|
12
|
+
from hound_cli.aws import (
|
|
13
|
+
_aws_account_id,
|
|
14
|
+
active_domain_work,
|
|
15
|
+
aws_client,
|
|
16
|
+
get_engagement_records,
|
|
17
|
+
get_workflow_record,
|
|
18
|
+
operator_only,
|
|
19
|
+
scan_workflow_records,
|
|
20
|
+
short_task_id,
|
|
21
|
+
sqs_queue_url,
|
|
22
|
+
)
|
|
23
|
+
from hound_cli.contracts.workflow import (
|
|
24
|
+
ACTIVE_INFRA_STATUSES,
|
|
25
|
+
ACTIVE_WORKFLOW_STATUSES,
|
|
26
|
+
ENGAGEMENT_RELAUNCH_STATUSES,
|
|
27
|
+
RETEST_LAUNCH_STATUSES,
|
|
28
|
+
SKIP_DNS_CHECK_FIELD,
|
|
29
|
+
RuntimeConfig,
|
|
30
|
+
runtime_config_from_record,
|
|
31
|
+
runtime_launch_value,
|
|
32
|
+
)
|
|
33
|
+
from hound_cli.targets.configure import prepare_target_access
|
|
34
|
+
from hound_cli.targets.verification import require_launch_authorization
|
|
35
|
+
from hound_cli.ui import MENU_STYLE
|
|
36
|
+
from hound_cli.workflows.selection import select_record, selected_domain
|
|
37
|
+
|
|
38
|
+
CAPABILITY_PARAMETERS = {
|
|
39
|
+
"relay": "/hound/capabilities/relay",
|
|
40
|
+
"residential_proxy": "/hound/capabilities/residential-proxy",
|
|
41
|
+
}
|
|
42
|
+
LAUNCH_RECEIPT_TIMEOUT_SECONDS = 60
|
|
43
|
+
LAUNCH_RECEIPT_POLL_SECONDS = 1
|
|
44
|
+
SKIP_DNS_CHECK_HELP = "Skip live DNS authorization checks after confirming authorization out of band."
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class EligibleEip:
|
|
49
|
+
"""An unassigned persistent Hound Elastic IP."""
|
|
50
|
+
|
|
51
|
+
allocation_id: str
|
|
52
|
+
public_ip: str
|
|
53
|
+
name: str | None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def title(self) -> str:
|
|
57
|
+
suffix = f" — {self.name}" if self.name else ""
|
|
58
|
+
return f"{self.public_ip} ({self.allocation_id}){suffix}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class RuntimeSelection:
|
|
63
|
+
"""One launch runtime plus its operator-facing EIP label."""
|
|
64
|
+
|
|
65
|
+
runtime: RuntimeConfig
|
|
66
|
+
eip_label: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _select_with_default(message: str, choices: list[questionary.Choice], default: object) -> object:
|
|
70
|
+
"""Start on the default while styling only the moving cursor as selected."""
|
|
71
|
+
default_choice = next((choice for choice in choices if choice.value == default), None)
|
|
72
|
+
if default_choice is None:
|
|
73
|
+
raise ValueError(f"Invalid default selection: {default}")
|
|
74
|
+
ordered_choices = [default_choice, *(choice for choice in choices if choice is not default_choice)]
|
|
75
|
+
return questionary.select(message, choices=ordered_choices, style=MENU_STYLE).unsafe_ask()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _record_status(record: dict) -> str:
|
|
79
|
+
statuses = [
|
|
80
|
+
f"{attr.removesuffix('_status')}={record[attr]['S']}" for attr in ACTIVE_WORKFLOW_STATUSES if attr in record
|
|
81
|
+
]
|
|
82
|
+
return ", ".join(statuses) or "?"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _active_domain_precheck(ddb, domain: str) -> None:
|
|
86
|
+
active = active_domain_work(ddb, domain)
|
|
87
|
+
if not active:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
status_summary = ", ".join(
|
|
91
|
+
(
|
|
92
|
+
f"{short_task_id(record['task_id']['S'])} "
|
|
93
|
+
f"({_record_status(record)}/{record.get('infra_status', {}).get('S', '?')})"
|
|
94
|
+
)
|
|
95
|
+
for record in sorted(active, key=lambda record: record["task_id"]["S"], reverse=True)
|
|
96
|
+
)
|
|
97
|
+
raise click.ClickException(f"Domain {domain} already has active Hound work: {status_summary}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _capabilities() -> dict[str, bool]:
|
|
101
|
+
"""Read both exact account capability parameters in one request."""
|
|
102
|
+
names = list(CAPABILITY_PARAMETERS.values())
|
|
103
|
+
try:
|
|
104
|
+
response = aws_client("ssm").get_parameters(Names=names, WithDecryption=False)
|
|
105
|
+
except (BotoCoreError, ClientError) as exc:
|
|
106
|
+
raise click.ClickException("Could not read account runtime capabilities.") from exc
|
|
107
|
+
|
|
108
|
+
returned = {parameter.get("Name"): parameter for parameter in response.get("Parameters", [])}
|
|
109
|
+
invalid = response.get("InvalidParameters", [])
|
|
110
|
+
if invalid or set(returned) != set(names):
|
|
111
|
+
missing = sorted(set(names) - set(returned) | set(invalid))
|
|
112
|
+
raise click.ClickException(f"Missing account runtime capability parameter(s): {', '.join(missing)}")
|
|
113
|
+
|
|
114
|
+
capabilities = {}
|
|
115
|
+
for capability, name in CAPABILITY_PARAMETERS.items():
|
|
116
|
+
parameter = returned[name]
|
|
117
|
+
if parameter.get("Type") != "String" or parameter.get("Value") not in {"enabled", "disabled"}:
|
|
118
|
+
raise click.ClickException(
|
|
119
|
+
f"Invalid account runtime capability {name}: expected a String with value enabled or disabled"
|
|
120
|
+
)
|
|
121
|
+
capabilities[capability] = parameter["Value"] == "enabled"
|
|
122
|
+
return capabilities
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _eligible_eips() -> list[EligibleEip]:
|
|
126
|
+
"""Discover unassigned persistent Hound EIPs directly from EC2."""
|
|
127
|
+
try:
|
|
128
|
+
response = aws_client("ec2").describe_addresses(Filters=[{"Name": "tag:app", "Values": ["hound"]}])
|
|
129
|
+
except (BotoCoreError, ClientError) as exc:
|
|
130
|
+
raise click.ClickException("Could not discover persistent Hound Elastic IPs.") from exc
|
|
131
|
+
|
|
132
|
+
eligible = []
|
|
133
|
+
for address in response.get("Addresses", []):
|
|
134
|
+
tags = {tag.get("Key"): tag.get("Value") for tag in address.get("Tags", []) if isinstance(tag.get("Key"), str)}
|
|
135
|
+
allocation_id = address.get("AllocationId")
|
|
136
|
+
public_ip = address.get("PublicIp")
|
|
137
|
+
if tags.get("app") != "hound" or "task_id" in tags:
|
|
138
|
+
continue
|
|
139
|
+
if isinstance(allocation_id, str) and isinstance(public_ip, str):
|
|
140
|
+
eligible.append(EligibleEip(allocation_id, public_ip, tags.get("Name")))
|
|
141
|
+
return sorted(eligible, key=lambda address: (address.name or "", address.public_ip, address.allocation_id))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@click.command()
|
|
145
|
+
@operator_only
|
|
146
|
+
def ips():
|
|
147
|
+
"""List persistent Hound Elastic IPs."""
|
|
148
|
+
addresses = _eligible_eips()
|
|
149
|
+
if not addresses:
|
|
150
|
+
click.echo("No persistent Hound Elastic IPs.")
|
|
151
|
+
return
|
|
152
|
+
for address in addresses:
|
|
153
|
+
click.echo(address.title)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _runtime_selection(
|
|
157
|
+
capabilities: dict[str, bool],
|
|
158
|
+
eips: list[EligibleEip],
|
|
159
|
+
current: RuntimeConfig | None = None,
|
|
160
|
+
) -> RuntimeSelection:
|
|
161
|
+
"""Prompt for one complete runtime map, preselecting current values."""
|
|
162
|
+
current_eip = current.eip_allocation_id if current else None
|
|
163
|
+
choices = [questionary.Choice("Dynamically assigned per-run IP", value="__dynamic__")]
|
|
164
|
+
choices.extend(questionary.Choice(address.title, value=address.allocation_id) for address in eips)
|
|
165
|
+
available_ids = {address.allocation_id for address in eips}
|
|
166
|
+
if current_eip is not None and current_eip not in available_ids:
|
|
167
|
+
raise click.ClickException(
|
|
168
|
+
f"Current persistent EIP {current_eip} is no longer eligible. Clean it up or choose a launchable record."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
selected_eip = _select_with_default("Cloud IP", choices, current_eip or "__dynamic__")
|
|
172
|
+
if selected_eip is None:
|
|
173
|
+
raise click.Abort()
|
|
174
|
+
|
|
175
|
+
if capabilities["residential_proxy"]:
|
|
176
|
+
residential_proxy_enabled = questionary.confirm(
|
|
177
|
+
"Use the account residential proxy?",
|
|
178
|
+
default=None,
|
|
179
|
+
instruction="(y/n)",
|
|
180
|
+
).unsafe_ask()
|
|
181
|
+
if residential_proxy_enabled is None:
|
|
182
|
+
raise click.Abort()
|
|
183
|
+
else:
|
|
184
|
+
residential_proxy_enabled = False
|
|
185
|
+
|
|
186
|
+
if capabilities["relay"]:
|
|
187
|
+
relay_enabled = questionary.confirm(
|
|
188
|
+
"Allow Hound to request operator assistance for CAPTCHAs or similar verification challenges?",
|
|
189
|
+
default=None,
|
|
190
|
+
instruction="(y/n)",
|
|
191
|
+
).unsafe_ask()
|
|
192
|
+
if relay_enabled is None:
|
|
193
|
+
raise click.Abort()
|
|
194
|
+
else:
|
|
195
|
+
relay_enabled = False
|
|
196
|
+
|
|
197
|
+
allocation_id = None if selected_eip == "__dynamic__" else selected_eip
|
|
198
|
+
selected = next((address for address in eips if address.allocation_id == allocation_id), None)
|
|
199
|
+
return RuntimeSelection(
|
|
200
|
+
runtime=RuntimeConfig(
|
|
201
|
+
eip_allocation_id=allocation_id,
|
|
202
|
+
residential_proxy_enabled=residential_proxy_enabled,
|
|
203
|
+
relay_enabled=relay_enabled,
|
|
204
|
+
),
|
|
205
|
+
eip_label=selected.title if selected else "Dynamically assigned per-run IP",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _record_is_active(record: dict) -> bool:
|
|
210
|
+
if record.get("infra_status", {}).get("S") in ACTIVE_INFRA_STATUSES:
|
|
211
|
+
return True
|
|
212
|
+
return any(
|
|
213
|
+
record.get(attribute, {}).get("S") in statuses for attribute, statuses in ACTIVE_WORKFLOW_STATUSES.items()
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _same_eip_users(ddb, domain: str, task_id: str, allocation_id: str | None) -> list[dict]:
|
|
218
|
+
"""Find other active records using a selected persistent EIP."""
|
|
219
|
+
if allocation_id is None:
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
users = []
|
|
223
|
+
for record in scan_workflow_records(ddb):
|
|
224
|
+
if (
|
|
225
|
+
record.get("domain", {}).get("S") == domain and record.get("task_id", {}).get("S") == task_id
|
|
226
|
+
) or not _record_is_active(record):
|
|
227
|
+
continue
|
|
228
|
+
try:
|
|
229
|
+
runtime = runtime_config_from_record(record)
|
|
230
|
+
except ValueError:
|
|
231
|
+
continue
|
|
232
|
+
if runtime.eip_allocation_id == allocation_id:
|
|
233
|
+
users.append(record)
|
|
234
|
+
return sorted(users, key=lambda record: record["task_id"]["S"], reverse=True)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _review_launch(
|
|
238
|
+
*,
|
|
239
|
+
domain: str,
|
|
240
|
+
workflow: str,
|
|
241
|
+
task_id: str,
|
|
242
|
+
selection: RuntimeSelection,
|
|
243
|
+
same_eip_users: list[dict],
|
|
244
|
+
authorization: str,
|
|
245
|
+
workflow_status: str | None = None,
|
|
246
|
+
) -> None:
|
|
247
|
+
click.echo("\nLaunch review")
|
|
248
|
+
click.echo(f" Domain: {domain}")
|
|
249
|
+
click.echo(f" Workflow: {workflow}")
|
|
250
|
+
click.echo(f" Task ID: {task_id}")
|
|
251
|
+
if workflow == "retest":
|
|
252
|
+
click.echo(f" Completed engagement: {task_id}")
|
|
253
|
+
if workflow_status is not None:
|
|
254
|
+
click.echo(f" Current status: {workflow_status}")
|
|
255
|
+
click.echo(f" Cloud IP: {selection.eip_label}")
|
|
256
|
+
click.echo(f" Authorization: {authorization}")
|
|
257
|
+
click.echo(f" Residential proxy: {'enabled' if selection.runtime.residential_proxy_enabled else 'disabled'}")
|
|
258
|
+
click.echo(f" Operator assistance: {'enabled' if selection.runtime.relay_enabled else 'disabled'}")
|
|
259
|
+
if same_eip_users:
|
|
260
|
+
click.echo("\n Warning: the selected persistent EIP also appears on active work:")
|
|
261
|
+
for record in same_eip_users:
|
|
262
|
+
click.echo(
|
|
263
|
+
f" {record['domain']['S']} — {short_task_id(record['task_id']['S'])} "
|
|
264
|
+
f"({_record_status(record)}/{record.get('infra_status', {}).get('S', '?')})"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
click.confirm("\nQueue launch?", default=None, abort=True)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _queue_launch(
|
|
271
|
+
queue_name: str,
|
|
272
|
+
domain: str,
|
|
273
|
+
task_id: str,
|
|
274
|
+
runtime: RuntimeConfig,
|
|
275
|
+
*,
|
|
276
|
+
skip_dns_check: bool,
|
|
277
|
+
) -> None:
|
|
278
|
+
message: dict[str, object] = {
|
|
279
|
+
"domain": domain,
|
|
280
|
+
"task_id": task_id,
|
|
281
|
+
"runtime": runtime_launch_value(runtime),
|
|
282
|
+
}
|
|
283
|
+
if skip_dns_check:
|
|
284
|
+
message[SKIP_DNS_CHECK_FIELD] = True
|
|
285
|
+
aws_client("sqs").send_message(
|
|
286
|
+
QueueUrl=sqs_queue_url(queue_name),
|
|
287
|
+
MessageBody=json.dumps(message),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _wait_for_launch_record(
|
|
292
|
+
ddb,
|
|
293
|
+
domain: str,
|
|
294
|
+
task_id: str,
|
|
295
|
+
workflow: str,
|
|
296
|
+
runtime: RuntimeConfig,
|
|
297
|
+
) -> dict:
|
|
298
|
+
"""Wait until the provisioner claim and runtime replacement are observable."""
|
|
299
|
+
status_attr = f"{workflow}_status"
|
|
300
|
+
deadline = time.monotonic() + LAUNCH_RECEIPT_TIMEOUT_SECONDS
|
|
301
|
+
while time.monotonic() < deadline:
|
|
302
|
+
record = get_workflow_record(ddb, domain, task_id)
|
|
303
|
+
if record is not None:
|
|
304
|
+
try:
|
|
305
|
+
observed_runtime = runtime_config_from_record(record)
|
|
306
|
+
except ValueError:
|
|
307
|
+
observed_runtime = None
|
|
308
|
+
status = record.get(status_attr, {}).get("S")
|
|
309
|
+
infra_status = record.get("infra_status", {}).get("S")
|
|
310
|
+
if observed_runtime == runtime and (status in {"queued", "running"} or infra_status != "absent"):
|
|
311
|
+
return record
|
|
312
|
+
time.sleep(LAUNCH_RECEIPT_POLL_SECONDS)
|
|
313
|
+
raise click.ClickException(
|
|
314
|
+
"Launch request was sent, but its workflow record was not observable within 60 seconds. "
|
|
315
|
+
f"Check with: hound status --domain {domain} --task-id {task_id}"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _show_receipt(domain: str, workflow: str, task_id: str, selection: RuntimeSelection) -> None:
|
|
320
|
+
noun = "engagements" if workflow == "engagement" else "retests"
|
|
321
|
+
click.echo("\nLaunch accepted")
|
|
322
|
+
click.echo(f" AWS account: {_aws_account_id()}")
|
|
323
|
+
click.echo(f" Domain: {domain}")
|
|
324
|
+
click.echo(f" Workflow: {workflow}")
|
|
325
|
+
click.echo(f" Task ID: {task_id}")
|
|
326
|
+
click.echo(f" Cloud IP: {selection.eip_label}")
|
|
327
|
+
click.echo(f" Residential proxy: {'enabled' if selection.runtime.residential_proxy_enabled else 'disabled'}")
|
|
328
|
+
click.echo(f" Operator assistance: {'enabled' if selection.runtime.relay_enabled else 'disabled'}")
|
|
329
|
+
click.echo("\nFollow-up")
|
|
330
|
+
click.echo(f" hound status --domain {domain} --task-id {task_id}")
|
|
331
|
+
click.echo(f" hound {noun} logs --domain {domain} --task-id {task_id}")
|
|
332
|
+
click.echo(f" hound {noun} stop --domain {domain} --task-id {task_id}")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _select_engagement_launch(records: list[dict]) -> dict | None:
|
|
336
|
+
"""Select a failed/cancelled engagement for relaunch, or create a new one."""
|
|
337
|
+
if not records:
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
records = sorted(records, key=lambda record: record["task_id"]["S"], reverse=True)
|
|
341
|
+
choices = [questionary.Choice("Create a new engagement", value="__new__")]
|
|
342
|
+
choices.extend(
|
|
343
|
+
questionary.Choice(
|
|
344
|
+
f"Relaunch {record['engagement_status']['S']} — {short_task_id(record['task_id']['S'])}",
|
|
345
|
+
value=record,
|
|
346
|
+
)
|
|
347
|
+
for record in records
|
|
348
|
+
)
|
|
349
|
+
selected = _select_with_default("Engagement", choices, "__new__")
|
|
350
|
+
if selected is None:
|
|
351
|
+
raise click.Abort()
|
|
352
|
+
return None if selected == "__new__" else selected
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _launch(
|
|
356
|
+
*,
|
|
357
|
+
workflow: str,
|
|
358
|
+
domain_option: str | None,
|
|
359
|
+
skip_dns_check: bool,
|
|
360
|
+
) -> None:
|
|
361
|
+
domain = selected_domain(domain_option)
|
|
362
|
+
ddb = aws_client("dynamodb")
|
|
363
|
+
_active_domain_precheck(ddb, domain)
|
|
364
|
+
current = None
|
|
365
|
+
current_status = None
|
|
366
|
+
if workflow == "engagement":
|
|
367
|
+
record = _select_engagement_launch(get_engagement_records(ddb, domain, status=ENGAGEMENT_RELAUNCH_STATUSES))
|
|
368
|
+
if record is None:
|
|
369
|
+
task_id = str(uuid.uuid7())
|
|
370
|
+
else:
|
|
371
|
+
task_id = record["task_id"]["S"]
|
|
372
|
+
try:
|
|
373
|
+
current = runtime_config_from_record(record)
|
|
374
|
+
except ValueError as exc:
|
|
375
|
+
raise click.ClickException(f"Engagement record has invalid runtime: {exc}") from exc
|
|
376
|
+
current_status = record["engagement_status"]["S"]
|
|
377
|
+
else:
|
|
378
|
+
records = [
|
|
379
|
+
record
|
|
380
|
+
for record in get_engagement_records(ddb, domain, status="completed")
|
|
381
|
+
if record.get("infra_status", {}).get("S") == "absent"
|
|
382
|
+
and record.get("retest_status", {}).get("S") in (None, *RETEST_LAUNCH_STATUSES)
|
|
383
|
+
]
|
|
384
|
+
if not records:
|
|
385
|
+
raise click.ClickException(f"No retestable completed engagements for {domain}")
|
|
386
|
+
record = select_record(records, label="retest", status_attr="retest_status")
|
|
387
|
+
task_id = record["task_id"]["S"]
|
|
388
|
+
current_status = record.get("retest_status", {}).get("S", "not started")
|
|
389
|
+
try:
|
|
390
|
+
current = runtime_config_from_record(record)
|
|
391
|
+
except ValueError as exc:
|
|
392
|
+
raise click.ClickException(f"Retest record has invalid runtime: {exc}") from exc
|
|
393
|
+
|
|
394
|
+
prepare_target_access(domain)
|
|
395
|
+
authorization = require_launch_authorization(domain, skip_dns_check=skip_dns_check)
|
|
396
|
+
capabilities = _capabilities()
|
|
397
|
+
eips = _eligible_eips()
|
|
398
|
+
selection = _runtime_selection(capabilities, eips, current=current)
|
|
399
|
+
eip_users = _same_eip_users(ddb, domain, task_id, selection.runtime.eip_allocation_id)
|
|
400
|
+
_review_launch(
|
|
401
|
+
domain=domain,
|
|
402
|
+
workflow=workflow,
|
|
403
|
+
task_id=task_id,
|
|
404
|
+
selection=selection,
|
|
405
|
+
same_eip_users=eip_users,
|
|
406
|
+
authorization=authorization,
|
|
407
|
+
workflow_status=current_status,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
queue_name = f"hound-{workflow}-provisioner"
|
|
411
|
+
_queue_launch(
|
|
412
|
+
queue_name,
|
|
413
|
+
domain,
|
|
414
|
+
task_id,
|
|
415
|
+
selection.runtime,
|
|
416
|
+
skip_dns_check=skip_dns_check,
|
|
417
|
+
)
|
|
418
|
+
_wait_for_launch_record(ddb, domain, task_id, workflow, selection.runtime)
|
|
419
|
+
_show_receipt(domain, workflow, task_id, selection)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _launch_options(command):
|
|
423
|
+
command = click.option("--domain", help="Use this exact target domain.")(command)
|
|
424
|
+
return click.option(
|
|
425
|
+
"--skip-dns-check",
|
|
426
|
+
is_flag=True,
|
|
427
|
+
help=SKIP_DNS_CHECK_HELP,
|
|
428
|
+
)(command)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@click.command(name="launch")
|
|
432
|
+
@_launch_options
|
|
433
|
+
@operator_only
|
|
434
|
+
def engagement_launch(domain: str | None, skip_dns_check: bool):
|
|
435
|
+
"""Start a new penetration test or relaunch a previous one."""
|
|
436
|
+
_launch(workflow="engagement", domain_option=domain, skip_dns_check=skip_dns_check)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@click.command(name="launch")
|
|
440
|
+
@_launch_options
|
|
441
|
+
@operator_only
|
|
442
|
+
def retest_launch(domain: str | None, skip_dns_check: bool):
|
|
443
|
+
"""Start or relaunch a retest for a completed penetration test."""
|
|
444
|
+
_launch(workflow="retest", domain_option=domain, skip_dns_check=skip_dns_check)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""View workflow log streams.
|
|
2
|
+
|
|
3
|
+
Shows the full log stream — thinking, subagent spawns, and tool calls.
|
|
4
|
+
Use --tail to keep polling for new events.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
hound engagements logs
|
|
8
|
+
hound engagements logs --tail
|
|
9
|
+
hound retests logs
|
|
10
|
+
hound retests logs --tail
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
from botocore.exceptions import ClientError
|
|
20
|
+
|
|
21
|
+
from hound_cli.aws import (
|
|
22
|
+
aws_client,
|
|
23
|
+
get_engagement_records,
|
|
24
|
+
get_retest_records,
|
|
25
|
+
get_workflow_record,
|
|
26
|
+
operator_only,
|
|
27
|
+
)
|
|
28
|
+
from hound_cli.workflows.selection import canonical_task_id, select_record, selected_domain
|
|
29
|
+
|
|
30
|
+
STATUSES_WITH_LOGS = ["running", "completed", "failed", "cancelled"]
|
|
31
|
+
|
|
32
|
+
POLL_INTERVAL_SECONDS = 5
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _log_group(task_id: str, workflow: str) -> str:
|
|
36
|
+
"""Derive CloudWatch log group from task ID and workflow."""
|
|
37
|
+
return f"/ecs/hound-task-{task_id[-12:]}-{workflow}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_message(raw: str) -> str | None:
|
|
41
|
+
"""Extract the message field from a JSON log line, or return raw text."""
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(raw).get("message")
|
|
44
|
+
except json.JSONDecodeError, AttributeError:
|
|
45
|
+
return raw
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _print_events(events: list[dict]) -> int:
|
|
49
|
+
"""Print log events, return count printed."""
|
|
50
|
+
count = 0
|
|
51
|
+
for event in events:
|
|
52
|
+
message = _extract_message(event["message"])
|
|
53
|
+
if message:
|
|
54
|
+
click.echo(message.strip())
|
|
55
|
+
click.echo()
|
|
56
|
+
count += 1
|
|
57
|
+
return count
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _event_identity(event: dict) -> str:
|
|
61
|
+
"""Return the stable CloudWatch identity used to suppress tail overlap."""
|
|
62
|
+
event_id = event.get("eventId")
|
|
63
|
+
if isinstance(event_id, str):
|
|
64
|
+
return event_id
|
|
65
|
+
return json.dumps(
|
|
66
|
+
{
|
|
67
|
+
"timestamp": event.get("timestamp"),
|
|
68
|
+
"ingestionTime": event.get("ingestionTime"),
|
|
69
|
+
"logStreamName": event.get("logStreamName"),
|
|
70
|
+
"message": event.get("message"),
|
|
71
|
+
},
|
|
72
|
+
sort_keys=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _print_logs(logs, log_group: str, tail: bool) -> None:
|
|
77
|
+
"""Consume every FilterLogEvents page, then poll from the latest timestamp."""
|
|
78
|
+
base_kwargs = {"logGroupName": log_group}
|
|
79
|
+
kwargs = base_kwargs.copy()
|
|
80
|
+
count = 0
|
|
81
|
+
cursor_timestamp: int | None = None
|
|
82
|
+
cursor_event_ids: set[str] = set()
|
|
83
|
+
|
|
84
|
+
while True:
|
|
85
|
+
resp = logs.filter_log_events(**kwargs)
|
|
86
|
+
events = resp.get("events", [])
|
|
87
|
+
unseen_events = []
|
|
88
|
+
for event in events:
|
|
89
|
+
timestamp = event["timestamp"]
|
|
90
|
+
if cursor_timestamp is None or timestamp > cursor_timestamp:
|
|
91
|
+
cursor_timestamp = timestamp
|
|
92
|
+
cursor_event_ids.clear()
|
|
93
|
+
elif timestamp < cursor_timestamp:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
event_id = _event_identity(event)
|
|
97
|
+
if event_id in cursor_event_ids:
|
|
98
|
+
continue
|
|
99
|
+
cursor_event_ids.add(event_id)
|
|
100
|
+
unseen_events.append(event)
|
|
101
|
+
|
|
102
|
+
count += _print_events(unseen_events)
|
|
103
|
+
|
|
104
|
+
# FilterLogEvents pages may be empty even when more pages contain
|
|
105
|
+
# events. Consume nextToken until the service says pagination is done.
|
|
106
|
+
request_token = kwargs.get("nextToken")
|
|
107
|
+
next_token = resp.get("nextToken")
|
|
108
|
+
if next_token and next_token != request_token:
|
|
109
|
+
kwargs["nextToken"] = next_token
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if not tail:
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
116
|
+
kwargs = base_kwargs.copy()
|
|
117
|
+
if cursor_timestamp is not None:
|
|
118
|
+
# startTime is inclusive. Re-read the cursor millisecond and
|
|
119
|
+
# suppress only the event IDs already printed at that timestamp,
|
|
120
|
+
# so late events sharing the timestamp cannot be skipped.
|
|
121
|
+
kwargs["startTime"] = cursor_timestamp
|
|
122
|
+
|
|
123
|
+
if count == 0:
|
|
124
|
+
click.echo(" No matching log entries found.")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _show_logs(
|
|
128
|
+
get_records,
|
|
129
|
+
status_attr: str,
|
|
130
|
+
label: str,
|
|
131
|
+
workflow: str,
|
|
132
|
+
tail: bool,
|
|
133
|
+
domain_option: str | None,
|
|
134
|
+
task_id: str | None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Prompt for a workflow record and print its log stream."""
|
|
137
|
+
if task_id is not None and domain_option is None:
|
|
138
|
+
raise click.UsageError("--task-id requires --domain")
|
|
139
|
+
domain = selected_domain(domain_option)
|
|
140
|
+
|
|
141
|
+
ddb = aws_client("dynamodb")
|
|
142
|
+
if task_id is not None:
|
|
143
|
+
task_id = canonical_task_id(task_id)
|
|
144
|
+
exact = get_workflow_record(ddb, domain, task_id)
|
|
145
|
+
records = [exact] if exact and exact.get(status_attr, {}).get("S") in STATUSES_WITH_LOGS else []
|
|
146
|
+
else:
|
|
147
|
+
records = get_records(ddb, domain, status=STATUSES_WITH_LOGS)
|
|
148
|
+
if not records:
|
|
149
|
+
raise click.ClickException(f"No {label}s with logs for {domain}")
|
|
150
|
+
|
|
151
|
+
record = select_record(records, label=label, status_attr=status_attr, task_id=task_id)
|
|
152
|
+
task_id = record["task_id"]["S"]
|
|
153
|
+
status = record[status_attr]["S"]
|
|
154
|
+
log_group = _log_group(task_id, workflow)
|
|
155
|
+
|
|
156
|
+
click.echo(f"\n Status: {status}")
|
|
157
|
+
click.echo(f" Task ID: {task_id}")
|
|
158
|
+
if tail:
|
|
159
|
+
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
160
|
+
click.echo(" Ctrl+C to stop")
|
|
161
|
+
click.echo()
|
|
162
|
+
|
|
163
|
+
cw = aws_client("logs")
|
|
164
|
+
try:
|
|
165
|
+
_print_logs(cw, log_group, tail)
|
|
166
|
+
except ClientError as e:
|
|
167
|
+
if e.response["Error"]["Code"] == "ResourceNotFoundException":
|
|
168
|
+
raise click.ClickException("Log group not found — may have expired.") from None
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@click.command(name="logs")
|
|
173
|
+
@click.option("--tail", "-t", is_flag=True, help="Keep polling for new events.")
|
|
174
|
+
@click.option("--domain", help="Use this exact target domain.")
|
|
175
|
+
@click.option("--task-id", help="Use one exact task; requires --domain.")
|
|
176
|
+
@operator_only
|
|
177
|
+
def engagement_logs(tail: bool, domain: str | None, task_id: str | None):
|
|
178
|
+
"""View the log stream for an engagement."""
|
|
179
|
+
_show_logs(
|
|
180
|
+
get_engagement_records,
|
|
181
|
+
"engagement_status",
|
|
182
|
+
"engagement",
|
|
183
|
+
"engagement",
|
|
184
|
+
tail,
|
|
185
|
+
domain,
|
|
186
|
+
task_id,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@click.command(name="logs")
|
|
191
|
+
@click.option("--tail", "-t", is_flag=True, help="Keep polling for new events.")
|
|
192
|
+
@click.option("--domain", help="Use this exact target domain.")
|
|
193
|
+
@click.option("--task-id", help="Use one exact task; requires --domain.")
|
|
194
|
+
@operator_only
|
|
195
|
+
def retest_logs(tail: bool, domain: str | None, task_id: str | None):
|
|
196
|
+
"""View the log stream for a retest."""
|
|
197
|
+
_show_logs(get_retest_records, "retest_status", "retest", "retest", tail, domain, task_id)
|