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.
This commit is contained in:
2026-04-26 04:34:19 -04:00
parent a455248dd9
commit 50870f2e7a
3 changed files with 77 additions and 0 deletions

View File

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

View File

@@ -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<Props> = ({ row, onClose }) => {
)}
</div>
<div>
<div className="type-label">{isPlain ? 'PLAINTEXT SECRET' : 'OBSERVED RESPONSE'}</div>
<pre className="code-block">
<span className="ck">printable:</span>{' '}
<span className="cs">{row.secret_printable ?? '—'}</span>{'\n'}
<span className="ck">b64:</span>{' '}
<span className="cs">{row.secret_b64 ?? '—'}</span>
</pre>
</div>
<div>
<div className="type-label">SECRET SHA-256</div>
<div className="hash-row">

View File

@@ -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