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:
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user