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,163 @@
|
|
|
1
|
+
"""Collect and store the access details Hound uses on a target."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import questionary
|
|
5
|
+
|
|
6
|
+
from hound_cli.aws import aws_client, get_secret, put_secret
|
|
7
|
+
from hound_cli.roster import load_roster
|
|
8
|
+
from hound_cli.ui import MENU_STYLE
|
|
9
|
+
|
|
10
|
+
DISPLAY_FIELDS = ("email", "username", "role", "phone_number")
|
|
11
|
+
ACCOUNTS_KEY = "accounts"
|
|
12
|
+
OPERATOR_NOTES_KEY = "operator_notes"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _print_agent_access(config: dict) -> None:
|
|
16
|
+
"""Print target accounts grouped by agent, including passwords."""
|
|
17
|
+
accounts = config.get(ACCOUNTS_KEY, {})
|
|
18
|
+
if not isinstance(accounts, dict):
|
|
19
|
+
return
|
|
20
|
+
if not accounts:
|
|
21
|
+
click.echo(" No target accounts")
|
|
22
|
+
return
|
|
23
|
+
for agent_id, fields in sorted(accounts.items()):
|
|
24
|
+
if not isinstance(fields, dict):
|
|
25
|
+
continue
|
|
26
|
+
click.echo(f" {agent_id}:")
|
|
27
|
+
for key in DISPLAY_FIELDS:
|
|
28
|
+
if key in fields:
|
|
29
|
+
click.echo(f" {key:14s} {fields[key]}")
|
|
30
|
+
click.echo(f" {'password':14s} {fields.get('password') or 'not set'}")
|
|
31
|
+
click.echo(f" {'sms mfa':14s} {'yes' if fields.get('phone_number') else 'no'}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _print_operator_notes(config: dict) -> None:
|
|
35
|
+
"""Print the full operator notes or their explicit absence."""
|
|
36
|
+
click.echo(f" {config.get(OPERATOR_NOTES_KEY) or 'not set'}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _configure_agent(agent_name: str, agent: dict, current: dict | None = None) -> dict:
|
|
40
|
+
"""Configure target-specific fields for an agent.
|
|
41
|
+
|
|
42
|
+
Existing values survive an empty prompt during a merge.
|
|
43
|
+
"""
|
|
44
|
+
click.echo(f"\n Configuring {agent_name}:")
|
|
45
|
+
fields = dict(current) if isinstance(current, dict) else {}
|
|
46
|
+
|
|
47
|
+
if agent.get("email"):
|
|
48
|
+
fields["email"] = agent["email"]
|
|
49
|
+
|
|
50
|
+
username = click.prompt(
|
|
51
|
+
"Target username (optional)",
|
|
52
|
+
default=fields.get("username", ""),
|
|
53
|
+
show_default=bool(fields.get("username")),
|
|
54
|
+
)
|
|
55
|
+
if username:
|
|
56
|
+
fields["username"] = username
|
|
57
|
+
|
|
58
|
+
password = click.prompt(
|
|
59
|
+
"Target password (optional)",
|
|
60
|
+
default=fields.get("password", ""),
|
|
61
|
+
show_default=bool(fields.get("password")),
|
|
62
|
+
hide_input=False,
|
|
63
|
+
)
|
|
64
|
+
if password:
|
|
65
|
+
fields["password"] = password
|
|
66
|
+
|
|
67
|
+
role = click.prompt(
|
|
68
|
+
"Role (optional)",
|
|
69
|
+
default=fields.get("role", ""),
|
|
70
|
+
show_default=bool(fields.get("role")),
|
|
71
|
+
)
|
|
72
|
+
if role:
|
|
73
|
+
fields["role"] = role
|
|
74
|
+
|
|
75
|
+
if agent.get("phone_number"):
|
|
76
|
+
if click.confirm("Does the target use SMS MFA?", default=bool(fields.get("phone_number"))):
|
|
77
|
+
fields["phone_number"] = agent["phone_number"]
|
|
78
|
+
else:
|
|
79
|
+
fields.pop("phone_number", None)
|
|
80
|
+
elif click.confirm("Does the target use SMS MFA?"):
|
|
81
|
+
click.echo(f"Warning: agent '{agent_name}' has no phone number in the roster.")
|
|
82
|
+
fields.pop("phone_number", None)
|
|
83
|
+
|
|
84
|
+
return fields
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _accounts(config: dict) -> dict:
|
|
88
|
+
accounts = config.get(ACCOUNTS_KEY)
|
|
89
|
+
if not isinstance(accounts, dict):
|
|
90
|
+
raise click.ClickException("Existing target configuration must be replaced before it can be edited.")
|
|
91
|
+
return accounts
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _prompt_agent_access(client, current_accounts: dict | None = None) -> dict:
|
|
95
|
+
"""Collect the complete set of target accounts for this test."""
|
|
96
|
+
if not click.confirm("Use target accounts for this test?", default=None):
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
current_accounts = current_accounts or {}
|
|
100
|
+
roster = load_roster(client)
|
|
101
|
+
choices = [questionary.Choice(name, checked=name in current_accounts) for name in sorted(roster)]
|
|
102
|
+
selected = questionary.checkbox("Select agents", choices=choices, style=MENU_STYLE).unsafe_ask()
|
|
103
|
+
if selected is None:
|
|
104
|
+
raise click.Abort()
|
|
105
|
+
if not selected:
|
|
106
|
+
raise click.ClickException("Select at least one agent, or answer no when asked about target accounts.")
|
|
107
|
+
|
|
108
|
+
return {name: _configure_agent(name, roster[name], current_accounts.get(name)) for name in selected}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def prepare_target_access(domain: str) -> None:
|
|
112
|
+
"""Ensure Hound has explicit access details for a launch target."""
|
|
113
|
+
client = aws_client("secretsmanager")
|
|
114
|
+
secret_id = f"hound/targets/{domain}"
|
|
115
|
+
|
|
116
|
+
existing = get_secret(client, secret_id)
|
|
117
|
+
if existing is not None and not isinstance(existing, dict):
|
|
118
|
+
raise click.ClickException("Target secret must be a JSON object.")
|
|
119
|
+
supported = existing is not None and isinstance(existing.get(ACCOUNTS_KEY), dict)
|
|
120
|
+
if existing is not None and not supported:
|
|
121
|
+
click.echo("\nExisting target access uses an unsupported format and must be replaced.")
|
|
122
|
+
|
|
123
|
+
config = dict(existing) if supported else {}
|
|
124
|
+
agent_access_changed = not supported
|
|
125
|
+
|
|
126
|
+
click.echo("\nAgent access")
|
|
127
|
+
if supported:
|
|
128
|
+
_print_agent_access(config)
|
|
129
|
+
if click.confirm("\nUpdate agent access?", default=False):
|
|
130
|
+
config[ACCOUNTS_KEY] = _prompt_agent_access(client, _accounts(config))
|
|
131
|
+
agent_access_changed = True
|
|
132
|
+
else:
|
|
133
|
+
if existing is None:
|
|
134
|
+
click.echo(f"No agent access is stored for {domain}.")
|
|
135
|
+
config[ACCOUNTS_KEY] = _prompt_agent_access(client)
|
|
136
|
+
|
|
137
|
+
click.echo("\nOperator notes")
|
|
138
|
+
_print_operator_notes(config)
|
|
139
|
+
notes_prompt = "Update operator notes?" if config.get(OPERATOR_NOTES_KEY) else "Add operator notes?"
|
|
140
|
+
operator_notes_changed = False
|
|
141
|
+
if click.confirm(f"\n{notes_prompt}", default=False):
|
|
142
|
+
operator_notes = click.prompt(
|
|
143
|
+
"Operator notes (leave empty to clear)",
|
|
144
|
+
default="",
|
|
145
|
+
show_default=False,
|
|
146
|
+
)
|
|
147
|
+
if operator_notes:
|
|
148
|
+
config[OPERATOR_NOTES_KEY] = operator_notes
|
|
149
|
+
else:
|
|
150
|
+
config.pop(OPERATOR_NOTES_KEY, None)
|
|
151
|
+
operator_notes_changed = True
|
|
152
|
+
|
|
153
|
+
if not agent_access_changed and not operator_notes_changed:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
put_secret(client, secret_id, config)
|
|
157
|
+
if agent_access_changed and operator_notes_changed:
|
|
158
|
+
saved = "Agent access and operator notes"
|
|
159
|
+
elif agent_access_changed:
|
|
160
|
+
saved = "Agent access"
|
|
161
|
+
else:
|
|
162
|
+
saved = "Operator notes"
|
|
163
|
+
click.echo(f"\n{saved} saved for {domain}.")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Show and check a target's DNS testing authorization."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from hound_cli.aws import _aws_account_id, operator_only
|
|
6
|
+
from hound_cli.contracts.domain import (
|
|
7
|
+
VerificationUnavailable,
|
|
8
|
+
is_ip_target,
|
|
9
|
+
verification_hash,
|
|
10
|
+
verification_record_names,
|
|
11
|
+
verification_txt_present,
|
|
12
|
+
verification_txt_value,
|
|
13
|
+
)
|
|
14
|
+
from hound_cli.workflows.selection import selected_domain
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_authorization(domain: str) -> bool:
|
|
18
|
+
expected_hash = verification_hash(_aws_account_id(), domain)
|
|
19
|
+
try:
|
|
20
|
+
return verification_txt_present(domain, expected_hash)
|
|
21
|
+
except VerificationUnavailable as exc:
|
|
22
|
+
raise click.ClickException(f"Could not check authorization for {domain}: {exc}") from exc
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _show_required_record(domain: str, *, ask_owner: bool) -> None:
|
|
26
|
+
value = verification_txt_value(verification_hash(_aws_account_id(), domain))
|
|
27
|
+
names = verification_record_names(domain)
|
|
28
|
+
if ask_owner:
|
|
29
|
+
heading = (
|
|
30
|
+
"Ask the target owner to add one of these DNS TXT records:"
|
|
31
|
+
if len(names) > 1
|
|
32
|
+
else "Ask the target owner to add this DNS TXT record:"
|
|
33
|
+
)
|
|
34
|
+
else:
|
|
35
|
+
heading = (
|
|
36
|
+
"Hound accepts either of these DNS TXT records:" if len(names) > 1 else "Hound accepts this DNS TXT record:"
|
|
37
|
+
)
|
|
38
|
+
click.echo(f"\n{heading}")
|
|
39
|
+
for index, name in enumerate(names):
|
|
40
|
+
if index:
|
|
41
|
+
click.echo("\nOR")
|
|
42
|
+
click.echo(f"\n Name: {name}")
|
|
43
|
+
click.echo(f" Value: {value}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_launch_authorization(domain: str, *, skip_dns_check: bool) -> str:
|
|
47
|
+
"""Check authorization before launch and return its review label."""
|
|
48
|
+
if skip_dns_check:
|
|
49
|
+
return "DNS check explicitly skipped"
|
|
50
|
+
if is_ip_target(domain):
|
|
51
|
+
raise click.ClickException(
|
|
52
|
+
"IP targets cannot use DNS authorization. Confirm authorization out of band, "
|
|
53
|
+
"then rerun with --skip-dns-check."
|
|
54
|
+
)
|
|
55
|
+
if _check_authorization(domain):
|
|
56
|
+
click.echo("\nTesting authorization: Authorized")
|
|
57
|
+
return "Authorized"
|
|
58
|
+
|
|
59
|
+
click.echo(f"\nTesting authorization\n\nTarget: {domain}\nStatus: Not authorized")
|
|
60
|
+
_show_required_record(domain, ask_owner=True)
|
|
61
|
+
click.echo(f"\nCheck again with:\n hound auth --domain {domain}")
|
|
62
|
+
raise click.ClickException(f"Launch stopped: {domain} is not authorized for testing.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.command(name="auth")
|
|
66
|
+
@click.option("--domain", help="Use this exact target domain.")
|
|
67
|
+
@operator_only
|
|
68
|
+
def authorization(domain: str | None) -> None:
|
|
69
|
+
"""Show and check DNS authorization for a target."""
|
|
70
|
+
domain = selected_domain(domain)
|
|
71
|
+
click.echo(f"Testing authorization\n\nTarget: {domain}")
|
|
72
|
+
if is_ip_target(domain):
|
|
73
|
+
click.echo(
|
|
74
|
+
"Status: DNS authorization is unavailable for IP targets.\n\n"
|
|
75
|
+
"Confirm authorization out of band, then launch with --skip-dns-check."
|
|
76
|
+
)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
authorized = _check_authorization(domain)
|
|
80
|
+
click.echo(f"Status: {'Authorized' if authorized else 'Not authorized'}")
|
|
81
|
+
_show_required_record(domain, ask_owner=not authorized)
|
hound_cli/ui.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Engagement and retest workflow commands."""
|