fix(hashview): correct remaining job/hashfile API routes

Continues the v0.8.3-dev API alignment beyond the hashfile-listing 404:

- download_left_hashes: "left" hashes are GET /v1/hashfiles/<id>
  (returns uncracked ciphertexts), not the nonexistent /<id>/left.
- delete_job: DELETE /v1/jobs/<id>, not GET /v1/jobs/delete/<id>.
- start_job: POST /v1/jobs/start/<id>, not GET (route is POST-only).
- stop_job: Hashview has no stop route; raise NotImplementedError
  pointing at delete_job rather than calling a phantom endpoint.
- found-hash bulk export: no such route exists (only single-hash
  POST /v1/search); the best-effort merge is documented and degrades
  gracefully on the expected 404.

Adds offline tests asserting start_job POSTs, delete_job uses DELETE,
and stop_job raises. Documents the release in README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-06-18 16:59:40 -04:00
co-authored by Claude Opus 4.8
parent 370764497e
commit 90653b856f
3 changed files with 57 additions and 14 deletions
+3
View File
@@ -912,6 +912,9 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi
-------------------------------------------------------------------
### Version History
Version 2.10.6
- Fixed the Hashview integration calling API routes that don't exist in Hashview (verified against v0.8.3-dev), which 404'd as soon as a customer ID was entered ("Could not list hashfiles"). The customer→hashfile listing relied on a phantom `GET /v1/hashfiles` list-all route; it now enumerates via the real `GET /v1/hashfiles/hash_type/<type>` endpoint, scoped to the session hash type, and filters by customer. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/<id>`; "left" (uncracked) hash download uses `GET /v1/hashfiles/<id>`; `delete_job` uses `DELETE /v1/jobs/<id>`; `start_job` uses `POST`. Hashview exposes no stop-job route, so `stop_job` now raises with guidance to use `delete_job`; and no bulk cracked-hash export exists, so the best-effort "found" merge degrades gracefully
Version 2.10.5
- Pipal analysis no longer corrupts its input when cracked passwords contain `$HEX[...]` rows. `binascii.unhexlify().decode()` returned the bytes without the trailing newline that normal rows inherit from `password[-1]`, so every HEX-encoded password got concatenated with the next one in the `.passwords` file fed to pipal (e.g. three cracks → two lines, one of them a bogus mashup). Pipal then under-counted entries and reported wrong top base words. The HEX branch now re-appends `\n` so each cracked password lands on its own line
+20 -12
View File
@@ -1304,26 +1304,29 @@ class HashviewAPI:
return {}
def stop_job(self, job_id):
url = f"{self.base_url}/v1/jobs/stop/{job_id}"
resp = self.session.get(url)
resp.raise_for_status()
return resp.json()
# Hashview exposes no "stop job" route (only add/get/start/delete).
# Deleting a job removes it regardless of Queued/Running state, which
# is the closest supported operation; use delete_job() instead.
raise NotImplementedError(
"Hashview has no stop-job endpoint; use delete_job() to remove a job."
)
def delete_job(self, job_id):
url = f"{self.base_url}/v1/jobs/delete/{job_id}"
resp = self.session.get(url)
# Hashview deletes via DELETE /v1/jobs/<id> (there is no /jobs/delete/).
url = f"{self.base_url}/v1/jobs/{job_id}"
resp = self.session.delete(url)
resp.raise_for_status()
return resp.json()
def start_job(self, job_id, priority=3, limit_recovered=False):
# /v1/jobs/start/<id> is POST-only; priority/limit_recovered come from
# the stored job record server-side, so they are validated here but not
# required by the endpoint.
url = f"{self.base_url}/v1/jobs/start/{job_id}"
params = {}
priority = int(priority)
if priority < 1 or priority > 5:
raise ValueError("priority must be an int between 1 and 5")
params["priority"] = priority
params["limit_recovered"] = bool(limit_recovered)
resp = self.session.get(url, params=params)
resp = self.session.post(url)
resp.raise_for_status()
return resp.json()
@@ -1332,7 +1335,9 @@ class HashviewAPI:
):
import sys
url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/left"
# Hashview's GET /v1/hashfiles/<id> returns exactly the uncracked
# ("left") ciphertexts for the hashfile (see v1_api_get_hashfile).
url = f"{self.base_url}/v1/hashfiles/{hashfile_id}"
resp = self.session.get(url, headers=self._auth_headers(), stream=True)
resp.raise_for_status()
if output_file is None:
@@ -1371,7 +1376,10 @@ class HashviewAPI:
found_file = os.path.join(out_dir, f"found_{customer_id}_{hashfile_id}.txt")
try:
# Try to download the found file
# Best-effort: Hashview v0.8.3-dev exposes no bulk "found"/cracked
# export endpoint (only the single-hash POST /v1/search), so this
# request 404s against stock servers and the merge is skipped. It
# remains for forks/versions that expose a per-hashfile found dump.
found_url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/found"
found_resp = self.session.get(
found_url, headers=self._auth_headers(), stream=True, timeout=30
+34 -2
View File
@@ -244,9 +244,11 @@ class TestHashviewAPI:
assert content == b"hash1\nhash2\n"
assert result["size"] == len(content)
# Verify auth headers were passed in the left hashes download call
# Verify auth headers were passed in the left hashes download call.
# The left download is the first GET (to /v1/hashfiles/<id>); the
# second GET is the best-effort found lookup that 404s here.
call_args_list = api.session.get.call_args_list
left_call = [c for c in call_args_list if "left" in str(c)][0]
left_call = [c for c in call_args_list if "/v1/hashfiles/2" in str(c)][0]
assert left_call.kwargs.get("headers") is not None
auth_headers = left_call.kwargs.get("headers")
assert "Cookie" in auth_headers or "uuid" in str(auth_headers)
@@ -451,6 +453,36 @@ class TestHashviewAPI:
print("✓ Option 2 (Create Job) is READY and WORKING!")
print("=" * 60)
def test_start_job_uses_post(self, api):
"""start_job must POST to /v1/jobs/start/<id> (the route is POST-only)."""
mock_response = Mock()
mock_response.json.return_value = {"status": 200, "msg": "Job started"}
mock_response.raise_for_status = Mock()
api.session.post.return_value = mock_response
result = api.start_job(42)
assert result["msg"] == "Job started"
api.session.post.assert_called_once_with(f"{HASHVIEW_URL}/v1/jobs/start/42")
api.session.get.assert_not_called()
def test_delete_job_uses_delete_verb(self, api):
"""delete_job must use DELETE /v1/jobs/<id> (there is no /jobs/delete/)."""
mock_response = Mock()
mock_response.json.return_value = {"status": 200, "msg": "Job deleted"}
mock_response.raise_for_status = Mock()
api.session.delete.return_value = mock_response
result = api.delete_job(7)
assert result["msg"] == "Job deleted"
api.session.delete.assert_called_once_with(f"{HASHVIEW_URL}/v1/jobs/7")
def test_stop_job_not_supported(self, api):
"""Hashview has no stop-job route, so stop_job raises NotImplementedError."""
with pytest.raises(NotImplementedError):
api.stop_job(7)
def test_create_job_with_new_customer(self, api, test_hashfile):
"""Test creating a new customer and then creating a job (real API if possible)."""
hashview_url, hashview_api_key = self._get_hashview_config()