Files
shopdb-flask/plugins/backups/migrations/versions/0002_backups_lastseenat.py
cproudlock dffa2082be Backups: stop storing credentials, and fix a dedup chain that never matched
The NTLARS/DNC registry carries an FTP password in cleartext and it was stored
verbatim. That put it in the backuprevisions table, on the revision panel which
renders values, and in the download route which hands back a ready-to-import
.reg - so anyone holding backups.view could read it off a web page.

Redaction happens at PARSE, so the secret never reaches the database at all.
Redacting on display would have left it at rest and one query away.

What is deliberately NOT done is dropping the value silently. This codec already
refuses to discard an unparseable value, precisely because a backup that looks
complete and restores a machine with a setting missing is the worst outcome. A
redacted value keeps its name, is listed in the projection, and renders as a
COMMENT naming what must be set by hand. Emitting a placeholder as the password
would produce a machine that looks restored and cannot connect, and the
technician would go debugging the network.

scripts/redact_backup_secrets.py scrubs revisions recorded before this, dry run
by default. It leaves contenthash alone: the hash identifies the configuration a
bay reported, so rewriting it would make every bay's next post look like a
change and insert a duplicate revision across the fleet.

Separately, sourcehostname is now normalized at ingest. samesource() maps '' to
IS NULL for the query while the insert stored the raw '', so rows written by a
client posting an empty hostname could never match their own next post: every
post inserted, the table grew without bound, and the history filled with
identical entries nobody could read. Whitespace did the same thing more quietly.
2026-08-26 15:06:04 -04:00

48 lines
1.8 KiB
Python

"""backups: record when a configuration was last confirmed unchanged.
Dedup means an unchanged config writes NO revision, so `collectedat` moves only
when something changes. A machine whose settings have been stable for six months
therefore has a six-month-old newest revision and is entirely healthy - and
ShopDB had no way to tell it apart from a machine whose backup stopped running
six months ago. The evidence that a check happened existed only in a log file on
the PC.
`lastseenat` records the check rather than the change. It is touched on every
matching post, including the no-op that writes nothing else.
Backfilled from `collectedat` (falling back to `createdat`) so existing rows
start from the last moment we can actually prove the config was current, rather
than from now - claiming a fresh check that never happened would be worse than
saying nothing.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'backups0002lastseenat'
down_revision = 'backups0001baseline'
branch_labels = None
depends_on = None
def upgrade():
inspector = sa.inspect(op.get_bind())
if 'backuprevisions' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
if 'lastseenat' not in columns:
op.add_column('backuprevisions',
sa.Column('lastseenat', sa.DateTime(), nullable=True))
op.execute('UPDATE backuprevisions '
'SET lastseenat = COALESCE(collectedat, createdat)')
def downgrade():
inspector = sa.inspect(op.get_bind())
if 'backuprevisions' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.drop_column('backuprevisions', 'lastseenat')