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.
@@ -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,5 @@
1
+ """Shared interactive CLI presentation."""
2
+
3
+ import questionary
4
+
5
+ MENU_STYLE = questionary.Style([("highlighted", "reverse")])
@@ -0,0 +1 @@
1
+ """Engagement and retest workflow commands."""