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()