From 50870f2e7a115fca747c6dfc471560d202735f64 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 26 Apr 2026 04:34:19 -0400 Subject: [PATCH] feat(creds): surface plaintext/b64 secret on reuse findings The CredentialReuse table only stores the sha256+kind hash of the secret; the printable + b64 forms live on the underlying Credential rows. The dashboard drawer was therefore showing only the hash, which defeats most of the value of having a reuse view in the first place. Repo helpers list_credential_reuses + get_credential_reuse_by_id now issue one batched SELECT against credentials keyed on the sha256s in the result page and graft secret_printable + secret_b64 onto each row before returning. The drawer renders the same printable/b64 code-block the credentials inspector uses. --- decnet/web/db/sqlmodel_repo.py | 42 +++++++++++++++++++ .../components/CredentialReuseInspector.tsx | 12 ++++++ tests/correlation/test_credential_reuse.py | 23 ++++++++++ 3 files changed, 77 insertions(+) diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index 5ef674bb..8a72aa38 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -927,6 +927,7 @@ class SQLModelRepository(BaseRepository): except (json.JSONDecodeError, TypeError): d[key] = [] out.append(d) + await self._enrich_with_secret(session, out) return int(total), out async def get_credential_reuse_by_id( @@ -944,8 +945,49 @@ class SQLModelRepository(BaseRepository): d[key] = json.loads(d[key]) except (json.JSONDecodeError, TypeError): d[key] = [] + await self._enrich_with_secret(session, [d]) return d + @staticmethod + async def _enrich_with_secret( + session: Any, rows: List[dict[str, Any]] + ) -> None: + """Tack ``secret_printable`` + ``secret_b64`` onto each reuse row. + + ``CredentialReuse`` only stores the sha256+kind hash of the + secret — the actual printable/b64 representations live on the + underlying ``Credential`` rows. The dashboard wants to show the + secret in the drawer, so we lift one matching credential per + ``(sha256, kind, principal)`` finding. One batched query for the + whole page; rows with no surviving credential (shouldn't happen + in practice) get nulls. + """ + if not rows: + return + sha_set = {r["secret_sha256"] for r in rows} + if not sha_set: + return + stmt = select( + Credential.secret_sha256, + Credential.secret_kind, + Credential.principal, + Credential.secret_printable, + Credential.secret_b64, + ).where(Credential.secret_sha256.in_(sha_set)) + secret_map: dict[ + tuple[str, str, Optional[str]], + tuple[Optional[str], Optional[str]], + ] = {} + for sha, kind, principal, printable, b64 in ( + (await session.execute(stmt)).all() + ): + secret_map.setdefault((sha, kind, principal), (printable, b64)) + for r in rows: + key = (r["secret_sha256"], r["secret_kind"], r.get("principal")) + printable, b64 = secret_map.get(key, (None, None)) + r["secret_printable"] = printable + r["secret_b64"] = b64 + async def get_state(self, key: str) -> Optional[dict[str, Any]]: async with self._session() as session: statement = select(State).where(State.key == key) diff --git a/decnet_web/src/components/CredentialReuseInspector.tsx b/decnet_web/src/components/CredentialReuseInspector.tsx index dfc30b84..1bdc166a 100644 --- a/decnet_web/src/components/CredentialReuseInspector.tsx +++ b/decnet_web/src/components/CredentialReuseInspector.tsx @@ -19,6 +19,8 @@ export interface CredentialReuseRow { first_seen: string; last_seen: string; updated_at: string; + secret_printable: string | null; + secret_b64: string | null; } interface Props { @@ -136,6 +138,16 @@ const CredentialReuseInspector: React.FC = ({ row, onClose }) => { )} +
+
{isPlain ? 'PLAINTEXT SECRET' : 'OBSERVED RESPONSE'}
+
+              printable:{' '}
+              {row.secret_printable ?? '—'}{'\n'}
+              b64:{' '}
+              {row.secret_b64 ?? '—'}
+            
+
+
SECRET SHA-256
diff --git a/tests/correlation/test_credential_reuse.py b/tests/correlation/test_credential_reuse.py index a4f1c999..f3a6d794 100644 --- a/tests/correlation/test_credential_reuse.py +++ b/tests/correlation/test_credential_reuse.py @@ -164,6 +164,29 @@ class TestEngineCorrelate: assert before_total == after_total == 1 assert results2 == [] + @pytest.mark.anyio + async def test_list_and_get_enrich_with_secret(self, repo) -> None: + """``list_credential_reuses`` and ``get_credential_reuse_by_id`` + must surface ``secret_printable`` + ``secret_b64`` from the + underlying ``Credential`` rows so the dashboard drawer can show + the actual secret instead of just its sha256. + """ + sha = _sha256("hunter2") + await _seed_credential(repo, secret_sha256=sha, decky_name="d1", service="ssh") + await _seed_credential(repo, secret_sha256=sha, decky_name="d2", service="ftp") + + engine = CorrelationEngine() + await engine.correlate_credential_reuse(repo, min_targets=2) + + _, rows = await repo.list_credential_reuses(min_target_count=2) + assert rows[0]["secret_printable"] == "hunter2" + assert rows[0]["secret_b64"] == "aHVudGVyMg==" + + single = await repo.get_credential_reuse_by_id(rows[0]["id"]) + assert single is not None + assert single["secret_printable"] == "hunter2" + assert single["secret_b64"] == "aHVudGVyMg==" + @pytest.mark.anyio async def test_growth_emits_changed(self, repo) -> None: """Adding a third target after an initial reuse run yields a