fix(telemetry): scrub existing rows, and stop --author= being eaten (#3925)
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""scrub credential-shaped spans out of retrieval_logs.query
|
||||
|
||||
Revision ID: 0099
|
||||
Revises: 0098
|
||||
Create Date: 2026-09-11
|
||||
|
||||
`pre_tool_rule` retrieves against the RAW COMMAND TEXT and `write_path_rule`
|
||||
against the code being written, so whatever was on the command line or in the
|
||||
buffer is what `record_retrieval` wrote into `retrieval_logs.query`. A command
|
||||
that exported a token stored the token (#3925).
|
||||
|
||||
`services/retrieval_telemetry.scrub_secrets` closes that going forward — the
|
||||
value never reaches the column. It cannot reach BACKWARDS, and this does: it
|
||||
rewrites the rows already written.
|
||||
|
||||
REDACTED IN PLACE, NOT DELETED. The rest of the row — score, threshold,
|
||||
result count, duration, the near-miss record id — is legitimate evidence, and
|
||||
it is what a threshold is tuned from. Deleting the row would throw that away
|
||||
to remove a secret that lives in one column, so the column is what gets
|
||||
rewritten. Rows with no credential in them are not touched at all.
|
||||
|
||||
THE PATTERNS ARE INLINED RATHER THAN IMPORTED, deliberately, against the DRY
|
||||
instinct. A migration is a frozen record of a change that already happened on
|
||||
every install that ran it; importing the live patterns would mean this
|
||||
migration quietly does something different next year than it did when it ran,
|
||||
and two installs at the same revision would no longer be in the same state.
|
||||
The Python twin in `services/retrieval_telemetry.py` is free to grow — this is
|
||||
what ran here, once. The one thing that must not drift is coverage, and the
|
||||
guard for that is `test_retrieval_query_scrubbing.py`, which tests the live
|
||||
function rather than this copy.
|
||||
|
||||
POSIX regex, not Python's. Postgres ARE supports the non-greedy `*?` the PEM
|
||||
pattern needs, and `\\s`/`\\S`, so the shapes port directly. The `'gi'` flags
|
||||
are global + case-insensitive, matching `re.sub` with `(?i)`.
|
||||
|
||||
NO BARE `auth` IN THE ASSIGNED PATTERN. It matches `--author=`, so a commit
|
||||
naming an address would have had the address redacted — evidence eaten for a
|
||||
word that only looks credential-shaped. `AUTH_TOKEN` is still caught, by
|
||||
`token`.
|
||||
|
||||
Downgrade is a no-op, and honestly so: the original text is gone and a
|
||||
migration cannot invent it back. Saying that plainly is better than a
|
||||
downgrade that appears to restore something and does not.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0099"
|
||||
down_revision = "0098"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Vendor-prefixed credentials — the prefix IS the tell, so no entropy guessing.
|
||||
_TOKEN = (
|
||||
r"(fmcp_|flt_|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|xox[abprs]-"
|
||||
r"|sk-[A-Za-z0-9]*-?|AKIA|ASIA)[A-Za-z0-9_\-]{12,}"
|
||||
)
|
||||
# A value handed to a secret-NAMED variable, in shell, env, YAML, JSON or a
|
||||
# query string. The NAME identifies it, so the value can be anything.
|
||||
_ASSIGNED = (
|
||||
r"([A-Za-z0-9_]*(token|secret|password|passwd|api[_-]?key|access[_-]?key)"
|
||||
r"[A-Za-z0-9_]*)(\s*[:=]\s*[\"']?)([^\s\"'&]{8,})"
|
||||
)
|
||||
_AUTH_HEADER = r"(authorization\s*:\s*(bearer|basic|token)\s+)(\S+)"
|
||||
_PEM = (
|
||||
r"-----BEGIN [A-Z ]*PRIVATE KEY-----(.|\n)*?-----END [A-Z ]*PRIVATE KEY-----"
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _lit(pattern: str) -> str:
|
||||
"""A regex as a SQL string literal.
|
||||
|
||||
A single quote inside a single-quoted SQL literal has to be DOUBLED, and
|
||||
the assigned-value pattern contains two of them (it allows an optional
|
||||
quote around the value). Left unescaped they close the literal early and
|
||||
the migration dies on a syntax error — which is the whole reason this
|
||||
helper exists rather than the patterns being pasted in inline.
|
||||
"""
|
||||
return pattern.replace("'", "''")
|
||||
|
||||
|
||||
_SCRUB_SQL = f"""
|
||||
UPDATE retrieval_logs
|
||||
SET query = regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(
|
||||
regexp_replace(query, '{_lit(_TOKEN)}', '[redacted:token]', 'gi'),
|
||||
'{_lit(_ASSIGNED)}', '\\1\\3[redacted:assigned]', 'gi'),
|
||||
'{_lit(_AUTH_HEADER)}', '\\1[redacted:auth-header]', 'gi'),
|
||||
'{_lit(_PEM)}', '[redacted:private-key]', 'gi')
|
||||
WHERE query IS NOT NULL
|
||||
AND (query ~* '{_lit(_TOKEN)}'
|
||||
OR query ~* '{_lit(_ASSIGNED)}'
|
||||
OR query ~* '{_lit(_AUTH_HEADER)}'
|
||||
OR query ~* '{_lit(_PEM)}')
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(_SCRUB_SQL)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Deliberately empty — the original text no longer exists to restore."""
|
||||
Reference in New Issue
Block a user