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,115 @@
|
|
|
1
|
+
"""Upload reviewed task artifacts to S3 and securely wipe local copies.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
hound reports close
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import mimetypes
|
|
9
|
+
import os
|
|
10
|
+
import secrets as secrets_module
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
from boto3.exceptions import S3UploadFailedError
|
|
15
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
16
|
+
|
|
17
|
+
from hound_cli.aws import report_reviewer_only, report_s3_client, short_task_id
|
|
18
|
+
from hound_cli.contracts.domain import domain_id
|
|
19
|
+
from hound_cli.contracts.workflow import artifacts_bucket_name
|
|
20
|
+
from hound_cli.reports.reviews import select_open_review
|
|
21
|
+
|
|
22
|
+
LOCAL_REVIEW_STATE = (".git", ".hound-local")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _secure_wipe_file(path: Path) -> None:
|
|
26
|
+
"""Overwrite a file with random bytes before deleting.
|
|
27
|
+
|
|
28
|
+
Best-effort on SSDs — wear leveling means old data may persist in
|
|
29
|
+
flash cells. Full-disk encryption is the real protection.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
size = path.stat().st_size
|
|
33
|
+
if size > 0:
|
|
34
|
+
with open(path, "r+b") as f:
|
|
35
|
+
remaining = size
|
|
36
|
+
while remaining:
|
|
37
|
+
chunk_size = min(remaining, 1024 * 1024)
|
|
38
|
+
f.write(secrets_module.token_bytes(chunk_size))
|
|
39
|
+
remaining -= chunk_size
|
|
40
|
+
f.flush()
|
|
41
|
+
os.fsync(f.fileno())
|
|
42
|
+
path.unlink()
|
|
43
|
+
except OSError:
|
|
44
|
+
path.unlink(missing_ok=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _secure_wipe_directory(directory: Path) -> None:
|
|
48
|
+
"""Recursively overwrite and delete all files, then remove directories."""
|
|
49
|
+
if directory.is_symlink():
|
|
50
|
+
directory.unlink()
|
|
51
|
+
return
|
|
52
|
+
paths = sorted(directory.rglob("*"), key=lambda path: len(path.parts), reverse=True)
|
|
53
|
+
for path in paths:
|
|
54
|
+
if path.is_file() or path.is_symlink():
|
|
55
|
+
if path.is_symlink():
|
|
56
|
+
path.unlink()
|
|
57
|
+
else:
|
|
58
|
+
_secure_wipe_file(path)
|
|
59
|
+
for path in paths:
|
|
60
|
+
if path.is_dir():
|
|
61
|
+
with contextlib.suppress(OSError):
|
|
62
|
+
path.rmdir()
|
|
63
|
+
with contextlib.suppress(OSError):
|
|
64
|
+
directory.rmdir()
|
|
65
|
+
if directory.exists() or directory.is_symlink():
|
|
66
|
+
raise OSError(f"Secure wipe did not remove directory completely: {directory}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _upload_review(review_path: Path, bucket: str, task_id: str, s3) -> None:
|
|
70
|
+
"""Upload local task files without removing existing S3 objects."""
|
|
71
|
+
|
|
72
|
+
def walk_error(error: OSError) -> None:
|
|
73
|
+
raise error
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
for directory, directories, filenames in review_path.walk(on_error=walk_error):
|
|
77
|
+
if directory == review_path:
|
|
78
|
+
directories[:] = [name for name in directories if name not in LOCAL_REVIEW_STATE]
|
|
79
|
+
for filename in filenames:
|
|
80
|
+
path = directory / filename
|
|
81
|
+
if path.is_symlink():
|
|
82
|
+
continue
|
|
83
|
+
key = f"{task_id}/{path.relative_to(review_path).as_posix()}"
|
|
84
|
+
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
85
|
+
s3.upload_file(str(path), bucket, key, ExtraArgs={"ContentType": content_type})
|
|
86
|
+
except (OSError, BotoCoreError, ClientError, S3UploadFailedError) as exc:
|
|
87
|
+
raise click.ClickException(
|
|
88
|
+
f"Could not upload the report review to S3: {exc} Local files were preserved."
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@click.command(name="close")
|
|
93
|
+
@report_reviewer_only
|
|
94
|
+
def close_report():
|
|
95
|
+
"""Upload one reviewed task to S3 and wipe its local copy."""
|
|
96
|
+
account_id, domain, task_id, review_path = select_open_review("close")
|
|
97
|
+
|
|
98
|
+
bucket = artifacts_bucket_name(domain_id(domain), account_id)
|
|
99
|
+
|
|
100
|
+
click.echo(f"\n Account: {account_id}")
|
|
101
|
+
click.echo(f" Domain: {domain}")
|
|
102
|
+
click.echo(f" Task: {short_task_id(task_id)}")
|
|
103
|
+
click.echo(f" Prefix: s3://{bucket}/{task_id}/")
|
|
104
|
+
click.confirm("\nUpload and wipe?", default=None, abort=True)
|
|
105
|
+
|
|
106
|
+
_upload_review(review_path, bucket, task_id, report_s3_client(domain, task_id))
|
|
107
|
+
click.echo(" Uploaded local task to S3")
|
|
108
|
+
|
|
109
|
+
click.echo(" Wiping local files...")
|
|
110
|
+
_secure_wipe_directory(review_path)
|
|
111
|
+
for directory in (review_path.parent, review_path.parent.parent):
|
|
112
|
+
if directory.exists() and not any(directory.iterdir()):
|
|
113
|
+
directory.rmdir()
|
|
114
|
+
|
|
115
|
+
click.echo(f"\n Closed and wiped {domain} / {short_task_id(task_id)}")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Layout and filesystem rules for local report reviews."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
PENTEST_REPORT = "pentest"
|
|
9
|
+
RETEST_REPORT = "retest"
|
|
10
|
+
CUSTOMER_REPORT_SOURCE_PATHS = {
|
|
11
|
+
PENTEST_REPORT: {
|
|
12
|
+
"executive_summary.md": Path("engagement/artifacts/debriefer/output/executive_summary.md"),
|
|
13
|
+
"technical_report.md": Path("engagement/artifacts/debriefer/output/technical_report.md"),
|
|
14
|
+
"hardening_report.md": Path("engagement/artifacts/debriefer/output/hardening_report.md"),
|
|
15
|
+
"coverage_summary.md": Path("engagement/artifacts/debriefer/output/coverage_summary.md"),
|
|
16
|
+
},
|
|
17
|
+
RETEST_REPORT: {
|
|
18
|
+
"retest_report.md": Path("retest/artifacts/retester/retest_report.md"),
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
CUSTOMER_REPORT_TYPES = tuple(CUSTOMER_REPORT_SOURCE_PATHS)
|
|
22
|
+
CUSTOMER_DELIVERABLE_DIR = Path("deliverables")
|
|
23
|
+
CUSTOMER_PDF_FILENAME_SUFFIXES = {
|
|
24
|
+
PENTEST_REPORT: "_Pentest_Report.pdf",
|
|
25
|
+
RETEST_REPORT: "_Pentest_Retest_Report.pdf",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReportFileError(ValueError):
|
|
30
|
+
"""A local report path or filename is unsafe or unusable."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def assert_confined_path(root: Path, path: Path, label: str) -> None:
|
|
34
|
+
"""Reject paths that escape root or traverse any symlink component."""
|
|
35
|
+
try:
|
|
36
|
+
relative = path.relative_to(root)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise ReportFileError(f"{label} is outside its required directory: {path}") from exc
|
|
39
|
+
|
|
40
|
+
current = root
|
|
41
|
+
if current.is_symlink():
|
|
42
|
+
raise ReportFileError(f"{label} must not traverse symlinks: {current}")
|
|
43
|
+
for component in relative.parts:
|
|
44
|
+
current /= component
|
|
45
|
+
if current.is_symlink():
|
|
46
|
+
raise ReportFileError(f"{label} must not traverse symlinks: {current}")
|
|
47
|
+
|
|
48
|
+
root_resolved = root.resolve(strict=False)
|
|
49
|
+
if not path.resolve(strict=False).is_relative_to(root_resolved):
|
|
50
|
+
raise ReportFileError(f"{label} escapes its required directory: {path}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_customer_report_sources(review_path: Path, report_type: str) -> dict[str, bytes]:
|
|
54
|
+
"""Read the known source files that exist for one report package."""
|
|
55
|
+
sources: dict[str, bytes] = {}
|
|
56
|
+
for name, relative in CUSTOMER_REPORT_SOURCE_PATHS[report_type].items():
|
|
57
|
+
path = review_path / relative
|
|
58
|
+
assert_confined_path(review_path, path, "Customer report source")
|
|
59
|
+
if path.is_file():
|
|
60
|
+
sources[name] = path.read_bytes()
|
|
61
|
+
return sources
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def customer_pdf_relative_path(report_type: str, customer_name: str) -> Path:
|
|
65
|
+
"""Return the safe customer-facing PDF path for one review."""
|
|
66
|
+
filename_name = re.sub(r"[\W_]+", "_", customer_name.strip()).strip("_")
|
|
67
|
+
if not filename_name:
|
|
68
|
+
raise ReportFileError("Customer name must contain a letter or number for the PDF filename.")
|
|
69
|
+
return CUSTOMER_DELIVERABLE_DIR / f"{filename_name}{CUSTOMER_PDF_FILENAME_SUFFIXES[report_type]}"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Pull task-scoped customer-facing artifacts for local review.
|
|
2
|
+
|
|
3
|
+
Downloads the full artifact tree and logs from S3 into
|
|
4
|
+
reports/{account_id}/{domain}/{task_id}/, preserving S3 keys relative to the
|
|
5
|
+
task prefix.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
hound reports open
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import questionary
|
|
15
|
+
|
|
16
|
+
from hound_cli.aws import (
|
|
17
|
+
_aws_account_id,
|
|
18
|
+
aws_client,
|
|
19
|
+
get_engagement_records,
|
|
20
|
+
get_workflow_record,
|
|
21
|
+
report_reviewer_only,
|
|
22
|
+
report_s3_client,
|
|
23
|
+
short_task_id,
|
|
24
|
+
)
|
|
25
|
+
from hound_cli.contracts.domain import domain_id
|
|
26
|
+
from hound_cli.contracts.workflow import artifacts_bucket_name
|
|
27
|
+
from hound_cli.reports.files import ReportFileError, assert_confined_path
|
|
28
|
+
from hound_cli.reports.reviews import customer_report_review_dir
|
|
29
|
+
from hound_cli.ui import MENU_STYLE
|
|
30
|
+
from hound_cli.workflows.selection import canonical_task_id, selected_domain
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _download_s3_prefix(s3, bucket: str, prefix: str, local_dir: Path) -> int:
|
|
34
|
+
"""Download all objects under an S3 prefix as-is.
|
|
35
|
+
|
|
36
|
+
Returns the count of files downloaded.
|
|
37
|
+
"""
|
|
38
|
+
paginator = s3.get_paginator("list_objects_v2")
|
|
39
|
+
count = 0
|
|
40
|
+
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
|
41
|
+
for obj in page.get("Contents", []):
|
|
42
|
+
key = obj["Key"]
|
|
43
|
+
relative = key[len(prefix) :]
|
|
44
|
+
if not relative:
|
|
45
|
+
continue
|
|
46
|
+
if Path(relative).parts[0] in {".git", ".hound-local"}:
|
|
47
|
+
raise click.ClickException("Downloaded report artifacts must not contain local review state.")
|
|
48
|
+
local_path = local_dir / relative
|
|
49
|
+
try:
|
|
50
|
+
assert_confined_path(local_dir, local_path, "Downloaded report artifact")
|
|
51
|
+
except ReportFileError as exc:
|
|
52
|
+
raise click.ClickException(str(exc)) from exc
|
|
53
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
s3.download_file(bucket, key, str(local_path))
|
|
55
|
+
count += 1
|
|
56
|
+
return count
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@click.command(name="open")
|
|
60
|
+
@click.option("--domain", help="Use this exact target domain.")
|
|
61
|
+
@click.option("--task-id", help="Use one exact task; requires --domain.")
|
|
62
|
+
@report_reviewer_only
|
|
63
|
+
def open_report(domain: str | None, task_id: str | None):
|
|
64
|
+
"""Pull task artifacts for local review."""
|
|
65
|
+
if task_id is not None and domain is None:
|
|
66
|
+
raise click.UsageError("--task-id requires --domain")
|
|
67
|
+
domain = selected_domain(domain)
|
|
68
|
+
account_id = _aws_account_id()
|
|
69
|
+
bucket = artifacts_bucket_name(domain_id(domain), account_id)
|
|
70
|
+
|
|
71
|
+
ddb = aws_client("dynamodb")
|
|
72
|
+
if task_id is not None:
|
|
73
|
+
task_id = canonical_task_id(task_id)
|
|
74
|
+
record = get_workflow_record(ddb, domain, task_id)
|
|
75
|
+
task_ids = [task_id] if record else []
|
|
76
|
+
else:
|
|
77
|
+
task_ids = sorted(
|
|
78
|
+
(record["task_id"]["S"] for record in get_engagement_records(ddb, domain)),
|
|
79
|
+
reverse=True,
|
|
80
|
+
)
|
|
81
|
+
if not task_ids:
|
|
82
|
+
raise click.ClickException(f"No task records found for {domain}")
|
|
83
|
+
if len(task_ids) == 1:
|
|
84
|
+
task_id = task_ids[0]
|
|
85
|
+
else:
|
|
86
|
+
choices = [short_task_id(candidate) for candidate in task_ids]
|
|
87
|
+
selected = questionary.select("Task ID", choices=choices, style=MENU_STYLE).unsafe_ask()
|
|
88
|
+
if selected is None:
|
|
89
|
+
raise click.Abort()
|
|
90
|
+
task_id = task_ids[choices.index(selected)]
|
|
91
|
+
|
|
92
|
+
prefix = f"{task_id}/"
|
|
93
|
+
try:
|
|
94
|
+
review_path = customer_report_review_dir(account_id, domain, task_id)
|
|
95
|
+
except ValueError as exc:
|
|
96
|
+
raise click.ClickException(str(exc)) from exc
|
|
97
|
+
|
|
98
|
+
if review_path.exists():
|
|
99
|
+
if not review_path.is_dir() or review_path.is_symlink():
|
|
100
|
+
raise click.ClickException(f"Review path is not a local directory: {review_path}")
|
|
101
|
+
click.echo(f"\n Review already open at {review_path}")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
click.echo(f"\n Pulling s3://{bucket}/{prefix}")
|
|
105
|
+
click.echo(f" To: {review_path}")
|
|
106
|
+
click.confirm("\nProceed?", default=True, abort=True)
|
|
107
|
+
|
|
108
|
+
s3 = report_s3_client(domain, task_id=task_id)
|
|
109
|
+
review_path.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
file_count = _download_s3_prefix(s3, bucket, prefix, review_path)
|
|
111
|
+
click.echo(f" Downloaded {file_count} files")
|
|
112
|
+
|
|
113
|
+
click.echo(f"\nReview open at {review_path}")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Render reviewed customer reports through the restricted AWS renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import questionary
|
|
11
|
+
|
|
12
|
+
from hound_cli.aws import report_reviewer_only, short_task_id
|
|
13
|
+
from hound_cli.reports.files import (
|
|
14
|
+
CUSTOMER_REPORT_SOURCE_PATHS,
|
|
15
|
+
CUSTOMER_REPORT_TYPES,
|
|
16
|
+
PENTEST_REPORT,
|
|
17
|
+
RETEST_REPORT,
|
|
18
|
+
ReportFileError,
|
|
19
|
+
assert_confined_path,
|
|
20
|
+
customer_pdf_relative_path,
|
|
21
|
+
read_customer_report_sources,
|
|
22
|
+
)
|
|
23
|
+
from hound_cli.reports.reviews import select_open_review
|
|
24
|
+
from hound_cli.reports.service import ReportServiceError, render_customer_report
|
|
25
|
+
from hound_cli.ui import MENU_STYLE
|
|
26
|
+
|
|
27
|
+
REPORT_TYPE_LABELS = {
|
|
28
|
+
PENTEST_REPORT: "Penetration test report",
|
|
29
|
+
RETEST_REPORT: "Penetration test retest report",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _available_report_types(review_path: Path) -> list[str]:
|
|
34
|
+
"""Return report packages represented in the local review."""
|
|
35
|
+
available: list[str] = []
|
|
36
|
+
for report_type in CUSTOMER_REPORT_TYPES:
|
|
37
|
+
found_source = False
|
|
38
|
+
for relative in CUSTOMER_REPORT_SOURCE_PATHS[report_type].values():
|
|
39
|
+
path = review_path / relative
|
|
40
|
+
assert_confined_path(review_path, path, "Customer report source")
|
|
41
|
+
found_source = found_source or path.is_file()
|
|
42
|
+
if found_source:
|
|
43
|
+
available.append(report_type)
|
|
44
|
+
return available
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _select_report_type(review_path: Path) -> str:
|
|
48
|
+
"""Select a report type only when the review contains more than one."""
|
|
49
|
+
available = _available_report_types(review_path)
|
|
50
|
+
if not available:
|
|
51
|
+
raise ReportFileError("No penetration test or retest report sources were found in this review.")
|
|
52
|
+
if len(available) == 1:
|
|
53
|
+
return available[0]
|
|
54
|
+
|
|
55
|
+
choices = [
|
|
56
|
+
questionary.Choice(title=REPORT_TYPE_LABELS[report_type], value=report_type) for report_type in available
|
|
57
|
+
]
|
|
58
|
+
selected = questionary.select("Report to render", choices=choices, style=MENU_STYLE).unsafe_ask()
|
|
59
|
+
if selected is None:
|
|
60
|
+
raise click.Abort()
|
|
61
|
+
return selected
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _publish_pdf(review_path: Path, report_type: str, customer_name: str, pdf_bytes: bytes) -> Path:
|
|
65
|
+
output = review_path / customer_pdf_relative_path(report_type, customer_name)
|
|
66
|
+
assert_confined_path(review_path, output, "Customer PDF")
|
|
67
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
temporary_path: Path | None = None
|
|
69
|
+
try:
|
|
70
|
+
with tempfile.NamedTemporaryFile(
|
|
71
|
+
mode="wb",
|
|
72
|
+
dir=output.parent,
|
|
73
|
+
prefix=f".{output.name}.",
|
|
74
|
+
suffix=".tmp",
|
|
75
|
+
delete=False,
|
|
76
|
+
) as temporary:
|
|
77
|
+
temporary.write(pdf_bytes)
|
|
78
|
+
temporary.flush()
|
|
79
|
+
os.fsync(temporary.fileno())
|
|
80
|
+
temporary_path = Path(temporary.name)
|
|
81
|
+
os.replace(temporary_path, output)
|
|
82
|
+
finally:
|
|
83
|
+
if temporary_path is not None:
|
|
84
|
+
temporary_path.unlink(missing_ok=True)
|
|
85
|
+
return output
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@click.command(name="render")
|
|
89
|
+
@click.option("--customer-name", help="Customer or application name shown on the cover.")
|
|
90
|
+
@report_reviewer_only
|
|
91
|
+
def render_report(customer_name: str | None) -> None:
|
|
92
|
+
"""Render one reviewed customer report package as a PDF."""
|
|
93
|
+
account_id, domain, task_id, review_path = select_open_review("render")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
report_type = _select_report_type(review_path)
|
|
97
|
+
if customer_name is None:
|
|
98
|
+
default_name = domain.split(".", 1)[0].replace("-", " ").title()
|
|
99
|
+
customer_name = click.prompt("Customer name", default=default_name)
|
|
100
|
+
|
|
101
|
+
sources = read_customer_report_sources(review_path, report_type)
|
|
102
|
+
pdf_bytes = render_customer_report(
|
|
103
|
+
domain,
|
|
104
|
+
task_id,
|
|
105
|
+
report_type,
|
|
106
|
+
customer_name,
|
|
107
|
+
sources,
|
|
108
|
+
)
|
|
109
|
+
output = _publish_pdf(review_path, report_type, customer_name, pdf_bytes)
|
|
110
|
+
except (ReportFileError, ReportServiceError) as exc:
|
|
111
|
+
raise click.ClickException(str(exc)) from exc
|
|
112
|
+
|
|
113
|
+
click.echo(f"\n Account: {account_id}")
|
|
114
|
+
click.echo(f" Domain: {domain}")
|
|
115
|
+
click.echo(f" Task: {short_task_id(task_id)}")
|
|
116
|
+
click.echo(f"\nRendered customer PDF:\n {output}")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Shared discovery and selection for local customer report reviews."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import questionary
|
|
9
|
+
|
|
10
|
+
from hound_cli.aws import _aws_account_id, short_task_id
|
|
11
|
+
from hound_cli.contracts.domain import normalize_domain
|
|
12
|
+
from hound_cli.reports.files import ReportFileError, assert_confined_path
|
|
13
|
+
from hound_cli.ui import MENU_STYLE
|
|
14
|
+
|
|
15
|
+
OpenReview = tuple[str, str, str, Path]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def customer_reports_dir() -> Path:
|
|
19
|
+
"""Return the explicit override or the normal per-user report root."""
|
|
20
|
+
configured = os.environ.get("HOUND_REPORTS_DIR")
|
|
21
|
+
if configured:
|
|
22
|
+
root = Path(configured)
|
|
23
|
+
if not root.is_absolute():
|
|
24
|
+
raise click.ClickException("HOUND_REPORTS_DIR must be an absolute path")
|
|
25
|
+
return root
|
|
26
|
+
return Path.home() / "Hound" / "reports"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def customer_report_review_dir(account_id: str, domain: str, task_id: str) -> Path:
|
|
30
|
+
"""Return the only permitted local directory for one customer review."""
|
|
31
|
+
if len(account_id) != 12 or not account_id.isdigit():
|
|
32
|
+
raise ValueError(f"Invalid AWS account ID: {account_id}")
|
|
33
|
+
if normalize_domain(domain) != domain:
|
|
34
|
+
raise ValueError(f"Customer report domain is not normalized: {domain}")
|
|
35
|
+
try:
|
|
36
|
+
parsed_task_id = uuid.UUID(task_id)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise ValueError(f"Customer report task ID is not a UUID: {task_id}") from exc
|
|
39
|
+
if str(parsed_task_id) != task_id:
|
|
40
|
+
raise ValueError(f"Customer report task ID is not canonical: {task_id}")
|
|
41
|
+
|
|
42
|
+
root = customer_reports_dir()
|
|
43
|
+
review_path = root / account_id / domain / task_id
|
|
44
|
+
assert_confined_path(root, review_path, "Customer report storage path")
|
|
45
|
+
return review_path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_open_reviews(account_id: str | None = None) -> list[OpenReview]:
|
|
49
|
+
"""Return valid local reviews, optionally for one account, newest first."""
|
|
50
|
+
base = customer_reports_dir()
|
|
51
|
+
if not base.exists():
|
|
52
|
+
return []
|
|
53
|
+
try:
|
|
54
|
+
assert_confined_path(base, base, "Customer reports directory")
|
|
55
|
+
except ReportFileError as exc:
|
|
56
|
+
raise click.ClickException(str(exc)) from exc
|
|
57
|
+
|
|
58
|
+
results: list[OpenReview] = []
|
|
59
|
+
for account_dir in sorted(base.iterdir()):
|
|
60
|
+
if account_id is not None and account_dir.name != account_id:
|
|
61
|
+
continue
|
|
62
|
+
if len(account_dir.name) == 12 and account_dir.name.isdigit():
|
|
63
|
+
try:
|
|
64
|
+
assert_confined_path(base, account_dir, "Customer report account directory")
|
|
65
|
+
except ReportFileError as exc:
|
|
66
|
+
raise click.ClickException(str(exc)) from exc
|
|
67
|
+
if not account_dir.is_dir() or len(account_dir.name) != 12 or not account_dir.name.isdigit():
|
|
68
|
+
continue
|
|
69
|
+
for domain_dir in sorted(account_dir.iterdir()):
|
|
70
|
+
if not domain_dir.name.startswith("."):
|
|
71
|
+
try:
|
|
72
|
+
assert_confined_path(base, domain_dir, "Customer report domain directory")
|
|
73
|
+
except ReportFileError as exc:
|
|
74
|
+
raise click.ClickException(str(exc)) from exc
|
|
75
|
+
if not domain_dir.is_dir() or domain_dir.name.startswith("."):
|
|
76
|
+
continue
|
|
77
|
+
for task_dir in sorted(domain_dir.iterdir()):
|
|
78
|
+
if not task_dir.name.startswith("."):
|
|
79
|
+
try:
|
|
80
|
+
assert_confined_path(base, task_dir, "Customer report task directory")
|
|
81
|
+
except ReportFileError as exc:
|
|
82
|
+
raise click.ClickException(str(exc)) from exc
|
|
83
|
+
if not task_dir.is_dir() or task_dir.name.startswith("."):
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
expected = customer_report_review_dir(
|
|
87
|
+
account_dir.name,
|
|
88
|
+
domain_dir.name,
|
|
89
|
+
task_dir.name,
|
|
90
|
+
)
|
|
91
|
+
except ValueError as exc:
|
|
92
|
+
raise click.ClickException(str(exc)) from exc
|
|
93
|
+
if task_dir != expected:
|
|
94
|
+
raise click.ClickException(f"Customer report task is outside its required directory: {task_dir}")
|
|
95
|
+
results.append(
|
|
96
|
+
(
|
|
97
|
+
account_dir.name,
|
|
98
|
+
domain_dir.name,
|
|
99
|
+
task_dir.name,
|
|
100
|
+
task_dir,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
results.sort(key=lambda review: review[2], reverse=True)
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def select_open_review(action: str) -> OpenReview:
|
|
108
|
+
"""Select one local review for an operator action."""
|
|
109
|
+
account_id = _aws_account_id()
|
|
110
|
+
reviews = find_open_reviews(account_id)
|
|
111
|
+
if not reviews:
|
|
112
|
+
raise click.ClickException(
|
|
113
|
+
f"No open reviews for active AWS account {account_id}. Run 'hound reports open' first."
|
|
114
|
+
)
|
|
115
|
+
if len(reviews) == 1:
|
|
116
|
+
return reviews[0]
|
|
117
|
+
|
|
118
|
+
choices = [f"{domain} / {short_task_id(task_id)}" for _, domain, task_id, _ in reviews]
|
|
119
|
+
selected = questionary.select(f"Review to {action}", choices=choices, style=MENU_STYLE).unsafe_ask()
|
|
120
|
+
return reviews[choices.index(selected)]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Direct client for the account-local customer report renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from botocore.config import Config
|
|
10
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
11
|
+
|
|
12
|
+
from hound_cli.aws import report_client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReportServiceError(RuntimeError):
|
|
16
|
+
"""The renderer could not complete the requested operation."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
REPORT_RENDERER_FUNCTION_NAME = "hound-report-renderer"
|
|
20
|
+
RENDERER_CLIENT_CONFIG = Config(read_timeout=150)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _invoke_renderer(
|
|
24
|
+
domain: str,
|
|
25
|
+
task_id: str,
|
|
26
|
+
request: dict[str, object],
|
|
27
|
+
) -> dict[str, object]:
|
|
28
|
+
payload = json.dumps(request, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
|
29
|
+
try:
|
|
30
|
+
response = report_client("lambda", domain, task_id, config=RENDERER_CLIENT_CONFIG).invoke(
|
|
31
|
+
FunctionName=REPORT_RENDERER_FUNCTION_NAME,
|
|
32
|
+
InvocationType="RequestResponse",
|
|
33
|
+
Payload=payload,
|
|
34
|
+
)
|
|
35
|
+
response_payload = response["Payload"].read()
|
|
36
|
+
except (BotoCoreError, ClientError, KeyError, OSError) as exc:
|
|
37
|
+
message = "Could not reach the customer report renderer. Local report data was preserved."
|
|
38
|
+
raise ReportServiceError(message) from exc
|
|
39
|
+
|
|
40
|
+
if response.get("FunctionError"):
|
|
41
|
+
raise ReportServiceError("The customer report renderer failed. Local report data was preserved.")
|
|
42
|
+
try:
|
|
43
|
+
result = json.loads(response_payload)
|
|
44
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
45
|
+
raise ReportServiceError("The customer report renderer returned an invalid response.") from exc
|
|
46
|
+
if not isinstance(result, dict):
|
|
47
|
+
raise ReportServiceError("The customer report renderer returned an invalid response.")
|
|
48
|
+
if result.get("ok") is not True:
|
|
49
|
+
error = result.get("error")
|
|
50
|
+
message = error.get("message") if isinstance(error, dict) else None
|
|
51
|
+
if not isinstance(message, str) or not message.strip():
|
|
52
|
+
message = "The customer report renderer rejected the report."
|
|
53
|
+
raise ReportServiceError(message)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _encode_sources(sources: dict[str, bytes]) -> dict[str, str]:
|
|
58
|
+
return {name: base64.b64encode(content).decode("ascii") for name, content in sources.items()}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_customer_report(
|
|
62
|
+
domain: str,
|
|
63
|
+
task_id: str,
|
|
64
|
+
report_type: str,
|
|
65
|
+
company_name: str,
|
|
66
|
+
sources: dict[str, bytes],
|
|
67
|
+
) -> bytes:
|
|
68
|
+
result = _invoke_renderer(
|
|
69
|
+
domain,
|
|
70
|
+
task_id,
|
|
71
|
+
{
|
|
72
|
+
"operation": "render",
|
|
73
|
+
"report_type": report_type,
|
|
74
|
+
"company_name": company_name,
|
|
75
|
+
"sources": _encode_sources(sources),
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
return base64.b64decode(result["pdf_base64"])
|
|
80
|
+
except (KeyError, TypeError, binascii.Error) as exc:
|
|
81
|
+
raise ReportServiceError("The customer report renderer returned an invalid response.") from exc
|
hound_cli/roster.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Validate and load the account-local Hound agent roster."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from hound_cli.aws import get_secret
|
|
10
|
+
|
|
11
|
+
ROSTER_SECRET_ID = "hound/agents"
|
|
12
|
+
_AGENT_ID = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
|
13
|
+
_E164_PHONE_NUMBER = re.compile(r"^\+[1-9]\d{7,14}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_agent_id(value: str) -> str:
|
|
17
|
+
"""Return a lowercase DNS-safe stable agent ID."""
|
|
18
|
+
if not isinstance(value, str) or not _AGENT_ID.fullmatch(value):
|
|
19
|
+
raise click.ClickException(f"Invalid agent ID in hound/agents: {value!r}.")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_phone_number(value: str) -> str:
|
|
24
|
+
"""Return an exact E.164 agent phone number."""
|
|
25
|
+
normalized = value.strip()
|
|
26
|
+
if not _E164_PHONE_NUMBER.fullmatch(normalized):
|
|
27
|
+
raise click.ClickException("The selected agent must have a valid E.164 phone_number in hound/agents.")
|
|
28
|
+
return normalized
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_roster(roster: dict) -> dict:
|
|
32
|
+
"""Return a validated coordinate-only Hound agent roster."""
|
|
33
|
+
if not isinstance(roster, dict) or not roster:
|
|
34
|
+
raise click.ClickException("Agent roster must be a non-empty JSON object.")
|
|
35
|
+
|
|
36
|
+
for agent_id, agent in roster.items():
|
|
37
|
+
validate_agent_id(agent_id)
|
|
38
|
+
if not isinstance(agent, dict):
|
|
39
|
+
raise click.ClickException("Agent roster entries must map agent IDs to JSON objects.")
|
|
40
|
+
|
|
41
|
+
email = agent.get("email")
|
|
42
|
+
if not isinstance(email, str) or not email.strip():
|
|
43
|
+
raise click.ClickException(f"Agent roster entry '{agent_id}' must contain a non-empty email.")
|
|
44
|
+
|
|
45
|
+
phone_number = agent.get("phone_number")
|
|
46
|
+
if phone_number is not None:
|
|
47
|
+
try:
|
|
48
|
+
validate_phone_number(phone_number)
|
|
49
|
+
except (AttributeError, click.ClickException) as exc:
|
|
50
|
+
raise click.ClickException(
|
|
51
|
+
f"Agent roster entry '{agent_id}' phone_number must use E.164 format, for example +12125550123."
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
return roster
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_roster(client) -> dict:
|
|
58
|
+
"""Load the validated agent roster from Secrets Manager."""
|
|
59
|
+
roster = get_secret(client, ROSTER_SECRET_ID)
|
|
60
|
+
if roster is None:
|
|
61
|
+
raise click.ClickException(
|
|
62
|
+
f"Agent roster not found at '{ROSTER_SECRET_ID}'. Create it in Secrets Manager first."
|
|
63
|
+
)
|
|
64
|
+
return validate_roster(roster)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Target configuration commands."""
|