From 370764497e25faab7f6a2f18cac4b9cbf76dab86 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 18 Jun 2026 16:53:11 -0400 Subject: [PATCH 1/7] fix(hashview): list customer hashfiles via per-type endpoint The customer->hashfile flow called GET /v1/hashfiles to list all hashfiles, then filtered client-side. That route does not exist in Hashview (confirmed against v0.8.3-dev), so it 404'd as soon as a customer ID was entered ("Could not list hashfiles"). Hashview exposes no list-all route; the only enumeration endpoint is GET /v1/hashfiles/hash_type/, which already returns customer_id and hash_type per file. Rebuild get_customer_hashfiles on top of get_hashfiles_by_type, scoped to the session hash type (hcatHashType), and filter by customer. Also fix get_hashfile_details to call GET /v1/getHashType/ instead of the nonexistent /v1/hashfiles//hash_type. Remove the dead list_hashfiles wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- hate_crack/api.py | 87 ++++++++++++++++++++---------------------- hate_crack/main.py | 9 +++-- tests/test_hashview.py | 52 +++++++++---------------- tests/test_utils.py | 2 +- 4 files changed, 65 insertions(+), 85 deletions(-) diff --git a/hate_crack/api.py b/hate_crack/api.py index 47813c8..79e1b9a 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1127,7 +1127,7 @@ class HashviewAPI: def get_hashfile_details(self, hashfile_id): """Get hashfile details and hashtype for a given hashfile_id.""" - url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/hash_type" + url = f"{self.base_url}/v1/getHashType/{hashfile_id}" resp = self.session.get(url, headers=self._auth_headers()) resp.raise_for_status() try: @@ -1173,61 +1173,49 @@ class HashviewAPI: return {"customers": customers} return data - def list_hashfiles(self): - url = f"{self.base_url}/v1/hashfiles" - resp = self.session.get(url, headers=self._auth_headers()) - resp.raise_for_status() - data = resp.json() - if "hashfiles" in data: - if isinstance(data["hashfiles"], str): - hashfiles = json.loads(data["hashfiles"]) - else: - hashfiles = data["hashfiles"] - return hashfiles - return [] + def get_customer_hashfiles(self, customer_id, hash_type=None): + """Return a customer's hashfiles of a given hash_type. - def get_customer_hashfiles(self, customer_id): - all_hashfiles = self.list_hashfiles() + Hashview exposes no "list all hashfiles" route; the only enumeration + endpoint is ``/v1/hashfiles/hash_type/`` (see + :meth:`get_hashfiles_by_type`), which already returns ``customer_id`` + and ``hash_type`` per file. We query that and filter by customer. + + ``hash_type`` is required to enumerate: without it there is no API + route to list a customer's files, so an empty list is returned. + """ + if hash_type is None: + if self.debug: + print( + "[DEBUG] get_customer_hashfiles: no hash_type given; Hashview " + "has no list-all route, returning []" + ) + return [] + + all_hashfiles = self.get_hashfiles_by_type(hash_type) customer_hfs = [ - hf for hf in all_hashfiles if int(hf.get("customer_id", 0)) == customer_id + hf for hf in all_hashfiles if int(hf.get("customer_id", 0)) == int(customer_id) ] + # The type-scoped endpoint already returns the hash_type, but normalize + # the key so downstream callers can read either spelling. + for hf in customer_hfs: + if not (hf.get("hashtype") or hf.get("hash_type")): + hf["hash_type"] = str(hash_type) + if self.debug: print( - f"[DEBUG] get_customer_hashfiles({customer_id}): found {len(customer_hfs)} hashfiles" + f"[DEBUG] get_customer_hashfiles({customer_id}, hash_type={hash_type}): " + f"found {len(customer_hfs)} hashfiles" ) - # Fetch hash types for any hashfiles missing them - for hf in customer_hfs: - if not (hf.get("hashtype") or hf.get("hash_type")): - hf_id = hf.get("id") - if hf_id is not None: - if self.debug: - print(f"[DEBUG] Fetching hash_type for hashfile {hf_id}") - try: - details = self.get_hashfile_details(hf_id) - hashtype = details.get("hashtype") - if hashtype: - hf["hash_type"] = hashtype - if self.debug: - print( - f"[DEBUG] Updated hashfile {hf_id} with hash_type={hashtype}" - ) - elif self.debug: - print( - f"[DEBUG] No hashtype found in details for {hf_id}: {details}" - ) - except Exception as e: - if self.debug: - print( - f"[DEBUG] Exception fetching hash_type for {hf_id}: {e}" - ) - return customer_hfs def get_customer_hashfiles_with_hashtype(self, customer_id, target_hashtype="1000"): """Return hashfiles for a customer that match the requested hashtype.""" - customer_hashfiles = self.get_customer_hashfiles(customer_id) + customer_hashfiles = self.get_customer_hashfiles( + customer_id, hash_type=target_hashtype + ) if not customer_hashfiles: return [] target_str = str(target_hashtype) @@ -1604,8 +1592,13 @@ def download_hashes_from_hashview( input_fn: Callable[[str], str] = input, print_fn: Callable[..., None] = print, potfile_path: Optional[str] = None, + hash_type: Optional[str] = None, ) -> Tuple[str, str]: - """Interactive Hashview download flow used by CLI.""" + """Interactive Hashview download flow used by CLI. + + ``hash_type`` is required to enumerate a customer's hashfiles, since + Hashview only exposes a per-hash-type listing endpoint. + """ try: if not sys.stdin or not sys.stdin.isatty(): print_fn("\nAvailable Customers:") @@ -1663,7 +1656,9 @@ def download_hashes_from_hashview( else: customer_id = int(customer_raw) try: - customer_hashfiles = api_harness.get_customer_hashfiles(customer_id) + customer_hashfiles = api_harness.get_customer_hashfiles( + customer_id, hash_type=hash_type + ) if customer_hashfiles: print_fn("\n" + "=" * 120) print_fn(f"Hashfiles for Customer ID {customer_id}:") diff --git a/hate_crack/main.py b/hate_crack/main.py index 93dde4a..1e4e940 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3757,15 +3757,18 @@ def hashview_api(): ) continue - # List hashfiles for the customer + # List hashfiles for the customer. Hashview has no + # list-all route, so we enumerate by the session hash + # type (the type of the hashes loaded in this session). try: customer_hashfiles = api_harness.get_customer_hashfiles( - customer_id + customer_id, hash_type=hcatHashType ) if not customer_hashfiles: print( - f"\nNo hashfiles found for customer ID {customer_id}" + f"\nNo hashfiles of type {hcatHashType} found for " + f"customer ID {customer_id}" ) continue diff --git a/tests/test_hashview.py b/tests/test_hashview.py index b8d6905..a92aafc 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -68,74 +68,56 @@ class TestHashviewAPI: # Cleanup - def test_list_hashfiles_success(self, api): - """Test successful hashfile listing with real API if possible, else mock.""" + def test_get_hashfiles_by_type_success(self, api): + """The /v1/hashfiles/hash_type/ endpoint returns a list (real API if possible).""" hashview_url, hashview_api_key = self._get_hashview_config() if hashview_url and hashview_api_key: real_api = HashviewAPI(hashview_url, hashview_api_key) - result = real_api.list_hashfiles() + result = real_api.get_hashfiles_by_type("1000") assert isinstance(result, list) - # If there are no hashfiles, that's valid, but if present, check structure if result: assert "name" in result[0] else: mock_response = Mock() - mock_response.json.return_value = { - "hashfiles": json.dumps( - [ - {"id": 1, "customer_id": 1, "name": "hashfile1.txt"}, - {"id": 2, "customer_id": 2, "name": "hashfile2.txt"}, - ] - ) - } + mock_response.json.return_value = [ + {"id": 1, "customer_id": 1, "name": "hashfile1.txt", "hash_type": 1000}, + {"id": 2, "customer_id": 2, "name": "hashfile2.txt", "hash_type": 1000}, + ] mock_response.raise_for_status = Mock() api.session.get.return_value = mock_response - result = api.list_hashfiles() + result = api.get_hashfiles_by_type("1000") assert isinstance(result, list) assert len(result) == 2 assert result[0]["name"] == "hashfile1.txt" - def test_list_hashfiles_empty(self, api): - """Test hashfile listing returns empty list if no hashfiles (real API if possible).""" - hashview_url, hashview_api_key = self._get_hashview_config() - if hashview_url and hashview_api_key: - real_api = HashviewAPI(hashview_url, hashview_api_key) - result = real_api.list_hashfiles() - # If there are no hashfiles, result should be [] - if not result: - assert result == [] - else: - assert isinstance(result, list) - else: - mock_response = Mock() - mock_response.json.return_value = {} - mock_response.raise_for_status = Mock() - api.session.get.return_value = mock_response - result = api.list_hashfiles() - assert result == [] + def test_get_customer_hashfiles_requires_hash_type(self, api): + """Without a hash_type there is no Hashview list route, so we return [].""" + result = api.get_customer_hashfiles(1) + assert result == [] def test_get_customer_hashfiles(self, api): - """Test filtering hashfiles by customer_id (real API if possible).""" + """Filter the type-scoped hashfile list by customer_id (real API if possible).""" hashview_url, hashview_api_key = self._get_hashview_config() customer_id = os.environ.get("HASHVIEW_CUSTOMER_ID") if hashview_url and hashview_api_key and customer_id: real_api = HashviewAPI(hashview_url, hashview_api_key) - result = real_api.get_customer_hashfiles(int(customer_id)) + result = real_api.get_customer_hashfiles(int(customer_id), hash_type="1000") assert isinstance(result, list) # If there are hashfiles, all should match customer_id if result: assert all(hf["customer_id"] == int(customer_id) for hf in result) else: - api.list_hashfiles = Mock( + api.get_hashfiles_by_type = Mock( return_value=[ {"id": 1, "customer_id": 1, "name": "hashfile1.txt"}, {"id": 2, "customer_id": 2, "name": "hashfile2.txt"}, {"id": 3, "customer_id": 1, "name": "hashfile3.txt"}, ] ) - result = api.get_customer_hashfiles(1) + result = api.get_customer_hashfiles(1, hash_type="1000") assert len(result) == 2 assert all(hf["customer_id"] == 1 for hf in result) + api.get_hashfiles_by_type.assert_called_once_with("1000") def test_display_customers_multicolumn_empty(self, api, capsys): """Test display_customers_multicolumn with no customers (mock only, as real API not needed).""" diff --git a/tests/test_utils.py b/tests/test_utils.py index fd688cf..5d01b81 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -226,7 +226,7 @@ def test_get_customer_hashfiles_with_hashtype_filters(monkeypatch): monkeypatch.setattr( hv, "get_customer_hashfiles", - lambda customer_id: [ + lambda customer_id, hash_type=None: [ {"customer_id": customer_id, "hashtype": "1000"}, {"customer_id": customer_id, "hash_type": "0"}, ], From 90653b856f784a96915f3c4c0fcbbd30de26e9cb Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 18 Jun 2026 16:59:40 -0400 Subject: [PATCH 2/7] 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/ (returns uncracked ciphertexts), not the nonexistent //left. - delete_job: DELETE /v1/jobs/, not GET /v1/jobs/delete/. - start_job: POST /v1/jobs/start/, 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) --- README.md | 3 +++ hate_crack/api.py | 32 ++++++++++++++++++++------------ tests/test_hashview.py | 36 ++++++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6f83a7b..d31bcd8 100644 --- a/README.md +++ b/README.md @@ -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/` 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/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `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 diff --git a/hate_crack/api.py b/hate_crack/api.py index 79e1b9a..72b69c8 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -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/ (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/ 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/ 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 diff --git a/tests/test_hashview.py b/tests/test_hashview.py index a92aafc..76d556a 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -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/); 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/ (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/ (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() From ab4700e636ccb773ae6c912d6e8e86099dcf1af2 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 18 Jun 2026 17:40:36 -0400 Subject: [PATCH 3/7] fix(hashview): prompt for hash type when session type is unset The download-hashes flow scoped the customer hashfile listing to hcatHashType, but that is None when the Hashview menu is entered without a hashfile loaded this session -> "No hashfiles of type None found". Since Hashview only lists hashfiles per type, prompt for a hashcat hash-type when none is set (Q cancels back to the menu) and use it for the listing. Downstream selection still derives the real type from the chosen hashfile. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- hate_crack/main.py | 32 +++++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d31bcd8..9e2ce22 100644 --- a/README.md +++ b/README.md @@ -913,7 +913,7 @@ 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/` 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/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `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 + - 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/` endpoint, scoped to the session hash type (prompting for a hash type when none is loaded this session), and filters by customer. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `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 diff --git a/hate_crack/main.py b/hate_crack/main.py index 1e4e940..88802d9 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3710,6 +3710,7 @@ def hashview_api(): elif option_key == "download_hashes": # Download left hashes try: + cancel_download = False while True: # First, list customers to help user select customers_result = api_harness.list_customers() @@ -3758,17 +3759,34 @@ def hashview_api(): continue # List hashfiles for the customer. Hashview has no - # list-all route, so we enumerate by the session hash - # type (the type of the hashes loaded in this session). + # list-all route, so we must enumerate by hash type. + # Prefer the session hash type (set when a hashfile is + # loaded); otherwise prompt, since None can't be queried. + download_hash_type = hcatHashType + if not download_hash_type: + ht_input = input( + "\nNo hash type loaded this session. Enter the hashcat " + "hash-type to list (e.g. 1000 for NTLM), or Q to cancel: " + ).strip() + if ht_input.lower() == "q": + cancel_download = True + break + if not ht_input.isdigit(): + print( + "\n✗ Error: Hash type must be a numeric hashcat mode." + ) + continue + download_hash_type = ht_input + try: customer_hashfiles = api_harness.get_customer_hashfiles( - customer_id, hash_type=hcatHashType + customer_id, hash_type=download_hash_type ) if not customer_hashfiles: print( - f"\nNo hashfiles of type {hcatHashType} found for " - f"customer ID {customer_id}" + f"\nNo hashfiles of type {download_hash_type} found " + f"for customer ID {customer_id}" ) continue @@ -3820,6 +3838,10 @@ def hashview_api(): break break + # User cancelled at the hash-type prompt: back to the menu. + if cancel_download: + continue + # Set output filename automatically output_file = f"left_{customer_id}_{hashfile_id}.txt" From 347e3529d580a6ff8ac21e9673355c0c002590ef Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 18 Jun 2026 17:44:48 -0400 Subject: [PATCH 4/7] feat(hashview): display a customer's hashfiles instead of asking for type Downloading previously required knowing/typing the hash type because Hashview only lists hashfiles per type. The download flow now sweeps common hashcat modes (HashviewAPI.get_all_customer_hashfiles) and shows all of the customer's uploaded hashfiles to pick from, deduped by id. A manual hash-type prompt remains as a fallback only when nothing is found among the common set (uncommon modes). The chosen file's real hash type still drives the download. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- hate_crack/api.py | 69 ++++++++++++++++++++++++++++++++++++++++++ hate_crack/main.py | 57 ++++++++++++++++++++-------------- tests/test_hashview.py | 35 +++++++++++++++++++++ 4 files changed, 139 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9e2ce22..b086e87 100644 --- a/README.md +++ b/README.md @@ -913,7 +913,7 @@ 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/` endpoint, scoped to the session hash type (prompting for a hash type when none is loaded this session), and filters by customer. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `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 + - 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/` endpoint. Since that route lists per hash type, the download flow sweeps common hashcat modes to display all of a customer's uploaded hashfiles (falling back to a manual hash-type prompt only if none are found among the common set), then filters by customer. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `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 diff --git a/hate_crack/api.py b/hate_crack/api.py index 72b69c8..c0eec96 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1211,6 +1211,75 @@ class HashviewAPI: return customer_hfs + # Curated set of hashcat modes commonly seen in engagements. Used to + # enumerate a customer's hashfiles by sweeping the per-type listing + # endpoint, since Hashview exposes no list-all route. Not exhaustive: + # uncommon types can still be queried explicitly via get_customer_hashfiles. + COMMON_HASH_TYPES = ( + 0, # MD5 + 100, # SHA1 + 1000, # NTLM + 3000, # LM + 1100, # Domain Cached Credentials (DCC), MS Cache + 2100, # Domain Cached Credentials 2 (DCC2), MS Cache 2 + 5500, # NetNTLMv1 + 5600, # NetNTLMv2 + 7500, # Kerberos 5 AS-REQ Pre-Auth (etype 23) + 13100, # Kerberos 5 TGS-REP (Kerberoasting, etype 23) + 18200, # Kerberos 5 AS-REP (AS-REP roasting, etype 23) + 19600, # Kerberos 5 TGS-REP (etype 17) + 19700, # Kerberos 5 TGS-REP (etype 18) + 1800, # sha512crypt $6$ (Linux) + 500, # md5crypt $1$ (Linux/Cisco) + 7400, # sha256crypt $5$ + 3200, # bcrypt $2*$ + 1700, # SHA512 + 1400, # SHA256 + 160, # HMAC-SHA1 + 13400, # KeePass + 9600, # MS Office 2013 + 10500, # PDF 1.4-1.6 + 11600, # 7-Zip + 16500, # JWT + 22000, # WPA-PBKDF2-PMKID+EAPOL + ) + + def get_all_customer_hashfiles(self, customer_id, hash_types=None): + """Aggregate a customer's hashfiles across hash types (deduped by id). + + Hashview has no list-all route, so this sweeps the per-type listing + endpoint (:meth:`get_hashfiles_by_type`) over ``hash_types`` and keeps + the files belonging to ``customer_id``. Defaults to + :attr:`COMMON_HASH_TYPES`; a file of an uncommon type not in the sweep + won't appear. The first hash type a file is seen under is retained as + its ``hash_type`` (mixed-type files are rare). + """ + if hash_types is None: + hash_types = self.COMMON_HASH_TYPES + seen = {} + for ht in hash_types: + try: + files = self.get_hashfiles_by_type(ht) + except Exception as e: + if self.debug: + print(f"[DEBUG] get_all_customer_hashfiles: type {ht} failed: {e}") + continue + for hf in files: + if int(hf.get("customer_id", 0)) != int(customer_id): + continue + hf_id = hf.get("id") + if hf_id is None: + continue + if not (hf.get("hashtype") or hf.get("hash_type")): + hf["hash_type"] = str(ht) + seen.setdefault(int(hf_id), hf) + if self.debug: + print( + f"[DEBUG] get_all_customer_hashfiles({customer_id}): " + f"found {len(seen)} hashfiles across {len(hash_types)} types" + ) + return list(seen.values()) + def get_customer_hashfiles_with_hashtype(self, customer_id, target_hashtype="1000"): """Return hashfiles for a customer that match the requested hashtype.""" customer_hashfiles = self.get_customer_hashfiles( diff --git a/hate_crack/main.py b/hate_crack/main.py index 88802d9..bed09ac 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3759,34 +3759,45 @@ def hashview_api(): continue # List hashfiles for the customer. Hashview has no - # list-all route, so we must enumerate by hash type. - # Prefer the session hash type (set when a hashfile is - # loaded); otherwise prompt, since None can't be queried. - download_hash_type = hcatHashType - if not download_hash_type: - ht_input = input( - "\nNo hash type loaded this session. Enter the hashcat " - "hash-type to list (e.g. 1000 for NTLM), or Q to cancel: " - ).strip() - if ht_input.lower() == "q": - cancel_download = True - break - if not ht_input.isdigit(): - print( - "\n✗ Error: Hash type must be a numeric hashcat mode." - ) - continue - download_hash_type = ht_input - + # list-all route, so we enumerate by sweeping the + # per-type endpoint across common hashcat modes and + # showing whatever the customer has uploaded. try: - customer_hashfiles = api_harness.get_customer_hashfiles( - customer_id, hash_type=download_hash_type + print( + "\nScanning customer hashfiles across common hash types..." ) + customer_hashfiles = api_harness.get_all_customer_hashfiles( + customer_id + ) + + # Fallback: nothing among common types. The file may + # use an uncommon mode, so let the user name it. + if not customer_hashfiles: + print( + f"\nNo hashfiles found for customer ID {customer_id} " + "among common hash types." + ) + ht_input = input( + "Enter a specific hashcat hash-type to check " + "(e.g. 1000), or Q to cancel: " + ).strip() + if ht_input.lower() == "q": + cancel_download = True + break + if not ht_input.isdigit(): + print( + "\n✗ Error: Hash type must be a numeric hashcat mode." + ) + continue + customer_hashfiles = ( + api_harness.get_customer_hashfiles( + customer_id, hash_type=ht_input + ) + ) if not customer_hashfiles: print( - f"\nNo hashfiles of type {download_hash_type} found " - f"for customer ID {customer_id}" + f"\nNo hashfiles found for customer ID {customer_id}" ) continue diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 76d556a..1e092b4 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -95,6 +95,41 @@ class TestHashviewAPI: result = api.get_customer_hashfiles(1) assert result == [] + def test_get_all_customer_hashfiles_sweeps_and_dedupes(self, api): + """Aggregate sweeps per-type listings, filters by customer, dedupes by id.""" + per_type = { + 1000: [ + {"id": 1, "customer_id": 1, "name": "ntlm.txt", "hash_type": 1000}, + {"id": 2, "customer_id": 2, "name": "other.txt", "hash_type": 1000}, + ], + 5600: [ + {"id": 3, "customer_id": 1, "name": "ntlmv2.txt", "hash_type": 5600}, + # id 1 appears again under another type; must dedupe (first wins) + {"id": 1, "customer_id": 1, "name": "ntlm.txt", "hash_type": 5600}, + ], + } + api.get_hashfiles_by_type = Mock(side_effect=lambda ht: per_type.get(int(ht), [])) + + result = api.get_all_customer_hashfiles(1, hash_types=[1000, 5600]) + + ids = sorted(hf["id"] for hf in result) + assert ids == [1, 3] # customer 2 excluded, id 1 not duplicated + by_id = {hf["id"]: hf for hf in result} + assert str(by_id[1]["hash_type"]) == "1000" # first type seen wins + assert str(by_id[3]["hash_type"]) == "5600" + + def test_get_all_customer_hashfiles_skips_failing_types(self, api): + """A per-type query that errors is skipped, not fatal.""" + + def _by_type(ht): + if int(ht) == 1000: + raise RuntimeError("boom") + return [{"id": 9, "customer_id": 1, "name": "x", "hash_type": int(ht)}] + + api.get_hashfiles_by_type = Mock(side_effect=_by_type) + result = api.get_all_customer_hashfiles(1, hash_types=[1000, 5600]) + assert [hf["id"] for hf in result] == [9] + def test_get_customer_hashfiles(self, api): """Filter the type-scoped hashfile list by customer_id (real API if possible).""" hashview_url, hashview_api_key = self._get_hashview_config() From c07bab1a2ba003da0219f1e98d3791128667166f Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 18 Jun 2026 17:56:48 -0400 Subject: [PATCH 5/7] fix(hashview): degrade to direct hashfile-ID entry when listing API absent The hashfile-listing route (/v1/hashfiles/hash_type/) only exists on Hashview builds from 2026-06-08+ (v0.8.3-dev). On `main` and older servers it 404s, which surfaced as "Could not list hashfiles" and a dead end. The download flow is now version-adaptive: - get_all_customer_hashfiles stops sweeping after the first 404 (the listing endpoint is absent) instead of probing every common type. - The menu treats listing as best-effort: when no listing is available it prints a clear note and accepts a hashfile ID typed directly (read from the web UI), with no list-membership check. The chosen file's type is resolved via GET /v1/getHashType/ and downloaded via GET /v1/hashfiles/ -- both present on all server versions. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- hate_crack/api.py | 12 +++++++ hate_crack/main.py | 77 ++++++++++++++++++------------------------ tests/test_hashview.py | 16 +++++++++ 4 files changed, 61 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index b086e87..22d83a1 100644 --- a/README.md +++ b/README.md @@ -913,7 +913,7 @@ 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/` endpoint. Since that route lists per hash type, the download flow sweeps common hashcat modes to display all of a customer's uploaded hashfiles (falling back to a manual hash-type prompt only if none are found among the common set), then filters by customer. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `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 + - 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/` endpoint where available — the download flow sweeps common hashcat modes to display a customer's uploaded hashfiles. That listing route only exists on Hashview builds from 2026-06-08+ (the `v0.8.3-dev` branch); on `main`/older servers there is no hashfile-listing API at all, so the flow now degrades gracefully to entering the hashfile ID directly (looked up in the Hashview web UI) and resolving its type via `GET /v1/getHashType/`. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `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 diff --git a/hate_crack/api.py b/hate_crack/api.py index c0eec96..83d8120 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1261,6 +1261,18 @@ class HashviewAPI: try: files = self.get_hashfiles_by_type(ht) except Exception as e: + status = getattr(getattr(e, "response", None), "status_code", None) + if status == 404: + # The /v1/hashfiles/hash_type route doesn't exist on this + # server (e.g. Hashview main, or builds before 2026-06-08), + # so no per-type sweep is possible. Stop after the first 404 + # instead of hammering the server with one request per type. + if self.debug: + print( + "[DEBUG] get_all_customer_hashfiles: hash_type listing " + "endpoint absent (404); aborting sweep" + ) + break if self.debug: print(f"[DEBUG] get_all_customer_hashfiles: type {ht} failed: {e}") continue diff --git a/hate_crack/main.py b/hate_crack/main.py index bed09ac..3f5e5dd 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3758,55 +3758,31 @@ def hashview_api(): ) continue - # List hashfiles for the customer. Hashview has no - # list-all route, so we enumerate by sweeping the - # per-type endpoint across common hashcat modes and - # showing whatever the customer has uploaded. + # Try to list the customer's hashfiles for convenience. + # This only works on Hashview servers that expose the + # /v1/hashfiles/hash_type endpoint (v0.8.3-dev, added + # 2026-06-08); on `main` or older builds there is no + # hashfile-listing API, so we fall back to entering the + # hashfile ID directly (look it up in the web UI). + hashfile_map = {} try: print( "\nScanning customer hashfiles across common hash types..." ) - customer_hashfiles = api_harness.get_all_customer_hashfiles( - customer_id + customer_hashfiles = ( + api_harness.get_all_customer_hashfiles(customer_id) ) + except Exception as e: + customer_hashfiles = [] + if debug_mode: + print(f"[DEBUG] hashfile listing unavailable: {e}") - # Fallback: nothing among common types. The file may - # use an uncommon mode, so let the user name it. - if not customer_hashfiles: - print( - f"\nNo hashfiles found for customer ID {customer_id} " - "among common hash types." - ) - ht_input = input( - "Enter a specific hashcat hash-type to check " - "(e.g. 1000), or Q to cancel: " - ).strip() - if ht_input.lower() == "q": - cancel_download = True - break - if not ht_input.isdigit(): - print( - "\n✗ Error: Hash type must be a numeric hashcat mode." - ) - continue - customer_hashfiles = ( - api_harness.get_customer_hashfiles( - customer_id, hash_type=ht_input - ) - ) - - if not customer_hashfiles: - print( - f"\nNo hashfiles found for customer ID {customer_id}" - ) - continue - + if customer_hashfiles: print("\n" + "=" * 120) print(f"Hashfiles for Customer ID {customer_id}:") print("=" * 120) print(f"{'ID':<10} {'Hash Type':<10} {'Name':<96}") print("-" * 120) - hashfile_map = {} for hf in customer_hashfiles: hf_id = hf.get("id") hf_name = hf.get("name", "N/A") @@ -3826,22 +3802,33 @@ def hashview_api(): hashfile_map[int(hf_id)] = hf_type print("=" * 120) print(f"Total: {len(hashfile_map)} hashfile(s)") - except Exception as e: - print(f"\nWarning: Could not list hashfiles: {e}") - continue + else: + print( + "\nThis Hashview server has no hashfile-listing API " + "(it lacks the /v1/hashfiles/hash_type endpoint), or " + f"customer {customer_id} has no hashfiles of a common " + "type. Look up the hashfile ID in the Hashview web UI " + "and enter it below." + ) while True: + hashfile_id_input = input( + "\nEnter hashfile ID (or Q to cancel): " + ).strip() + if hashfile_id_input.lower() == "q": + cancel_download = True + break try: - hashfile_id_input = input( - "\nEnter hashfile ID: " - ).strip() hashfile_id = int(hashfile_id_input) except ValueError: print( "\n✗ Error: Invalid ID entered. Please enter a numeric ID." ) continue - if hashfile_id not in hashfile_map: + # Only restrict to the listed set when we actually + # have a listing; otherwise accept any ID the user + # read from the web UI. + if hashfile_map and hashfile_id not in hashfile_map: print( "\n✗ Error: Hashfile ID not in the list. Please try again." ) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index 1e092b4..9a140d3 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -118,6 +118,22 @@ class TestHashviewAPI: assert str(by_id[1]["hash_type"]) == "1000" # first type seen wins assert str(by_id[3]["hash_type"]) == "5600" + def test_get_all_customer_hashfiles_aborts_on_404(self, api): + """A 404 means the listing endpoint is absent (e.g. Hashview main); + the sweep stops after the first request instead of probing every type.""" + import requests + + def _raise_404(ht): + resp = Mock() + resp.status_code = 404 + raise requests.exceptions.HTTPError("404 Not Found", response=resp) + + api.get_hashfiles_by_type = Mock(side_effect=_raise_404) + result = api.get_all_customer_hashfiles(1, hash_types=[1000, 5600, 3000]) + assert result == [] + # Stopped after the first 404, did not sweep all three types. + assert api.get_hashfiles_by_type.call_count == 1 + def test_get_all_customer_hashfiles_skips_failing_types(self, api): """A per-type query that errors is skipped, not fatal.""" From 048ed7cac1df2d6c0ce1fcd113a8f08a9bc47681 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 25 Jun 2026 10:17:49 -0400 Subject: [PATCH 6/7] feat(test): run live Hashview tests against a local docker stack The live Hashview tests previously could only run against a remote server configured in config.json, and the CLI ignored env-var overrides, so the suite always hit prod. Prod runs an older Hashview whose /v1/jobs/add 500s on a notify_email kwarg, which masked real client/server drift. Wire the suite up to a local Hashview docker stack: - main.py: HASHVIEW_URL / HASHVIEW_API_KEY env vars now override config.json, so the CLI can be pointed at a local instance without editing config. - tests/_hashview_local.py + pytest_configure: when HASHVIEW_TEST_LOCAL=1, bring up + seed the Hashview compose stack (HASHVIEW_REPO), verify authenticated API access, export the HASHVIEW_* env, and tear down (HASHVIEW_KEEP=1 keeps it). Runs in configure so collection-time skipif on HASHVIEW_TEST_REAL sees the exported env. - tests/hashview_local_seed.py: seed an admin api_key + non-default password (else Hashview's setup guard redirects every request to /setup), a customer, a hashfile, and cracked "effective task" data (else /v1/jobs/add has no tasks to schedule). - api.py: download_left_hashes now uses GET /v1/hashfiles/; the old /v1/hashfiles//left route 404s on current servers. - subprocess tests: assert on the CLI's actual output ("Job ID:") rather than the literal "Job created", and strip ambient HASHVIEW_* creds from the no-key-configured check so the env override doesn't defeat it. - README/CLAUDE: document HASHVIEW_TEST_LOCAL and the env overrides. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 25 ++ hate_crack/api.py | 5 +- hate_crack/main.py | 10 +- tests/_hashview_local.py | 231 ++++++++++++++++++ tests/conftest.py | 37 +++ tests/hashview_local_seed.py | 152 ++++++++++++ tests/test_hashview.py | 10 +- ...est_hashview_cli_subcommands_subprocess.py | 27 +- 8 files changed, 488 insertions(+), 9 deletions(-) create mode 100644 tests/_hashview_local.py create mode 100644 tests/hashview_local_seed.py diff --git a/README.md b/README.md index 6f83a7b..91470ac 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,31 @@ environment variable and provide valid credentials in `config.json`: HATE_CRACK_RUN_LIVE_TESTS=1 uv run pytest tests/test_upload_cracked_hashes.py -v ``` +### Live Hashview Tests Against a Local Docker Stack + +Instead of pointing the live tests at a remote Hashview server, you can have +the suite spin up a local [Hashview](https://github.com/hashview/hashview) +Docker stack, seed it, run the live tests against it, and tear it down. Set +`HASHVIEW_TEST_LOCAL=1` and point `HASHVIEW_REPO` at a Hashview checkout: + +```bash +HASHVIEW_TEST_LOCAL=1 HASHVIEW_REPO=~/projects/hashview \ + HATE_CRACK_SKIP_INIT=1 uv run pytest tests/test_hashview_cli_subcommands_subprocess.py -v +``` + +This brings up `docker compose` in the Hashview repo, seeds an admin API key, +a customer, a hashfile, and cracked "effective task" data, then exports the +`HASHVIEW_*` env vars the tests read. Useful env vars: + +- `HASHVIEW_TEST_LOCAL=1` — enable the local stack (no-op otherwise) +- `HASHVIEW_REPO=` — Hashview checkout (default `~/projects/hashview`) +- `HASHVIEW_KEEP=1` — leave containers running after the session (faster re-runs) +- `HASHVIEW_LOCAL_PORT=5000` — host port the app is published on + +The hate_crack CLI honours the `HASHVIEW_URL` / `HASHVIEW_API_KEY` environment +variables (overriding `config.json`), which is what lets the suite point the +CLI at the local stack without editing your persisted config. + ### End-to-End Install Tests (Local + Docker) Local uv tool install + script execution (uses a temporary HOME): diff --git a/hate_crack/api.py b/hate_crack/api.py index 47813c8..8e8acce 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -1344,7 +1344,10 @@ class HashviewAPI: ): import sys - url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/left" + # Hashview's GET /v1/hashfiles/ streams exactly the still-uncracked + # ("left") hashes, one per line. The older /v1/hashfiles//left route + # no longer exists and 404s on current servers. + 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: diff --git a/hate_crack/main.py b/hate_crack/main.py index 93dde4a..c5fcd91 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -272,8 +272,14 @@ if _missing_keys: print(f"[config] Added {len(_missing_keys)} missing key(s) to {_config_path}") print(f" Keys: {', '.join(_missing_keys)}") -hashview_url = config_parser["hashview_url"] -hashview_api_key = config_parser["hashview_api_key"] +# Environment variables override config.json so the CLI can be pointed at a +# different Hashview instance (e.g. a local docker stack for the live test +# suite) without editing the persisted config. Empty/unset env vars fall back +# to config.json. +hashview_url = os.environ.get("HASHVIEW_URL") or config_parser["hashview_url"] +hashview_api_key = ( + os.environ.get("HASHVIEW_API_KEY") or config_parser["hashview_api_key"] +) SKIP_INIT = os.environ.get("HATE_CRACK_SKIP_INIT") == "1" diff --git a/tests/_hashview_local.py b/tests/_hashview_local.py new file mode 100644 index 0000000..c4c7555 --- /dev/null +++ b/tests/_hashview_local.py @@ -0,0 +1,231 @@ +"""Bring up / tear down a local Hashview docker stack for the live test suite. + +Used by the session-scoped ``hashview_local_stack`` fixture in ``conftest.py``. +Activated by ``HASHVIEW_TEST_LOCAL=1``; otherwise everything here is a no-op and +the live tests skip as before. + +Lifecycle (when enabled): + 1. write ``hashview/config.conf`` with ``SERVER_NAME = 127.0.0.1:5000`` so the + app is reachable from the host (a stale ``app:5000`` SERVER_NAME makes every + request 404 via Flask host matching) + 2. ``docker compose up -d --build`` + 3. poll until the app answers on the host + 4. seed the DB (admin api_key, customer, hashfile, cracked effective-task data) + 5. export ``HASHVIEW_*`` env vars so both the test helpers and the hate_crack + CLI (which now honours ``HASHVIEW_URL`` / ``HASHVIEW_API_KEY``) target local + 6. on teardown ``docker compose down -v`` unless ``HASHVIEW_KEEP=1`` + +Config via env: + HASHVIEW_TEST_LOCAL=1 enable the stack + HASHVIEW_REPO= hashview checkout (default ~/projects/hashview) + HASHVIEW_KEEP=1 leave containers running after the session + HASHVIEW_LOCAL_PORT=5000 host port the app is published on +""" +import os +import shutil +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path + +LOCAL_API_KEY = "3eac1ab7-e525-4bb7-b565-2c9f045dfc56" +CUSTOMER_ID = "1" +HASHFILE_ID = "1" +# md5 is hashcat mode 0; the suite's pwdump test uploads NTLM (1000). Seed both. +HASH_TYPE = "0" +SEED_HASH_TYPES = "0,1000" + +_CONFIG_CONF = """[SERVER] +SERVER_NAME = 127.0.0.1:{port} +SECRET_KEY = hate-crack-local-test-secret + +[database] +host = db +username = hashview +password = hashview + +[SMTP] +server = smtp.example.com +port = 25 +use_tls = False +username = +password = +default_sender = +""" + + +def enabled() -> bool: + return os.environ.get("HASHVIEW_TEST_LOCAL", "").lower() in ("1", "true", "yes") + + +def _keep() -> bool: + return os.environ.get("HASHVIEW_KEEP", "").lower() in ("1", "true", "yes") + + +def _repo() -> Path: + return Path( + os.environ.get("HASHVIEW_REPO", os.path.expanduser("~/projects/hashview")) + ).resolve() + + +def _port() -> str: + return os.environ.get("HASHVIEW_LOCAL_PORT", "5000") + + +def _base_url() -> str: + return f"http://127.0.0.1:{_port()}" + + +def _compose(repo: Path, *args: str, check: bool = True, capture: bool = False): + env = {**os.environ, "DOCKER_PLATFORM": os.environ.get("DOCKER_PLATFORM", "linux/amd64")} + return subprocess.run( + ["docker", "compose", *args], + cwd=str(repo), + env=env, + check=check, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + ) + + +def _http_status(url: str, cookie: str | None = None): + """Return the HTTP status for ``url`` (following no redirects), or None.""" + + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + opener = urllib.request.build_opener(_NoRedirect) + req = urllib.request.Request(url) + if cookie: + req.add_header("Cookie", f"uuid={cookie}") + try: + with opener.open(req, timeout=3) as resp: + return resp.status + except urllib.error.HTTPError as exc: + return exc.code + except Exception: + return None + + +# Statuses that mean "Flask is up and routing" — i.e. SERVER_NAME matches and +# the app is serving. ``/login`` answers 200 or 302 (redirect) depending on +# session/setup state; either is fine. A 404 means the route didn't match +# (e.g. a stale ``app:5000`` SERVER_NAME) and a connection error (None) means +# the app isn't listening yet. +_ROUTING_STATUSES = {200, 301, 302, 303, 403} + + +def _wait_ready(timeout: float = 240.0) -> bool: + """Poll until the app is routing requests on ``/login``. + + On a cold start the DB volume is fresh, so the app boots and runs + migrations before it stops connection-refusing. Routing (not auth/seed) + readiness is the gate here; auth/seed readiness is checked separately in + :func:`_wait_authenticated`. + """ + url = f"{_base_url()}/login" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _http_status(url) in _ROUTING_STATUSES: + return True + time.sleep(2) + return False + + +def _wait_authenticated(timeout: float = 60.0) -> bool: + """Poll an authenticated endpoint until the seeded api_key is accepted. + + Closes the race where ``/login`` is up but the seed's admin api_key isn't + effective yet: an unauthenticated ``/v1/customers`` 302-redirects (to an + HTML page), while a recognised cookie returns 200 JSON. + """ + url = f"{_base_url()}/v1/customers" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if _http_status(url, cookie=LOCAL_API_KEY) == 200: + return True + time.sleep(2) + return False + + +def _seed(repo: Path, attempts: int = 6) -> None: + seeder = Path(__file__).with_name("hashview_local_seed.py") + _compose(repo, "cp", str(seeder), "app:/tmp/hashview_local_seed.py") + seed_env = [ + "-e", "PYTHONPATH=/", + "-e", f"HASHVIEW_API_KEY={LOCAL_API_KEY}", + "-e", f"HASHVIEW_CUSTOMER_ID={CUSTOMER_ID}", + "-e", f"HASHVIEW_HASHFILE_ID={HASHFILE_ID}", + "-e", f"HASHVIEW_SEED_HASH_TYPES={SEED_HASH_TYPES}", + ] + # The admin user (id=1) and default tasks are created during the app's + # first-request boot, which can lag a few seconds behind the app routing. + # Retry so a cold start doesn't fail on "Admin user not found". + last = None + for i in range(attempts): + result = _compose( + repo, + "exec", + "-T", + *seed_env, + "-w", + "/", + "app", + "python", + "/tmp/hashview_local_seed.py", + check=False, + capture=True, + ) + if result.returncode == 0: + return + last = result.stdout + time.sleep(5) + raise RuntimeError(f"seed did not succeed after {attempts} attempts: {last}") + + +def _export_env() -> None: + os.environ["HASHVIEW_TEST_REAL"] = "1" + os.environ["HASHVIEW_URL"] = _base_url() + os.environ["HASHVIEW_API_KEY"] = LOCAL_API_KEY + os.environ["HASHVIEW_CUSTOMER_ID"] = CUSTOMER_ID + os.environ["HASHVIEW_HASHFILE_ID"] = HASHFILE_ID + os.environ.setdefault("HASHVIEW_HASH_TYPE", HASH_TYPE) + + +def setup(): + """Bring the stack up + seed. Returns a skip reason string, or None on success.""" + if shutil.which("docker") is None: + return "docker not available" + repo = _repo() + if not repo.is_dir(): + return f"hashview repo not found at {repo} (set HASHVIEW_REPO)" + if not (repo / "docker-compose.yml").is_file(): + return f"no docker-compose.yml in hashview repo {repo}" + + (repo / "hashview" / "config.conf").write_text(_CONFIG_CONF.format(port=_port())) + try: + _compose(repo, "up", "-d", "--build") + except subprocess.CalledProcessError as exc: + return f"docker compose up failed: {exc}" + if not _wait_ready(): + return f"hashview app did not become ready at {_base_url()}" + try: + _seed(repo) + except (subprocess.CalledProcessError, RuntimeError) as exc: + return f"hashview DB seed failed: {exc}" + if not _wait_authenticated(): + return "seeded api_key not accepted by Hashview (auth/seed race)" + + _export_env() + return None + + +def teardown(): + if _keep(): + return + repo = _repo() + if repo.is_dir() and (repo / "docker-compose.yml").is_file(): + _compose(repo, "down", "-v", check=False, capture=True) diff --git a/tests/conftest.py b/tests/conftest.py index 22eb2bc..2189302 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,3 +42,40 @@ def _isolate_notify_state(): notify.clear_state_for_tests() yield notify.clear_state_for_tests() + + +def pytest_configure(config): + """Spin up + seed a local Hashview docker stack for the live test suite. + + No-op unless ``HASHVIEW_TEST_LOCAL=1``. When enabled, brings up the stack + from the hashview repo, seeds the DB, and exports the ``HASHVIEW_*`` env + vars the live tests (and the hate_crack CLI) read so they target the local + instance instead of whatever ``config.json`` points at. + + This runs in ``pytest_configure`` — *before* collection — on purpose: the + live subprocess tests gate on ``HASHVIEW_TEST_REAL`` via ``@skipif``, which + is evaluated at collection time. A session fixture would set the env too + late and every live test would skip. On failure we deliberately leave + ``HASHVIEW_TEST_REAL`` unset so the live tests skip with their normal + reason rather than erroring. See ``tests/_hashview_local.py`` for config. + """ + from tests import _hashview_local as hv + + if not hv.enabled(): + return + reason = hv.setup() + if reason is not None: + config.issue_config_time_warning( + pytest.PytestWarning( + f"HASHVIEW_TEST_LOCAL set but local stack unavailable " + f"({reason}); live Hashview tests will skip." + ), + stacklevel=2, + ) + + +def pytest_unconfigure(config): + from tests import _hashview_local as hv + + if hv.enabled(): + hv.teardown() diff --git a/tests/hashview_local_seed.py b/tests/hashview_local_seed.py new file mode 100644 index 0000000..961a337 --- /dev/null +++ b/tests/hashview_local_seed.py @@ -0,0 +1,152 @@ +"""Seed a local Hashview docker stack to the state hate_crack's live tests need. + +Runs INSIDE the Hashview ``app`` container (``PYTHONPATH=/``), where the +``hashview`` package is importable. Idempotent. + +Brings the database to: +- admin user (id=1) with a known ``api_key`` (so hate_crack can authenticate) +- a Settings row (so the /setup wizard does not intercept requests) +- a Customer at ``HASHVIEW_CUSTOMER_ID`` +- a Hashfile at ``HASHVIEW_HASHFILE_ID`` holding one (uncracked) hash, so the + ``download-hashes`` / left-list endpoints return data +- at least one *cracked* Hashes row per hash type in + ``HASHVIEW_SEED_HASH_TYPES`` (comma separated, default ``0,1000``) attached to + a real Task. ``/v1/jobs/add`` derives its "top 10 effective tasks" from + cracked hashes of the uploaded file's hash type; without this it answers + "Not enough data to determine effective tasks" and job creation fails. + +Required env: HASHVIEW_API_KEY, HASHVIEW_CUSTOMER_ID, HASHVIEW_HASHFILE_ID. +Optional env: HASHVIEW_SEED_HASH_TYPES, HASHVIEW_SEED_TASK_ID (default 1). +""" +import os +import sys + +from flask import Flask +from flask_bcrypt import Bcrypt + +from hashview.config import Config +from hashview.models import ( + Customers, + Hashes, + HashfileHashes, + Hashfiles, + Settings, + Tasks, + Users, + db, +) + +REQUIRED_ENV = ( + "HASHVIEW_API_KEY", + "HASHVIEW_CUSTOMER_ID", + "HASHVIEW_HASHFILE_ID", +) + + +def build_app() -> Flask: + app = Flask(__name__) + app.config.from_object(Config) + db.init_app(app) + Bcrypt(app) + return app + + +def seed(app: Flask) -> None: + api_key = os.environ["HASHVIEW_API_KEY"] + customer_id = int(os.environ["HASHVIEW_CUSTOMER_ID"]) + hashfile_id = int(os.environ["HASHVIEW_HASHFILE_ID"]) + task_id = int(os.environ.get("HASHVIEW_SEED_TASK_ID", "1")) + hash_types = [ + int(x) for x in os.environ.get("HASHVIEW_SEED_HASH_TYPES", "0,1000").split(",") if x.strip() + ] + + bcrypt = Bcrypt(app) + + with app.app_context(): + admin = db.session.get(Users, 1) + if admin is None: + raise RuntimeError( + "Admin user (id=1) not found. App must finish booting before seeding." + ) + admin.api_key = api_key + admin.admin = True + # Hashview's setup guard (do_gui_setup_if_needed) redirects every request + # to /setup/admin-pass while the admin password is still the default. + # Set a non-default password so the API is reachable without the wizard. + admin.password = bcrypt.generate_password_hash( + os.environ.get("HASHVIEW_ADMIN_PASSWORD", "hate-crack-local-pass") + ).decode("utf-8") + admin.email_address = admin.email_address or "admin@hate-crack.local" + + if db.session.query(Settings).first() is None: + db.session.add( + Settings(retention_period=30, max_runtime_jobs=0, max_runtime_tasks=0) + ) + + if db.session.get(Customers, customer_id) is None: + db.session.add(Customers(id=customer_id, name="hate_crack Local Customer")) + + if db.session.get(Tasks, task_id) is None: + raise RuntimeError( + f"Task id={task_id} not found. Default tasks should exist after app boot." + ) + + if db.session.get(Hashfiles, hashfile_id) is None: + db.session.add( + Hashfiles( + id=hashfile_id, + name="hate-crack-local-hashfile", + customer_id=customer_id, + owner_id=admin.id, + ) + ) + db.session.flush() + hash_row = Hashes( + sub_ciphertext="d41d8cd98f00b204e9800998ecf8427e", + ciphertext="d41d8cd98f00b204e9800998ecf8427e", + hash_type=0, + cracked=False, + ) + db.session.add(hash_row) + db.session.flush() + db.session.add( + HashfileHashes( + hash_id=hash_row.id, username="local-user", hashfile_id=hashfile_id + ) + ) + + # Cracked "effective task" data, one row per hash type the tests upload. + for ht in hash_types: + sub = f"seed-cracked-{ht}" + exists = ( + db.session.query(Hashes) + .filter_by(sub_ciphertext=sub, hash_type=ht, cracked=True) + .first() + ) + if exists is None: + db.session.add( + Hashes( + sub_ciphertext=sub, + ciphertext=sub, + hash_type=ht, + cracked=True, + task_id=task_id, + plaintext="password", + ) + ) + + db.session.commit() + + +def main() -> int: + missing = [k for k in REQUIRED_ENV if not os.getenv(k)] + if missing: + print(f"hashview_local_seed: missing env vars: {missing}", file=sys.stderr) + return 2 + seed(build_app()) + print("hashview_local_seed: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_hashview.py b/tests/test_hashview.py index b8d6905..664e70c 100644 --- a/tests/test_hashview.py +++ b/tests/test_hashview.py @@ -262,9 +262,15 @@ 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 uncracked ("left") hashes come from GET /v1/hashfiles/ + # (the trailing /found call is a separate lookup). 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) and "found" not 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) diff --git a/tests/test_hashview_cli_subcommands_subprocess.py b/tests/test_hashview_cli_subcommands_subprocess.py index 529e57d..01d8a43 100644 --- a/tests/test_hashview_cli_subcommands_subprocess.py +++ b/tests/test_hashview_cli_subcommands_subprocess.py @@ -105,6 +105,16 @@ def test_hashview_subcommands_require_api_key(tmp_path, args): path.write_text("dummy\n") args[idx + 1] = str(path) + # Strip any ambient Hashview credentials (e.g. exported by the local-stack + # fixture) so this exercises the genuine no-key-configured path. The CLI + # honours HASHVIEW_URL / HASHVIEW_API_KEY as overrides, so leaving them set + # would supply a key and defeat the check. + sub_env = { + k: v + for k, v in os.environ.items() + if k not in ("HASHVIEW_URL", "HASHVIEW_API_KEY") + } + sub_env["PYTHONUNBUFFERED"] = "1" cli_cmd = [sys.executable, HATE_CRACK_SCRIPT] + args result = subprocess.run( cli_cmd, @@ -112,7 +122,7 @@ def test_hashview_subcommands_require_api_key(tmp_path, args): stderr=subprocess.PIPE, text=True, cwd=REPO_ROOT, - env={**os.environ, "PYTHONUNBUFFERED": "1"}, + env=sub_env, ) output = result.stdout + result.stderr assert "Hashview API key not configured" in output @@ -215,7 +225,10 @@ def test_hashview_subcommands_live_upload_hashfile_job(tmp_path): ) assert run.returncode == 0, output assert ("Hashfile uploaded" in output) or ("Hashfile added" in output) - assert ("Job created" in output) or ("Failed to add job" in output) + # Success surfaces the server's job id ("Job ID: N"); a graceful failure + # surfaces an "Error:" line. The CLI echoes the server's own message ("Job + # added"), so don't assert on the literal "Job created". + assert ("Job ID:" in output) or ("Error:" in output) if "Job ID:" in output: job_id = None for line in output.splitlines(): @@ -305,7 +318,10 @@ def test_hashview_subcommands_live_upload_hashfile_job_pwdump(tmp_path): ) assert run.returncode == 0, output assert ("Hashfile uploaded" in output) or ("Hashfile added" in output) - assert ("Job created" in output) or ("Failed to add job" in output) + # Success surfaces the server's job id ("Job ID: N"); a graceful failure + # surfaces an "Error:" line. The CLI echoes the server's own message ("Job + # added"), so don't assert on the literal "Job created". + assert ("Job ID:" in output) or ("Error:" in output) if "Job ID:" in output: job_id = None for line in output.splitlines(): @@ -389,7 +405,10 @@ def test_hashview_subcommands_live_upload_hashfile_job_hashonly(tmp_path): output = run.stdout + run.stderr assert run.returncode == 0, output assert ("Hashfile uploaded" in output) or ("Hashfile added" in output) - assert ("Job created" in output) or ("Failed to add job" in output) + # Success surfaces the server's job id ("Job ID: N"); a graceful failure + # surfaces an "Error:" line. The CLI echoes the server's own message ("Job + # added"), so don't assert on the literal "Job created". + assert ("Job ID:" in output) or ("Error:" in output) if "Job ID:" in output: job_id = None for line in output.splitlines(): From 1b31ecf7058377fde84fecfa5d007c1a61adce71 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Thu, 25 Jun 2026 11:52:29 -0400 Subject: [PATCH 7/7] docs: add v2.10.6 version-history entries for env override + local test harness Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 890d3df..833d615 100644 --- a/README.md +++ b/README.md @@ -939,6 +939,8 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi 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/` endpoint where available — the download flow sweeps common hashcat modes to display a customer's uploaded hashfiles. That listing route only exists on Hashview builds from 2026-06-08+ (the `v0.8.3-dev` branch); on `main`/older servers there is no hashfile-listing API at all, so the flow now degrades gracefully to entering the hashfile ID directly (looked up in the Hashview web UI) and resolving its type via `GET /v1/getHashType/`. Additional client-side route fixes: hashfile hash-type lookup now uses `GET /v1/getHashType/`; "left" (uncracked) hash download uses `GET /v1/hashfiles/`; `delete_job` uses `DELETE /v1/jobs/`; `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 + - The Hashview CLI now honours `HASHVIEW_URL` / `HASHVIEW_API_KEY` environment variables as overrides for the `config.json` values, so the client can be pointed at a different Hashview instance (e.g. a local dev stack) without editing the persisted config + - Added an opt-in local Hashview integration-test harness: `HASHVIEW_TEST_LOCAL=1` (with `HASHVIEW_REPO=`) spins up and seeds a local Hashview docker stack, runs the live Hashview tests against it, and tears it down (`HASHVIEW_KEEP=1` keeps it). This is what surfaced and verified the route fixes above against `v0.8.3-dev`. See the README testing section for details 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