Guides image + draft gating, audit retention, ADR-015 gate, backup credential redaction

This commit is contained in:
2026-08-26 15:08:53 -04:00
parent 4e38303326
commit 0738d71525
37 changed files with 1953 additions and 129 deletions

View File

@@ -1416,3 +1416,112 @@ def test_the_card_omits_the_snapshot_row_when_no_path_is_passed(bk_app, bk_plugi
from plugins.backups.services import partmarkerinfo
card = partmarkerinfo.build(_markermanifest(['A.PAT']), 1)
assert 'Snapshot' not in [f['label'] for f in card['fields']]
def test_an_empty_source_hostname_still_dedups(bk_app, bk_plugin):
"""A client posting sourcehostname='' must not defeat dedup forever.
samesource() maps '' to IS NULL for the QUERY, but the insert stored the raw
'' - so the rows a client wrote could never match its own next post. Every
post inserted, the table grew without bound, and the revision history filled
with identical entries nobody could read.
"""
from plugins.backups.models import BackupRevision
with bk_app.app_context():
first = bk_plugin.apply_collector_payload(_payload(sourcehostname=''))
assert first['action'] == 'created'
second = bk_plugin.apply_collector_payload(_payload(sourcehostname=''))
assert second['action'] == 'noop', 'an identical re-post must dedup'
rows = BackupRevision.query.all()
assert len(rows) == 1
assert rows[0].sourcehostname is None, \
"'' is stored as NULL, matching how samesource() queries it"
def test_a_padded_source_hostname_is_the_same_source(bk_app, bk_plugin):
"""Whitespace must not fork a machine's history into two chains."""
with bk_app.app_context():
bk_plugin.apply_collector_payload(_payload(sourcehostname='GGBX0NH3ESF'))
second = bk_plugin.apply_collector_payload(
_payload(sourcehostname=' GGBX0NH3ESF '))
assert second['action'] == 'noop'
# ---------------------------------------------------------------------------
# Credential redaction. The DNC registry carries an FTP password in cleartext;
# stored verbatim it reached the database, the revision panel and the download
# route, so anyone with backups.view could read the fleet's password off a page.
# ---------------------------------------------------------------------------
# A configured install that also carries the FTP credential, which is what a
# real marker/DNC bay actually has in its registry.
SECRETREG = CONFIGUREDREG.replace(
'"MachineNo"="3204"',
'"MachineNo"="3204"\r\n"FtpPasswd"="hunter2"\r\n"FtpUser"="dnc"')
def test_a_credential_never_enters_the_projection():
"""Redaction happens at parse, so the secret is never persisted.
Redacting on display instead would leave it at rest and one query away.
"""
import json
from plugins.backups.services import ntlars
projection = ntlars.parse(_asbytes(SECRETREG))
assert 'hunter2' not in json.dumps(projection)
assert projection['redacted'] == ['General\\FtpPasswd']
values = dict(projection['keys'][0]['values'])
for key in projection['keys']:
values.update(key['values'])
assert values['FtpPasswd']['data'] == ntlars.REDACTEDMARKER
assert values['FtpUser']['data'] == 'dnc', 'only credentials are withheld'
def test_a_redacted_value_is_commented_out_not_imported():
"""Importing the marker as a password yields a machine that cannot log in.
It would LOOK restored, and the technician would go debugging the network.
A commented-out value fails at the one moment somebody is reading the file.
"""
from plugins.backups.services import ntlars
text = ntlars.render(ntlars.parse(_asbytes(SECRETREG))).decode('utf-16-le')
assert 'hunter2' not in text
assert ntlars.REDACTEDMARKER not in text
assert 'FtpPasswd' in text, 'the omission must be named, not silent'
assert '"FtpPasswd"="' not in text, 'must not be an importable value line'
assert '"FtpUser"="dnc"' in text, 'everything else still restores'
def test_the_download_route_cannot_hand_back_a_password(bk_app, bk_plugin):
"""End to end: post a config with a password, download it back."""
with bk_app.app_context():
result = bk_plugin.apply_collector_payload(_payload(reg=SECRETREG))
assert result['action'] == 'created'
from plugins.backups.models import BackupRevision
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
stored = json.dumps(revision.payload)
assert 'hunter2' not in stored
rendered = ntlars_render(revision.payload)
assert 'hunter2' not in rendered
def ntlars_render(projection):
from plugins.backups.services import ntlars
return ntlars.render(projection).decode('utf-16-le')
def test_secret_name_matching_covers_the_usual_spellings():
from plugins.backups.services.ntlars import issecretname
for name in ('FtpPasswd', 'Password', 'ftp_pwd', 'ApiKey', 'AuthToken',
'ClientSecret'):
assert issecretname(name), name
for name in ('FtpUser', 'FMSHost', 'Retries', 'Passive'):
assert not issecretname(name), name

View File

@@ -332,3 +332,230 @@ def test_a_corrupt_blocks_column_renders_as_empty_rather_than_erroring(gd_app, g
guide.blocksjson = 'not json at all'
_db.session.commit()
assert guide.blocks == []
# ---------------------------------------------------------------------------
# The API surface. Everything above this line tests services directly; the
# draft-invisibility rule lives in the ROUTES, and until these existed no test
# executed a single one of them. That is how three routes shipped serving
# unpublished drafts to any reader who could count upwards through ids.
# ---------------------------------------------------------------------------
def _user_with(db, username, permissions, rolename=None):
"""A user holding exactly these guides permissions and nothing else."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, Role, Permission
role = Role(rolename=rolename or (username + 'role'), description=username)
for name in permissions:
# Shared across fixtures: two users in one test both want guides.view,
# and permissions.name is unique.
permission = Permission.query.filter_by(name=name).first()
if permission is None:
permission = Permission(name=name, description=name, category='guides')
db.session.add(permission)
db.session.flush()
role.permissions.append(permission)
db.session.add(role)
db.session.flush()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
return user
def _login(client, username):
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
assert response.status_code == 200, response.get_json()
return {'Authorization':
'Bearer ' + response.get_json()['data']['access_token']}
@pytest.fixture
def reader(client, db):
"""Can read guides. Cannot author them."""
_user_with(db, 'guidereader', ['guides.view'])
return _login(client, 'guidereader')
@pytest.fixture
def author(client, db):
"""Can write their own guides, but holds no guides.edit over others'."""
_user_with(db, 'guideauthor', ['guides.view', 'guides.create'])
return _login(client, 'guideauthor')
def _draft(db, title='Unreleased procedure', published=False):
"""A guide with one saved revision, published or not."""
from plugins.guides.models import Guide
from plugins.guides.services import authoring
guide = Guide(title=title, slug=title.lower().replace(' ', '-'),
status='draft')
db.session.add(guide)
db.session.flush()
guide, revision = authoring.save(
guide, title=title,
blocks=[_block('b1', 'paragraph', text='secret draft wording')])
if published:
authoring.publish(guide)
db.session.commit()
return guide, revision
def test_a_reader_cannot_open_an_unpublished_guide_by_slug(client, db, reader):
guide, _ = _draft(db)
response = client.get('/api/guides/' + guide.slug, headers=reader)
assert response.status_code == 404
def test_a_reader_cannot_read_an_unpublished_revision_by_id(client, db, reader):
"""Revision ids are sequential, so this route is walkable by hand.
Gating it on guides.view alone meant anyone who could read one guide could
read every draft on the site by counting upwards.
"""
guide, revision = _draft(db)
response = client.get('/api/guides/revisions/{}'.format(revision.guiderevisionid),
headers=reader)
assert response.status_code == 404
assert 'secret draft wording' not in response.get_data(as_text=True)
def test_a_reader_may_read_the_published_revision_by_id(client, db, reader):
guide, revision = _draft(db, title='Published procedure', published=True)
response = client.get('/api/guides/revisions/{}'.format(revision.guiderevisionid),
headers=reader)
assert response.status_code == 200
def test_a_reader_cannot_list_a_guides_revision_history(client, db, reader):
guide, _ = _draft(db, title='Published procedure', published=True)
response = client.get('/api/guides/{}/revisions'.format(guide.guideid),
headers=reader)
assert response.status_code == 403
def test_search_does_not_match_a_reader_against_draft_wording(client, db, reader):
"""A hit a reader cannot open is worse than no hit.
The guide row holds the WORKING copy, so matching it would surface a
published guide on a word that exists only in somebody's unpublished edit -
and the word is not on the page when the reader opens it.
"""
from plugins.guides.services import authoring
guide, revision = _draft(db, title='Published procedure', published=True)
authoring.save(guide, blocks=[_block('b1', 'paragraph',
text='unpublished sentence about widgets')])
db.session.commit()
response = client.get('/api/guides?q=widgets', headers=reader)
assert response.status_code == 200
assert response.get_json()['data'] == []
# The author of the draft still finds their own work.
response = client.get('/api/guides?q=secret', headers=reader)
assert response.status_code == 200
def test_an_author_may_edit_their_own_draft_but_not_someone_elses(client, db, author):
guide, _ = _draft(db)
response = client.get('/api/guides/{}/working'.format(guide.guideid),
headers=author)
assert response.status_code == 403, 'not their guide'
def test_an_image_upload_refuses_a_type_that_is_not_an_image(client, db, author):
import io
data = {'file': (io.BytesIO(b'MZ not a picture'), 'payload.exe')}
response = client.post('/api/guides/images', data=data, headers=author,
content_type='multipart/form-data')
assert response.status_code == 400
assert 'Unsupported image type' in response.get_json()['data']['error']['message']
def test_an_image_in_a_draft_is_not_served_to_a_reader(client, db, reader, author):
"""An image is content, and a draft's images are draft content."""
import io
from plugins.guides.models import GuideImage
png = (b'\x89PNG\r\n\x1a\n' + b'0' * 32)
upload = client.post(
'/api/guides/images',
data={'file': (io.BytesIO(png), 'screenshot.png')},
headers=author, content_type='multipart/form-data')
assert upload.status_code == 200, upload.get_json()
imageid = upload.get_json()['data']['guideimageid']
# Unattached: it belongs to whoever uploaded it, not to every reader.
assert client.get('/api/guides/images/{}'.format(imageid),
headers=reader).status_code == 404
assert client.get('/api/guides/images/{}'.format(imageid),
headers=author).status_code == 200
# Attached to an unpublished guide: still not the reader's business.
guide, _ = _draft(db)
db.session.get(GuideImage, imageid).guideid = guide.guideid
db.session.commit()
assert client.get('/api/guides/images/{}'.format(imageid),
headers=reader).status_code == 404
def test_an_image_in_a_published_guide_is_served_and_hardened(client, db, reader, author):
"""The other half: a published screenshot must actually reach the reader.
Guarding this route without checking the published case is how you trade a
leak for every image on the site rendering as a broken icon.
"""
import io
from plugins.guides.models import Guide, GuideImage
from plugins.guides.services import authoring
png = (b'\x89PNG\r\n\x1a\n' + b'0' * 32)
upload = client.post(
'/api/guides/images',
data={'file': (io.BytesIO(png), 'screenshot.png')},
headers=author, content_type='multipart/form-data')
imageid = upload.get_json()['data']['guideimageid']
guide = Guide(title='With a picture', slug='with-a-picture', status='draft')
db.session.add(guide)
db.session.flush()
guide, revision = authoring.save(
guide, title='With a picture',
blocks=[{'id': 'b1', 'type': 'image', 'imageid': imageid,
'url': '/api/guides/images/{}'.format(imageid)}])
authoring.publish(guide)
db.session.get(GuideImage, imageid).guideid = guide.guideid
db.session.commit()
response = client.get('/api/guides/images/{}'.format(imageid), headers=reader)
assert response.status_code == 200
assert response.data == png
# Uploaded bytes are attacker-influenced; they must be inert in this origin.
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
assert 'Content-Security-Policy' in response.headers
def test_renaming_a_guide_does_not_move_its_url(client, db, author):
"""The stored slug exists so a pasted link keeps working.
The editor autosaves on a timer, so re-slugging on title change moved the
URL while somebody was still typing the title.
"""
created = client.post('/api/guides',
json={'title': 'Replacing a marker controler',
'blocks': []}, headers=author)
assert created.status_code == 200, created.get_json()
guideid = created.get_json()['data']['guideid']
slug = created.get_json()['data']['slug']
fixed = client.put('/api/guides/{}'.format(guideid),
json={'title': 'Replacing a marker controller',
'blocks': []}, headers=author)
assert fixed.status_code == 200
assert fixed.get_json()['data']['slug'] == slug, 'the URL must not move'
assert fixed.get_json()['data']['title'] == 'Replacing a marker controller'