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
hound_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hound CLI."""
|
hound_cli/__main__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Hound cli entrypoint."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from hound_cli.agents import agents
|
|
6
|
+
from hound_cli.inbox.command import inbox
|
|
7
|
+
from hound_cli.reports.close import close_report
|
|
8
|
+
from hound_cli.reports.open import open_report
|
|
9
|
+
from hound_cli.reports.render import render_report
|
|
10
|
+
from hound_cli.targets.verification import authorization
|
|
11
|
+
from hound_cli.workflows.launch import engagement_launch, ips, retest_launch
|
|
12
|
+
from hound_cli.workflows.logs import engagement_logs, retest_logs
|
|
13
|
+
from hound_cli.workflows.status import account_status, engagement_status, retest_status
|
|
14
|
+
from hound_cli.workflows.stop import engagement_stop, retest_stop
|
|
15
|
+
|
|
16
|
+
CONTEXT = {"help_option_names": ["-h", "--help"]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group(context_settings=CONTEXT)
|
|
20
|
+
def main():
|
|
21
|
+
"""Hound CLI."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@main.group()
|
|
25
|
+
def engagements():
|
|
26
|
+
"""Launch and manage penetration tests."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@main.group()
|
|
30
|
+
def retests():
|
|
31
|
+
"""Launch and manage retests."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@main.group()
|
|
35
|
+
def reports():
|
|
36
|
+
"""Manage task-scoped customer report reviews."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
engagements.add_command(engagement_launch)
|
|
40
|
+
engagements.add_command(engagement_status)
|
|
41
|
+
engagements.add_command(engagement_logs)
|
|
42
|
+
engagements.add_command(engagement_stop)
|
|
43
|
+
|
|
44
|
+
retests.add_command(retest_launch)
|
|
45
|
+
retests.add_command(retest_status)
|
|
46
|
+
retests.add_command(retest_logs)
|
|
47
|
+
retests.add_command(retest_stop)
|
|
48
|
+
|
|
49
|
+
reports.add_command(open_report)
|
|
50
|
+
reports.add_command(render_report)
|
|
51
|
+
reports.add_command(close_report)
|
|
52
|
+
|
|
53
|
+
main.add_command(agents)
|
|
54
|
+
main.add_command(authorization)
|
|
55
|
+
main.add_command(account_status)
|
|
56
|
+
main.add_command(inbox)
|
|
57
|
+
main.add_command(ips)
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
hound_cli/agents.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""List the account-local Hound agent roster."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from hound_cli.aws import aws_client, operator_only
|
|
6
|
+
from hound_cli.roster import load_roster
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command("agents")
|
|
10
|
+
@operator_only
|
|
11
|
+
def agents() -> None:
|
|
12
|
+
"""List Hound agents and their configured contact details."""
|
|
13
|
+
roster = load_roster(aws_client("secretsmanager"))
|
|
14
|
+
|
|
15
|
+
click.echo("Agents:")
|
|
16
|
+
for agent_id, agent in sorted(roster.items()):
|
|
17
|
+
click.echo(f"\n {agent_id}")
|
|
18
|
+
click.echo(f" Email: {agent['email']}")
|
|
19
|
+
click.echo(f" Phone: {agent.get('phone_number') or '—'}")
|
hound_cli/aws.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Account-local AWS access for the Hound CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from functools import cache, wraps
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
import click
|
|
9
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
10
|
+
|
|
11
|
+
from hound_cli.contracts.domain import domain_id, normalize_domain
|
|
12
|
+
from hound_cli.contracts.workflow import ACTIVE_WORKFLOW_STATUSES, artifacts_bucket_name
|
|
13
|
+
|
|
14
|
+
PENTESTS_TABLE = "hound-pentests"
|
|
15
|
+
OPERATOR_ROLE = "hound-operator"
|
|
16
|
+
REPORT_REVIEWER_ROLE = "hound-report-reviewer"
|
|
17
|
+
REPORT_SESSION_ROLE = "hound-report-session"
|
|
18
|
+
_OPERATOR_ARN = re.compile(rf"^arn:aws:sts::(?P<account_id>[0-9]{{12}}):assumed-role/{OPERATOR_ROLE}/[^/]+$")
|
|
19
|
+
_REPORT_REVIEWER_ARN = re.compile(
|
|
20
|
+
rf"^arn:aws:sts::(?P<account_id>[0-9]{{12}}):assumed-role/{REPORT_REVIEWER_ROLE}/[^/]+$"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def short_task_id(task_id: str) -> str:
|
|
25
|
+
"""Return the operator-facing task suffix used in AWS resource names."""
|
|
26
|
+
return task_id[-12:]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@cache
|
|
30
|
+
def _aws_region() -> str:
|
|
31
|
+
"""Resolve AWS region from the current session."""
|
|
32
|
+
return boto3.session.Session().region_name or "us-east-1"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@cache
|
|
36
|
+
def _aws_identity() -> tuple[str, str]:
|
|
37
|
+
"""Resolve the account and ARN for the current AWS session."""
|
|
38
|
+
try:
|
|
39
|
+
identity = boto3.client("sts", region_name=_aws_region()).get_caller_identity()
|
|
40
|
+
return identity["Account"], identity["Arn"]
|
|
41
|
+
except (BotoCoreError, ClientError, KeyError) as exc:
|
|
42
|
+
raise click.ClickException("Could not verify the active AWS identity.") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_operator_identity(account_id: str, arn: str) -> str:
|
|
46
|
+
"""Return the account ID when the caller is the account-local operator."""
|
|
47
|
+
match = _OPERATOR_ARN.fullmatch(arn)
|
|
48
|
+
if match is None or match.group("account_id") != account_id:
|
|
49
|
+
raise click.ClickException(
|
|
50
|
+
f"Active AWS identity must be an assumed {OPERATOR_ROLE} role; got {arn}. "
|
|
51
|
+
"Select the managed account's operator profile."
|
|
52
|
+
)
|
|
53
|
+
return account_id
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_report_reviewer_identity(account_id: str, arn: str) -> str:
|
|
57
|
+
"""Return the account ID when the caller is the account-local report reviewer."""
|
|
58
|
+
match = _REPORT_REVIEWER_ARN.fullmatch(arn)
|
|
59
|
+
if match is None or match.group("account_id") != account_id:
|
|
60
|
+
raise click.ClickException(
|
|
61
|
+
f"Active AWS identity must be an assumed {REPORT_REVIEWER_ROLE} role; got {arn}. "
|
|
62
|
+
"Select the managed account's report-reviewer profile."
|
|
63
|
+
)
|
|
64
|
+
return account_id
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def require_operator_identity() -> str:
|
|
68
|
+
"""Fail closed unless the active session uses the account-local operator role."""
|
|
69
|
+
return _validate_operator_identity(*_aws_identity())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def require_report_reviewer_identity() -> str:
|
|
73
|
+
"""Fail closed unless the active session uses the account-local report-reviewer role."""
|
|
74
|
+
return _validate_report_reviewer_identity(*_aws_identity())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def operator_only(func):
|
|
78
|
+
"""Require the normal public workflow-operator identity before running a command."""
|
|
79
|
+
|
|
80
|
+
@wraps(func)
|
|
81
|
+
def wrapped(*args, **kwargs):
|
|
82
|
+
account_id = require_operator_identity()
|
|
83
|
+
click.echo(f"AWS account: {account_id}")
|
|
84
|
+
return func(*args, **kwargs)
|
|
85
|
+
|
|
86
|
+
return wrapped
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def report_reviewer_only(func):
|
|
90
|
+
"""Require the report-review identity before running a report command."""
|
|
91
|
+
|
|
92
|
+
@wraps(func)
|
|
93
|
+
def wrapped(*args, **kwargs):
|
|
94
|
+
account_id = require_report_reviewer_identity()
|
|
95
|
+
click.echo(f"AWS account: {account_id}")
|
|
96
|
+
return func(*args, **kwargs)
|
|
97
|
+
|
|
98
|
+
return wrapped
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@cache
|
|
102
|
+
def _aws_account_id() -> str:
|
|
103
|
+
"""Resolve AWS account ID from the current session."""
|
|
104
|
+
return _aws_identity()[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def prompt_target(label: str = "Domain") -> str:
|
|
108
|
+
"""Return the normalized exact submitted target used as the workflow key."""
|
|
109
|
+
raw = click.prompt(label).strip()
|
|
110
|
+
try:
|
|
111
|
+
return normalize_domain(raw)
|
|
112
|
+
except ValueError as exc:
|
|
113
|
+
raise click.ClickException(f"Invalid target: {raw}") from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_secret(client, secret_id: str) -> dict | None:
|
|
117
|
+
"""Read a JSON secret. Returns None if it doesn't exist."""
|
|
118
|
+
try:
|
|
119
|
+
resp = client.get_secret_value(SecretId=secret_id)
|
|
120
|
+
return json.loads(resp["SecretString"])
|
|
121
|
+
except ClientError as e:
|
|
122
|
+
if e.response["Error"]["Code"] == "ResourceNotFoundException":
|
|
123
|
+
return None
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def aws_client(service: str):
|
|
128
|
+
"""Create a boto3 client with the resolved region."""
|
|
129
|
+
return boto3.client(service, region_name=_aws_region())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def put_secret(client, secret_id: str, value: dict) -> None:
|
|
133
|
+
"""Create or update a JSON secret."""
|
|
134
|
+
secret_string = json.dumps(value, indent=2)
|
|
135
|
+
try:
|
|
136
|
+
client.put_secret_value(SecretId=secret_id, SecretString=secret_string)
|
|
137
|
+
except ClientError as e:
|
|
138
|
+
if e.response["Error"]["Code"] == "ResourceNotFoundException":
|
|
139
|
+
client.create_secret(Name=secret_id, SecretString=secret_string)
|
|
140
|
+
else:
|
|
141
|
+
raise
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sqs_queue_url(queue_name: str) -> str:
|
|
145
|
+
"""Return an account-local Hound queue URL."""
|
|
146
|
+
return f"https://sqs.{_aws_region()}.amazonaws.com/{_aws_account_id()}/{queue_name}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _domain_records(
|
|
150
|
+
ddb,
|
|
151
|
+
table_name: str,
|
|
152
|
+
domain: str,
|
|
153
|
+
status_attr: str | None = None,
|
|
154
|
+
status: str | list[str] | None = None,
|
|
155
|
+
infra_status: str | list[str] | None = None,
|
|
156
|
+
) -> list[dict]:
|
|
157
|
+
"""Fetch domain records, optionally filtered by lifecycle or infra status."""
|
|
158
|
+
kwargs = {
|
|
159
|
+
"TableName": table_name,
|
|
160
|
+
"KeyConditionExpression": "#d = :d",
|
|
161
|
+
"ExpressionAttributeNames": {"#d": "domain"},
|
|
162
|
+
"ExpressionAttributeValues": {":d": {"S": domain}},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
filters = []
|
|
166
|
+
|
|
167
|
+
if isinstance(status, (list, tuple, set, frozenset)):
|
|
168
|
+
if status_attr is None:
|
|
169
|
+
raise ValueError("status_attr is required when status filter is set")
|
|
170
|
+
placeholders = {f":s{i}": {"S": s} for i, s in enumerate(status)}
|
|
171
|
+
filters.append(f"{status_attr} IN ({', '.join(placeholders)})")
|
|
172
|
+
kwargs["ExpressionAttributeValues"].update(placeholders)
|
|
173
|
+
elif status:
|
|
174
|
+
if status_attr is None:
|
|
175
|
+
raise ValueError("status_attr is required when status filter is set")
|
|
176
|
+
filters.append(f"{status_attr} = :s")
|
|
177
|
+
kwargs["ExpressionAttributeValues"][":s"] = {"S": status}
|
|
178
|
+
|
|
179
|
+
if isinstance(infra_status, (list, tuple, set, frozenset)):
|
|
180
|
+
placeholders = {f":is{i}": {"S": s} for i, s in enumerate(infra_status)}
|
|
181
|
+
filters.append(f"infra_status IN ({', '.join(placeholders)})")
|
|
182
|
+
kwargs["ExpressionAttributeValues"].update(placeholders)
|
|
183
|
+
elif infra_status:
|
|
184
|
+
filters.append("infra_status = :is")
|
|
185
|
+
kwargs["ExpressionAttributeValues"][":is"] = {"S": infra_status}
|
|
186
|
+
|
|
187
|
+
if filters:
|
|
188
|
+
kwargs["FilterExpression"] = " AND ".join(filters)
|
|
189
|
+
|
|
190
|
+
records = []
|
|
191
|
+
while True:
|
|
192
|
+
page = ddb.query(**kwargs)
|
|
193
|
+
records.extend(page.get("Items", []))
|
|
194
|
+
last_key = page.get("LastEvaluatedKey")
|
|
195
|
+
if not last_key:
|
|
196
|
+
return records
|
|
197
|
+
kwargs["ExclusiveStartKey"] = last_key
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_workflow_record(ddb, domain: str, task_id: str) -> dict | None:
|
|
201
|
+
"""Read one exact account-local workflow record."""
|
|
202
|
+
response = ddb.get_item(
|
|
203
|
+
TableName=PENTESTS_TABLE,
|
|
204
|
+
Key={"domain": {"S": domain}, "task_id": {"S": task_id}},
|
|
205
|
+
)
|
|
206
|
+
return response.get("Item")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def scan_workflow_records(ddb) -> list[dict]:
|
|
210
|
+
"""Read every account-local workflow record for the operator work queue."""
|
|
211
|
+
records = []
|
|
212
|
+
kwargs = {"TableName": PENTESTS_TABLE, "ConsistentRead": False}
|
|
213
|
+
while True:
|
|
214
|
+
page = ddb.scan(**kwargs)
|
|
215
|
+
records.extend(page.get("Items", []))
|
|
216
|
+
last_key = page.get("LastEvaluatedKey")
|
|
217
|
+
if not last_key:
|
|
218
|
+
return records
|
|
219
|
+
kwargs["ExclusiveStartKey"] = last_key
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_engagement_records(
|
|
223
|
+
ddb,
|
|
224
|
+
domain: str,
|
|
225
|
+
status: str | list[str] | None = None,
|
|
226
|
+
infra_status: str | list[str] | None = None,
|
|
227
|
+
) -> list[dict]:
|
|
228
|
+
"""Fetch engagement records for a domain, optionally filtered by status fields."""
|
|
229
|
+
return _domain_records(
|
|
230
|
+
ddb,
|
|
231
|
+
PENTESTS_TABLE,
|
|
232
|
+
domain,
|
|
233
|
+
status_attr="engagement_status",
|
|
234
|
+
status=status,
|
|
235
|
+
infra_status=infra_status,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def get_retest_records(
|
|
240
|
+
ddb,
|
|
241
|
+
domain: str,
|
|
242
|
+
status: str | list[str] | None = None,
|
|
243
|
+
infra_status: str | list[str] | None = None,
|
|
244
|
+
) -> list[dict]:
|
|
245
|
+
"""Fetch engagement records that have current retest state."""
|
|
246
|
+
records = _domain_records(
|
|
247
|
+
ddb,
|
|
248
|
+
PENTESTS_TABLE,
|
|
249
|
+
domain,
|
|
250
|
+
status_attr="retest_status",
|
|
251
|
+
status=status,
|
|
252
|
+
infra_status=infra_status,
|
|
253
|
+
)
|
|
254
|
+
return [record for record in records if "retest_status" in record]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def active_domain_work(ddb, domain: str) -> list[dict]:
|
|
258
|
+
"""Return same-domain engagement records that block launch."""
|
|
259
|
+
records = get_engagement_records(ddb, domain)
|
|
260
|
+
active = []
|
|
261
|
+
for record in records:
|
|
262
|
+
infra_status = record.get("infra_status", {}).get("S")
|
|
263
|
+
if infra_status != "absent":
|
|
264
|
+
active.append(record)
|
|
265
|
+
continue
|
|
266
|
+
if any(
|
|
267
|
+
record.get(attr, {}).get("S") in active_statuses
|
|
268
|
+
for attr, active_statuses in ACTIVE_WORKFLOW_STATUSES.items()
|
|
269
|
+
):
|
|
270
|
+
active.append(record)
|
|
271
|
+
return active
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@cache
|
|
275
|
+
def _report_session(domain: str, task_id: str) -> boto3.Session:
|
|
276
|
+
"""Return the task-scoped AWS session used for one report review."""
|
|
277
|
+
account_id = require_report_reviewer_identity()
|
|
278
|
+
role_arn = f"arn:aws:iam::{account_id}:role/{REPORT_SESSION_ROLE}"
|
|
279
|
+
bucket_arn = f"arn:aws:s3:::{artifacts_bucket_name(domain_id(domain), account_id)}"
|
|
280
|
+
renderer_arn = f"arn:aws:lambda:{_aws_region()}:{account_id}:function:hound-report-renderer"
|
|
281
|
+
|
|
282
|
+
session_policy = json.dumps(
|
|
283
|
+
{
|
|
284
|
+
"Version": "2012-10-17",
|
|
285
|
+
"Statement": [
|
|
286
|
+
{
|
|
287
|
+
"Effect": "Allow",
|
|
288
|
+
"Action": "s3:ListBucket",
|
|
289
|
+
"Resource": bucket_arn,
|
|
290
|
+
"Condition": {
|
|
291
|
+
"StringLike": {
|
|
292
|
+
"s3:prefix": [
|
|
293
|
+
f"{task_id}/",
|
|
294
|
+
f"{task_id}/*",
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"Effect": "Allow",
|
|
301
|
+
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
|
|
302
|
+
"Resource": f"{bucket_arn}/{task_id}/*",
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
"Effect": "Allow",
|
|
306
|
+
"Action": "lambda:InvokeFunction",
|
|
307
|
+
"Resource": renderer_arn,
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
creds = aws_client("sts").assume_role(
|
|
314
|
+
RoleArn=role_arn,
|
|
315
|
+
RoleSessionName=f"report-review-{domain_id(domain)}",
|
|
316
|
+
Policy=session_policy,
|
|
317
|
+
Tags=[
|
|
318
|
+
{"Key": "domain_id", "Value": domain_id(domain)},
|
|
319
|
+
{"Key": "task_id", "Value": task_id},
|
|
320
|
+
{"Key": "target_domain", "Value": domain},
|
|
321
|
+
],
|
|
322
|
+
DurationSeconds=900,
|
|
323
|
+
)["Credentials"]
|
|
324
|
+
|
|
325
|
+
return boto3.Session(
|
|
326
|
+
region_name=_aws_region(),
|
|
327
|
+
aws_access_key_id=creds["AccessKeyId"],
|
|
328
|
+
aws_secret_access_key=creds["SecretAccessKey"],
|
|
329
|
+
aws_session_token=creds["SessionToken"],
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def report_client(service: str, domain: str, task_id: str, *, config=None):
|
|
334
|
+
"""Create an AWS client inside the report review's security boundary."""
|
|
335
|
+
return _report_session(domain, task_id).client(service, region_name=_aws_region(), config=config)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def report_s3_client(domain: str, task_id: str):
|
|
339
|
+
"""Create the task-scoped S3 client for a customer report review."""
|
|
340
|
+
return report_client("s3", domain, task_id)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Minimal service-boundary values needed by the public CLI."""
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Target identity and DNS verification values used by the public CLI."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import urllib.parse
|
|
6
|
+
|
|
7
|
+
import dns.exception
|
|
8
|
+
import dns.resolver
|
|
9
|
+
import idna
|
|
10
|
+
from publicsuffixlist import PublicSuffixList
|
|
11
|
+
|
|
12
|
+
_TXT_PREFIX = "hound-verification="
|
|
13
|
+
_icann_psl = PublicSuffixList(only_icann=True)
|
|
14
|
+
_resolver = dns.resolver.Resolver()
|
|
15
|
+
_resolver.timeout = 1.5
|
|
16
|
+
_resolver.lifetime = 2.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VerificationUnavailable(RuntimeError):
|
|
20
|
+
"""DNS could not determine the target's verification state."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _extract_hostname(raw: str) -> str:
|
|
24
|
+
if not raw:
|
|
25
|
+
raise ValueError("Empty target")
|
|
26
|
+
|
|
27
|
+
value = raw.strip()
|
|
28
|
+
parsed = urllib.parse.urlparse(value if "://" in value or value.startswith("//") else f"//{value}")
|
|
29
|
+
try:
|
|
30
|
+
_ = parsed.port
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise ValueError("Invalid target") from exc
|
|
33
|
+
|
|
34
|
+
hostname = (parsed.hostname or "").strip().rstrip(".")
|
|
35
|
+
if not hostname:
|
|
36
|
+
raise ValueError("Invalid target")
|
|
37
|
+
return hostname
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def normalize_domain(raw: str) -> str:
|
|
41
|
+
"""Return the canonical DNS name or IPv4 workflow key for a target."""
|
|
42
|
+
hostname = _extract_hostname(raw)
|
|
43
|
+
try:
|
|
44
|
+
address = ipaddress.ip_address(hostname)
|
|
45
|
+
except ValueError:
|
|
46
|
+
address = None
|
|
47
|
+
if address is not None:
|
|
48
|
+
if address.version != 4:
|
|
49
|
+
raise ValueError("IPv6 targets are not supported")
|
|
50
|
+
return str(address)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
domain = idna.encode(hostname, uts46=True, std3_rules=True).decode("ascii").lower()
|
|
54
|
+
except idna.IDNAError as exc:
|
|
55
|
+
raise ValueError("Invalid domain") from exc
|
|
56
|
+
if "." not in domain or not _icann_psl.privatesuffix(domain):
|
|
57
|
+
raise ValueError("Invalid domain")
|
|
58
|
+
return domain
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_ip_target(target: str) -> bool:
|
|
62
|
+
"""Return whether a canonical target is an IPv4 address."""
|
|
63
|
+
try:
|
|
64
|
+
return ipaddress.ip_address(target).version == 4
|
|
65
|
+
except ValueError:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def domain_id(domain: str) -> str:
|
|
70
|
+
"""Return the stable artifact-routing identifier for a target."""
|
|
71
|
+
return hashlib.sha256(normalize_domain(domain).encode()).hexdigest()[:20]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def derive_root_domain(domain: str) -> str:
|
|
75
|
+
"""Return the DNS fallback location for a canonical domain target."""
|
|
76
|
+
root_domain = _icann_psl.privatesuffix(domain)
|
|
77
|
+
if not root_domain:
|
|
78
|
+
raise ValueError(f"Invalid domain: {domain}")
|
|
79
|
+
return root_domain
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def verification_hash(account_id: str, domain: str) -> str:
|
|
83
|
+
"""Return the account-scoped verification hash for a target domain."""
|
|
84
|
+
if len(account_id) != 12 or not account_id.isdigit():
|
|
85
|
+
raise ValueError("account_id must be a 12-digit AWS account ID")
|
|
86
|
+
canonical_domain = normalize_domain(domain)
|
|
87
|
+
return hashlib.sha256(f"{account_id}:{canonical_domain}".encode()).hexdigest()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verification_txt_value(expected_hash: str) -> str:
|
|
91
|
+
"""Return the exact TXT value Hound expects in DNS."""
|
|
92
|
+
return f"{_TXT_PREFIX}{expected_hash}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def verification_record_names(domain: str) -> list[str]:
|
|
96
|
+
"""Return the DNS names where Hound accepts the verification record."""
|
|
97
|
+
domain = normalize_domain(domain)
|
|
98
|
+
if is_ip_target(domain):
|
|
99
|
+
return []
|
|
100
|
+
root_domain = derive_root_domain(domain)
|
|
101
|
+
return [domain] if root_domain == domain else [domain, root_domain]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _txt_value(rdata) -> str:
|
|
105
|
+
strings = getattr(rdata, "strings", None)
|
|
106
|
+
if strings is not None:
|
|
107
|
+
return "".join(part.decode() for part in strings)
|
|
108
|
+
return rdata.to_text().strip().strip('"').strip()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def verification_txt_present(domain: str, expected_hash: str) -> bool:
|
|
112
|
+
"""Return whether the expected account-scoped TXT value is present."""
|
|
113
|
+
expected = verification_txt_value(expected_hash)
|
|
114
|
+
lookup_error: VerificationUnavailable | None = None
|
|
115
|
+
|
|
116
|
+
for location in verification_record_names(domain):
|
|
117
|
+
try:
|
|
118
|
+
answers = _resolver.resolve(location, "TXT")
|
|
119
|
+
except dns.resolver.NXDOMAIN, dns.resolver.NoAnswer:
|
|
120
|
+
continue
|
|
121
|
+
except (dns.exception.Timeout, dns.resolver.NoNameservers) as exc:
|
|
122
|
+
lookup_error = VerificationUnavailable(str(exc))
|
|
123
|
+
continue
|
|
124
|
+
if any(_txt_value(rdata) == expected for rdata in answers):
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
if lookup_error:
|
|
128
|
+
raise lookup_error
|
|
129
|
+
return False
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Workflow values the public CLI must send or interpret."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
SKIP_DNS_CHECK_FIELD = "skip_dns_check"
|
|
7
|
+
|
|
8
|
+
ENGAGEMENT_RELAUNCH_STATUSES = ("cancelled", "failed")
|
|
9
|
+
RETEST_LAUNCH_STATUSES = ("completed", "failed", "cancelled")
|
|
10
|
+
ACTIVE_INFRA_STATUSES = frozenset(
|
|
11
|
+
{
|
|
12
|
+
"provisioning",
|
|
13
|
+
"provisioned",
|
|
14
|
+
"destroying",
|
|
15
|
+
"provisioning_failed",
|
|
16
|
+
"destroy_failed",
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
ACTIVE_WORKFLOW_STATUSES = {
|
|
21
|
+
"engagement_status": frozenset({"queued", "running"}),
|
|
22
|
+
"retest_status": frozenset({"queued", "running"}),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RuntimeConfig:
|
|
28
|
+
"""One workflow run's complete effective runtime selection."""
|
|
29
|
+
|
|
30
|
+
eip_allocation_id: str | None
|
|
31
|
+
residential_proxy_enabled: bool
|
|
32
|
+
relay_enabled: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def runtime_config_from_record(record: Mapping[str, object]) -> RuntimeConfig:
|
|
36
|
+
"""Read the runtime fields displayed or reused by the public CLI."""
|
|
37
|
+
attribute = record.get("runtime")
|
|
38
|
+
if not isinstance(attribute, Mapping) or not isinstance(attribute.get("M"), Mapping):
|
|
39
|
+
raise ValueError("Workflow record runtime must be a DynamoDB map")
|
|
40
|
+
|
|
41
|
+
runtime = attribute["M"]
|
|
42
|
+
booleans: dict[str, bool] = {}
|
|
43
|
+
for field in ("relay_enabled", "residential_proxy_enabled"):
|
|
44
|
+
value = runtime.get(field)
|
|
45
|
+
if not isinstance(value, Mapping) or not isinstance(value.get("BOOL"), bool):
|
|
46
|
+
raise ValueError(f"Workflow record runtime.{field} must be a DynamoDB boolean")
|
|
47
|
+
booleans[field] = value["BOOL"]
|
|
48
|
+
|
|
49
|
+
eip_allocation_id = None
|
|
50
|
+
if "eip_allocation_id" in runtime:
|
|
51
|
+
value = runtime["eip_allocation_id"]
|
|
52
|
+
if not isinstance(value, Mapping) or not isinstance(value.get("S"), str):
|
|
53
|
+
raise ValueError("Workflow record runtime.eip_allocation_id must be a DynamoDB string")
|
|
54
|
+
eip_allocation_id = value["S"]
|
|
55
|
+
|
|
56
|
+
return RuntimeConfig(
|
|
57
|
+
eip_allocation_id=eip_allocation_id,
|
|
58
|
+
residential_proxy_enabled=booleans["residential_proxy_enabled"],
|
|
59
|
+
relay_enabled=booleans["relay_enabled"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def runtime_launch_value(runtime: RuntimeConfig) -> dict[str, object]:
|
|
64
|
+
"""Encode a runtime selection for the public launch message."""
|
|
65
|
+
value: dict[str, object] = {
|
|
66
|
+
"residential_proxy_enabled": runtime.residential_proxy_enabled,
|
|
67
|
+
"relay_enabled": runtime.relay_enabled,
|
|
68
|
+
}
|
|
69
|
+
if runtime.eip_allocation_id is not None:
|
|
70
|
+
value["eip_allocation_id"] = runtime.eip_allocation_id
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def artifacts_bucket_name(domain_id: str, account_id: str) -> str:
|
|
75
|
+
"""Return the account-local artifact bucket for a target-domain identity."""
|
|
76
|
+
return f"hound-artifacts-{domain_id}-{account_id}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent inbox commands."""
|