311 lines
6.7 KiB
Python
311 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
class ResultPackageError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def utc_now() -> str:
|
|
return (
|
|
datetime.now(timezone.utc)
|
|
.replace(microsecond=0)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
)
|
|
|
|
|
|
def _sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
|
|
with path.open("rb") as handle:
|
|
while True:
|
|
chunk = handle.read(1024 * 1024)
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
digest.update(chunk)
|
|
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _json_bytes(data: dict[str, Any]) -> bytes:
|
|
return (
|
|
json.dumps(
|
|
data,
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
+ "\n"
|
|
).encode("utf-8")
|
|
|
|
|
|
def _atomic_write(
|
|
path: Path,
|
|
data: bytes,
|
|
) -> None:
|
|
path.parent.mkdir(
|
|
parents=True,
|
|
exist_ok=True,
|
|
)
|
|
|
|
fd, temporary = tempfile.mkstemp(
|
|
prefix=f".{path.name}.",
|
|
suffix=".tmp",
|
|
dir=str(path.parent),
|
|
)
|
|
|
|
temporary_path = Path(temporary)
|
|
|
|
try:
|
|
with os.fdopen(fd, "wb") as handle:
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
|
|
temporary_path.replace(path)
|
|
except BaseException:
|
|
temporary_path.unlink(
|
|
missing_ok=True
|
|
)
|
|
raise
|
|
|
|
|
|
def _validate_result_steps(
|
|
job: dict[str, Any],
|
|
step_states: list[dict[str, Any]],
|
|
) -> None:
|
|
job_steps = job.get("steps")
|
|
|
|
if not isinstance(job_steps, list):
|
|
raise ResultPackageError(
|
|
"Job has no valid steps list."
|
|
)
|
|
|
|
if len(job_steps) != len(step_states):
|
|
raise ResultPackageError(
|
|
"Result step count does not match Job step count."
|
|
)
|
|
|
|
for job_step, result_step in zip(
|
|
job_steps,
|
|
step_states,
|
|
):
|
|
if (
|
|
result_step.get("step_no")
|
|
!= job_step.get("step_no")
|
|
):
|
|
raise ResultPackageError(
|
|
"Result step_no does not match Job."
|
|
)
|
|
|
|
if (
|
|
result_step.get("step_id")
|
|
!= job_step.get("step_id")
|
|
):
|
|
raise ResultPackageError(
|
|
"Result step_id does not match Job."
|
|
)
|
|
|
|
if (
|
|
result_step.get("session_name")
|
|
!= job_step.get("session_name")
|
|
):
|
|
raise ResultPackageError(
|
|
"Result session_name does not match Job."
|
|
)
|
|
|
|
if result_step.get("status") != "COMPLETED":
|
|
raise ResultPackageError(
|
|
f"Step {job_step.get('step_no')} "
|
|
"is not COMPLETED."
|
|
)
|
|
|
|
if result_step.get("exit_code") not in (0, 1):
|
|
raise ResultPackageError(
|
|
f"Step {job_step.get('step_no')} "
|
|
"has unsupported exit_code."
|
|
)
|
|
|
|
if result_step.get("restore_seen"):
|
|
raise ResultPackageError(
|
|
f"Step {job_step.get('step_no')} "
|
|
"still has an active restore state."
|
|
)
|
|
|
|
|
|
def build_result(
|
|
job: dict[str, Any],
|
|
step_states: list[dict[str, Any]],
|
|
*,
|
|
completed_at: str | None = None,
|
|
) -> tuple[dict[str, Any], bytes]:
|
|
job_id = job.get("job_id")
|
|
|
|
if not isinstance(job_id, str) or not job_id:
|
|
raise ResultPackageError(
|
|
"Job has no valid job_id."
|
|
)
|
|
|
|
method_id = job.get("method_id")
|
|
method_version = job.get("method_version")
|
|
|
|
if not isinstance(method_id, str) or not method_id:
|
|
raise ResultPackageError(
|
|
"Job has no valid method_id."
|
|
)
|
|
|
|
if (
|
|
isinstance(method_version, bool)
|
|
or not isinstance(method_version, int)
|
|
):
|
|
raise ResultPackageError(
|
|
"Job has no valid method_version."
|
|
)
|
|
|
|
_validate_result_steps(
|
|
job,
|
|
step_states,
|
|
)
|
|
|
|
result = {
|
|
"schema_version": 1,
|
|
"job_id": job_id,
|
|
"status": "COMPLETED",
|
|
"completed_at": completed_at or utc_now(),
|
|
"hash_file_sha256": job["hash_file_sha256"],
|
|
"hash_count": job["hash_count"],
|
|
"method": {
|
|
"id": method_id,
|
|
"version": method_version,
|
|
},
|
|
"steps": step_states,
|
|
"show_file": "hashcat-show.txt",
|
|
}
|
|
|
|
unsigned_bytes = _json_bytes(result)
|
|
|
|
result["result_sha256"] = _sha256_bytes(
|
|
unsigned_bytes
|
|
)
|
|
|
|
return result, _json_bytes(result)
|
|
|
|
|
|
def create_result_package(
|
|
job_dir: Path,
|
|
job: dict[str, Any],
|
|
step_states: list[dict[str, Any]],
|
|
outbox: Path,
|
|
*,
|
|
logger=None,
|
|
) -> Path:
|
|
results_dir = job_dir / "results"
|
|
|
|
show_path = results_dir / "hashcat-show.txt"
|
|
|
|
if not show_path.is_file():
|
|
raise ResultPackageError(
|
|
f"Missing Hashcat show output: {show_path}"
|
|
)
|
|
|
|
result, result_bytes = build_result(
|
|
job,
|
|
step_states,
|
|
)
|
|
|
|
result_path = results_dir / "result.json"
|
|
|
|
_atomic_write(
|
|
result_path,
|
|
result_bytes,
|
|
)
|
|
|
|
result_sha256 = _sha256_file(
|
|
result_path
|
|
)
|
|
|
|
if result_sha256 != _sha256_bytes(
|
|
result_bytes
|
|
):
|
|
raise ResultPackageError(
|
|
"Written result.json SHA-256 mismatch."
|
|
)
|
|
|
|
outbox.mkdir(
|
|
parents=True,
|
|
exist_ok=True,
|
|
)
|
|
|
|
job_id = job["job_id"]
|
|
|
|
destination = (
|
|
outbox / f"RESULT-{job_dir.name}.zip"
|
|
)
|
|
|
|
if destination.exists():
|
|
raise ResultPackageError(
|
|
f"Result package already exists: "
|
|
f"{destination}"
|
|
)
|
|
|
|
fd, temporary = tempfile.mkstemp(
|
|
prefix=f".RESULT-{job_dir.name}.",
|
|
suffix=".zip.tmp",
|
|
dir=str(outbox),
|
|
)
|
|
|
|
temporary_path = Path(temporary)
|
|
|
|
try:
|
|
os.close(fd)
|
|
|
|
with zipfile.ZipFile(
|
|
temporary_path,
|
|
mode="w",
|
|
compression=zipfile.ZIP_DEFLATED,
|
|
) as archive:
|
|
archive.write(
|
|
result_path,
|
|
"result.json",
|
|
)
|
|
archive.write(
|
|
show_path,
|
|
"hashcat-show.txt",
|
|
)
|
|
|
|
temporary_path.replace(
|
|
destination
|
|
)
|
|
|
|
except BaseException:
|
|
temporary_path.unlink(
|
|
missing_ok=True
|
|
)
|
|
raise
|
|
|
|
if logger is not None:
|
|
logger.info(
|
|
"Result package created: %s "
|
|
"result_sha256=%s "
|
|
"show_sha256=%s",
|
|
destination.name,
|
|
result["result_sha256"],
|
|
_sha256_file(show_path),
|
|
)
|
|
|
|
return destination
|