Merge remote-tracking branch 'origin/wj' into wilm
This commit is contained in:
@@ -11,6 +11,7 @@ import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
@@ -27,17 +28,51 @@ TOKENS_URL = '/api/endpoints/siteagent/bootstrap/tokens'
|
||||
SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1'
|
||||
PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package'
|
||||
BASE_URL = 'https://shopdb.test/shopdb'
|
||||
TEMP_SCRIPT = r'C:\Windows\Temp\shopdb-bootstrap.ps1'
|
||||
TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
|
||||
QUERY_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
# Admin-only staging, NOT C:\Windows\Temp: a standard user can pre-plant a
|
||||
# path there and have it run elevated.
|
||||
STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
|
||||
STAGED_SCRIPT = STAGE_DIR + r'\shopdb-bootstrap.ps1'
|
||||
ADMIN_SID = 'S-1-5-32-544'
|
||||
SYSTEM_SID = 'S-1-5-18'
|
||||
# The marker the admin "Download .ps1" link carries, so the server can tell an
|
||||
# admin's browser click apart from a bay still on a pre-change pasted command.
|
||||
UI_LINK_QUERY = 'via=download'
|
||||
|
||||
|
||||
def _expected_command(secret, base=BASE_URL):
|
||||
"""Download, run, delete. Never `irm | iex` - Defender kills that shape."""
|
||||
return ('powershell -ExecutionPolicy Bypass -c "'
|
||||
"try {{ Invoke-WebRequest -UseBasicParsing "
|
||||
"'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}' "
|
||||
"-OutFile '{2}'; & '{2}' }} finally {{ "
|
||||
"Remove-Item '{2}' -Force -ErrorAction SilentlyContinue }}\""
|
||||
).format(base, secret, TEMP_SCRIPT)
|
||||
"""Stage admin-only, download with the token in a header, check the owner,
|
||||
run, delete. Never `irm | iex` - Defender kills that shape."""
|
||||
return (
|
||||
'powershell -ExecutionPolicy Bypass -c "'
|
||||
"try {{ New-Item -ItemType Directory -Force -Path '{dir}' "
|
||||
'| Out-Null; '
|
||||
"icacls '{dir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
|
||||
"'*{system}:(OI)(CI)F' | Out-Null; "
|
||||
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
|
||||
"{{ Write-Host 'Refusing to run: could not restrict "
|
||||
"{dir} to administrators (icacls failed). Fix the directory "
|
||||
"or delete it, then run the command again.' -ForegroundColor Red; "
|
||||
'exit 6 }}; '
|
||||
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
|
||||
"Invoke-WebRequest -UseBasicParsing "
|
||||
"'{base}/api/endpoints/siteagent/bootstrap.ps1' "
|
||||
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
|
||||
"if ((Get-Acl '{path}').GetOwner("
|
||||
'[Security.Principal.SecurityIdentifier]).Value -notin '
|
||||
"@('{admin}','{system}',"
|
||||
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
|
||||
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
|
||||
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
|
||||
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
|
||||
'-ErrorAction SilentlyContinue }}"'
|
||||
).format(dir=STAGE_DIR, path=STAGED_SCRIPT, base=base, header=TOKEN_HEADER,
|
||||
secret=secret, admin=ADMIN_SID, system=SYSTEM_SID)
|
||||
|
||||
|
||||
def _token_header(secret):
|
||||
return {TOKEN_HEADER: secret}
|
||||
|
||||
|
||||
def _make_cert():
|
||||
@@ -111,8 +146,8 @@ def test_generate_returns_the_secret_and_the_ready_made_command(
|
||||
assert len(secret) == len('sdbb_') + 43
|
||||
assert data['command'] == _expected_command(secret)
|
||||
assert data['scripturl'] == (
|
||||
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}'.format(BASE_URL,
|
||||
secret))
|
||||
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}&{2}'.format(
|
||||
BASE_URL, secret, UI_LINK_QUERY))
|
||||
token = data['token']
|
||||
assert token['tokenprefix'] == secret[:12]
|
||||
assert token['maxuses'] == 3
|
||||
@@ -136,13 +171,74 @@ def test_the_command_downloads_and_runs_instead_of_piping_into_iex(
|
||||
assert 'iex' not in command.lower()
|
||||
assert '$' not in command
|
||||
assert '\n' not in command
|
||||
assert data['scripturl'] in command
|
||||
assert data['secret'] in command
|
||||
assert "Remove-Item '{0}' -Force".format(TEMP_SCRIPT) in command
|
||||
assert "Remove-Item '{0}' -Force".format(STAGED_SCRIPT) in command
|
||||
assert 'finally' in command
|
||||
# The link the UI offers instead is the route that really serves it.
|
||||
assert client.get(SCRIPT_URL,
|
||||
query_string={'t': data['secret']}).status_code == 200
|
||||
assert client.get(data['scripturl'].split(BASE_URL, 1)[1]
|
||||
).status_code == 200
|
||||
|
||||
|
||||
def test_the_command_stages_where_only_administrators_can_write(
|
||||
client, db, auth_headers):
|
||||
"""C:\\Windows\\Temp lets any standard user create the exact path the
|
||||
command runs elevated, so the staging directory moved under Program Files,
|
||||
is created with an admin-only DACL, and the command refuses to run a staged
|
||||
file owned by anyone but an administrator."""
|
||||
command = _secret(client, auth_headers)['command']
|
||||
|
||||
assert r'C:\Windows\Temp' not in command
|
||||
assert "-OutFile '{0}'".format(STAGED_SCRIPT) in command
|
||||
assert "New-Item -ItemType Directory -Force -Path '{0}'".format(
|
||||
STAGE_DIR) in command
|
||||
assert "icacls '{0}' /inheritance:r".format(STAGE_DIR) in command
|
||||
# Well-known SIDs, so a renamed or localized group cannot widen the grant.
|
||||
assert "/grant '*{0}:(OI)(CI)F' '*{1}:(OI)(CI)F'".format(
|
||||
ADMIN_SID, SYSTEM_SID) in command
|
||||
assert "Get-Acl '{0}'".format(STAGED_SCRIPT) in command
|
||||
assert 'GetOwner([Security.Principal.SecurityIdentifier]).Value' in command
|
||||
assert "-notin @('{0}','{1}',".format(ADMIN_SID, SYSTEM_SID) in command
|
||||
assert '[Security.Principal.WindowsIdentity]::GetCurrent().User.Value' \
|
||||
in command
|
||||
assert 'not owned by an administrator' in command
|
||||
assert 'exit 5' in command
|
||||
# A leftover from an earlier run is cleared before the download, so the
|
||||
# owner test cannot pass on stale content.
|
||||
assert command.index("Remove-Item '{0}'".format(STAGED_SCRIPT)) < \
|
||||
command.index('Invoke-WebRequest')
|
||||
|
||||
|
||||
def test_the_command_sends_the_token_in_a_header_not_the_query_string(
|
||||
client, db, auth_headers):
|
||||
"""A query string lands verbatim in the nginx and gunicorn access logs."""
|
||||
data = _secret(client, auth_headers)
|
||||
command = data['command']
|
||||
|
||||
assert '?t=' not in command
|
||||
assert "-Headers @{{'{0}'='{1}'}}".format(TOKEN_HEADER,
|
||||
data['secret']) in command
|
||||
assert "'{0}/api/endpoints/siteagent/bootstrap.ps1'".format(
|
||||
BASE_URL) in command
|
||||
# The browser download link is the one place that cannot send a header,
|
||||
# and it is marked so its own fetches are not counted as un-migrated bays.
|
||||
assert data['scripturl'].endswith(
|
||||
'?t={0}&{1}'.format(data['secret'], UI_LINK_QUERY))
|
||||
|
||||
|
||||
def test_a_failed_acl_hardening_stops_the_command(client, db, auth_headers):
|
||||
"""icacls is a native command: it raises nothing and $ErrorActionPreference
|
||||
never applies to it, so an unchecked call leaves a staging directory that
|
||||
still inherits the parent DACL and the line downloads into it anyway. The
|
||||
exit code is read sigil-free, because the whole line must hold no `$`."""
|
||||
command = _secret(client, auth_headers)['command']
|
||||
|
||||
assert '(Get-Variable LASTEXITCODE -ValueOnly) -ne 0' in command
|
||||
assert 'LASTEXITCODE' in command and '$LASTEXITCODE' not in command
|
||||
assert 'icacls failed' in command
|
||||
assert 'exit 6' in command
|
||||
# Checked before anything is fetched into the directory, not after.
|
||||
assert command.index('LASTEXITCODE') > command.index('icacls')
|
||||
assert command.index('LASTEXITCODE') < command.index('Invoke-WebRequest')
|
||||
|
||||
|
||||
def test_defaults_are_a_day_and_twenty_five_uses(client, db, auth_headers):
|
||||
@@ -226,8 +322,8 @@ def test_script_is_served_for_a_good_token(client, db, auth_headers):
|
||||
assert response.mimetype == 'text/plain'
|
||||
script = response.get_data(as_text=True)
|
||||
script.encode('ascii')
|
||||
assert '{0}/api/endpoints/siteagent/bootstrap/package?t={1}'.format(
|
||||
BASE_URL, secret) in script
|
||||
assert '"{0}/api/endpoints/siteagent/bootstrap/package"'.format(
|
||||
BASE_URL) in script
|
||||
assert 'Run as administrator' in script
|
||||
assert 'GE-Enforce' in script
|
||||
assert 'SHOPDB_BOOTSTRAP_FORCE' in script
|
||||
@@ -251,6 +347,163 @@ def test_script_bakes_the_staged_binary_hashes(client, db, auth_headers,
|
||||
assert hashlib.sha256(traypayload).hexdigest() in script
|
||||
|
||||
|
||||
def test_the_generated_script_fetches_the_package_with_the_header(
|
||||
client, db, auth_headers):
|
||||
"""Same reason as the one-liner: the package URL must not carry the token,
|
||||
or every agent install writes it into an access log."""
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
script = client.get(SCRIPT_URL,
|
||||
headers=_token_header(secret)).get_data(as_text=True)
|
||||
|
||||
assert '?t=' not in script
|
||||
assert '$BootstrapPackageHeaders = @{{ "{0}" = "{1}" }}'.format(
|
||||
TOKEN_HEADER, secret) in script
|
||||
assert ('Invoke-WebRequest -Uri $BootstrapPackageUrl '
|
||||
'-Headers $BootstrapPackageHeaders') in script
|
||||
|
||||
|
||||
def test_both_routes_take_the_token_in_a_header(client, db, auth_headers,
|
||||
servercert):
|
||||
secret = _secret(client, auth_headers, maxuses=2)['secret']
|
||||
|
||||
script = client.get(SCRIPT_URL, headers=_token_header(secret))
|
||||
assert script.status_code == 200
|
||||
assert 'ShopDB endpoint agent bootstrap' in script.get_data(as_text=True)
|
||||
assert 'install.ps1' in _files(
|
||||
client.get(PACKAGE_URL, headers=_token_header(secret)))
|
||||
assert EndpointBootstrapToken.query.one().usecount == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_a_bad_header_token_is_not_found(client, db, auth_headers, url):
|
||||
"""The header is read first, so a good ?t= cannot rescue a bad header - one
|
||||
request carries one token."""
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
response = client.get(url, query_string={'t': secret},
|
||||
headers=_token_header('sdbb_' + 'a' * 43))
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_the_query_form_still_works_and_is_audited_as_deprecated(
|
||||
client, db, auth_headers, caplog):
|
||||
"""A bay running a command pasted before this change must keep working, so
|
||||
the query form stays accepted; it is flagged in the audit row and warned
|
||||
about in the log, with no token value in either."""
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert client.get(SCRIPT_URL, query_string={'t': secret},
|
||||
environ_overrides={'REMOTE_ADDR': '10.0.0.6'}
|
||||
).status_code == 200
|
||||
|
||||
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||
action='requested').one()
|
||||
assert 'query-string token, deprecated' in row.details
|
||||
assert secret not in row.details
|
||||
warnings = [record.getMessage() for record in caplog.records
|
||||
if record.levelno >= logging.WARNING]
|
||||
assert any('query string' in message and '10.0.0.6' in message
|
||||
for message in warnings)
|
||||
assert all(secret not in message for message in warnings)
|
||||
|
||||
|
||||
def test_the_header_form_is_not_flagged_as_deprecated(client, db, auth_headers,
|
||||
caplog):
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
with caplog.at_level(logging.WARNING):
|
||||
client.get(SCRIPT_URL, headers=_token_header(secret))
|
||||
|
||||
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||
action='requested').one()
|
||||
assert 'deprecated' not in row.details
|
||||
assert not [record for record in caplog.records
|
||||
if record.levelno >= logging.WARNING]
|
||||
|
||||
|
||||
def test_the_admin_download_link_is_not_counted_as_an_un_migrated_bay(
|
||||
client, db, auth_headers, caplog):
|
||||
"""The rollout finds un-migrated bays by the deprecation WARNING, and the
|
||||
UI Download .ps1 link has to use the query form. Marked, it logs at info
|
||||
and is audited as the admin path, so an admin testing a token does not
|
||||
invent a bay that is not there."""
|
||||
data = _secret(client, auth_headers)
|
||||
path = data['scripturl'].split(BASE_URL, 1)[1]
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert client.get(path, environ_overrides={'REMOTE_ADDR': '10.0.0.7'}
|
||||
).status_code == 200
|
||||
|
||||
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||
action='requested').one()
|
||||
assert 'admin download link' in row.details
|
||||
assert 'deprecated' not in row.details
|
||||
assert data['secret'] not in row.details
|
||||
assert not [record for record in caplog.records
|
||||
if record.levelno >= logging.WARNING]
|
||||
infos = [record.getMessage() for record in caplog.records
|
||||
if record.levelno == logging.INFO]
|
||||
assert any('Download .ps1' in message and '10.0.0.7' in message
|
||||
for message in infos)
|
||||
assert all(data['secret'] not in message for message in infos)
|
||||
|
||||
|
||||
def test_no_log_or_audit_row_ever_carries_the_token_value(
|
||||
client, db, auth_headers, caplog, servercert):
|
||||
secret = _secret(client, auth_headers, maxuses=4)['secret']
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
client.get(SCRIPT_URL, query_string={'t': secret})
|
||||
client.get(SCRIPT_URL, headers=_token_header(secret))
|
||||
client.get(PACKAGE_URL, query_string={'t': secret})
|
||||
client.get(PACKAGE_URL, headers=_token_header(secret))
|
||||
|
||||
assert all(secret not in record.getMessage()
|
||||
for record in caplog.records)
|
||||
rows = AuditLog.query.filter_by(
|
||||
entitytype='EndpointBootstrapToken').all()
|
||||
assert rows
|
||||
for row in rows:
|
||||
assert secret not in (row.details or '')
|
||||
# The 12-char display prefix is the identifier the UI already shows.
|
||||
assert row.entityname == secret[:12]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_the_query_form_can_be_turned_off_once_a_site_has_re_issued(
|
||||
client, db, auth_headers, url):
|
||||
"""Off is the hardened end state, not the default: the UI download link
|
||||
uses the query form too, so a site only turns it off deliberately."""
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
Setting.set(QUERY_SETTING, False, valuetype='boolean')
|
||||
|
||||
refused = client.get(url, query_string={'t': secret})
|
||||
assert refused.status_code == 400
|
||||
assert TOKEN_HEADER in refused.get_json()['data']['error']['message']
|
||||
# The admin Download .ps1 link is a query token too: the setting is about
|
||||
# a live token landing in an access log, and that link puts one there.
|
||||
assert client.get(url, query_string={'t': secret, 'via': 'download'}
|
||||
).status_code == 400
|
||||
# Refused before any lookup, so it confirms nothing about the token.
|
||||
assert EndpointBootstrapToken.query.one().usecount == 0
|
||||
assert AuditLog.query.filter_by(
|
||||
entitytype='EndpointBootstrapToken', action='requested').count() == 0
|
||||
assert client.get(url, headers=_token_header(secret)).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_no_token_is_still_not_found_when_the_query_form_is_off(
|
||||
client, db, auth_headers, url):
|
||||
"""A bare GET presented no token, so it is unusable and answers 404 like
|
||||
every other unusable request. Calling it a query token answered 400 naming
|
||||
the header, which handed an anonymous caller the state of the switch."""
|
||||
_secret(client, auth_headers)
|
||||
Setting.set(QUERY_SETTING, False, valuetype='boolean')
|
||||
|
||||
assert client.get(url).status_code == 404
|
||||
# An empty ?t= presented nothing either.
|
||||
assert client.get(url, query_string={'t': ''}).status_code == 404
|
||||
# And it is the same answer with the query form still on.
|
||||
Setting.set(QUERY_SETTING, True, valuetype='boolean')
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
|
||||
def test_script_fetch_does_not_spend_a_use(client, db, auth_headers):
|
||||
secret = _secret(client, auth_headers, maxuses=1)['secret']
|
||||
for _ in range(3):
|
||||
@@ -421,3 +674,4 @@ def test_the_command_falls_back_to_the_request_when_no_site_url_is_set(
|
||||
base='https://localhost')
|
||||
assert data['scripturl'].startswith(
|
||||
'https://localhost/api/endpoints/siteagent/bootstrap.ps1?t=')
|
||||
assert data['scripturl'].endswith('&' + UI_LINK_QUERY)
|
||||
|
||||
@@ -689,6 +689,7 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
|
||||
body = response.get_json()
|
||||
data = body['data']
|
||||
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True,
|
||||
'revokeddeniessharedkey': False,
|
||||
'maxpendingenrollments': 500}
|
||||
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1,
|
||||
'pendingcomputers': 1, 'pending': 1}
|
||||
@@ -711,6 +712,7 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
|
||||
assert put.status_code == 200
|
||||
assert put.get_json()['data'] == {'sharedkeyallowed': False,
|
||||
'autoenroll': True,
|
||||
'revokeddeniessharedkey': False,
|
||||
'maxpendingenrollments': 500}
|
||||
bad = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'autoenroll': 'yes'}, headers=auth_headers)
|
||||
|
||||
352
tests/test_plugins/test_endpoints_enrollment_denials.py
Normal file
352
tests/test_plugins/test_endpoints_enrollment_denials.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""What a denial means at the enrollment door, and how long it lasts.
|
||||
|
||||
Three denials meet here, and each one used to be undone by the PC on the other
|
||||
side of it:
|
||||
|
||||
reject An admin refuses a request. The agent then sends a new nonce and
|
||||
the row was re-armed as a fresh cycle - which, with the shared key
|
||||
and auto-enroll on, auto-issued the credential the admin had just
|
||||
refused. A rejection binds to the identity (hostname + serial), so
|
||||
it survives any nonce; only an admin approving it clears it.
|
||||
cap The pending queue is bounded, and at the cap a PC nobody has seen
|
||||
before is refused. Abandoned rows come out first, so a queue that
|
||||
filled with junk drains itself instead of blocking every new PC.
|
||||
revoke A revoked credential falls back to the shared key, which is how
|
||||
the un-migrated fleet and the rollback to an older agent keep
|
||||
reporting. plugin.endpoints.revokeddeniessharedkey (off by
|
||||
default) turns a revoke into an eviction instead.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
|
||||
from plugins.endpoints.api import agentauth
|
||||
from plugins.endpoints.api.enrollment import (
|
||||
PENDING_CAP_SETTING, PENDING_STALE_AFTER,
|
||||
)
|
||||
from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest
|
||||
|
||||
|
||||
COLLECTOR_KEY = 'testcollectorkey'
|
||||
ENROLL_URL = '/api/endpoints/enroll'
|
||||
ALERTS_URL = '/api/endpoints/alerts'
|
||||
NONCE = 'n' * 40
|
||||
OTHER_NONCE = 'm' * 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keyheaders(app):
|
||||
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
|
||||
return {'X-API-Key': COLLECTOR_KEY}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def nolimiter(app):
|
||||
"""The per-IP limiter counts in a process-wide cache that outlives a test.
|
||||
|
||||
Nothing here is about how FAST a PC asks, and another test in the same
|
||||
worker may already have spent the budget for this window and address, so the
|
||||
limiter is off for this module and put back as it was.
|
||||
"""
|
||||
key = 'ENDPOINTS_ENROLL_RATELIMIT_ENABLED'
|
||||
previous = app.config.get(key)
|
||||
app.config[key] = False
|
||||
yield
|
||||
if previous is None:
|
||||
app.config.pop(key, None)
|
||||
else:
|
||||
app.config[key] = previous
|
||||
|
||||
|
||||
def _computer(db, hostname, serialnumber='SER-1'):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if atype is None:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
serialnumber=serialnumber, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _body(hostname='BAY-01', serialnumber='SER-1', nonce=NONCE):
|
||||
return {'hostname': hostname, 'serialnumber': serialnumber,
|
||||
'enrollnonce': nonce, 'agentversion': '1.20.0'}
|
||||
|
||||
|
||||
def _enroll(client, headers, **kwargs):
|
||||
return client.post(ENROLL_URL, json=_body(**kwargs), headers=headers)
|
||||
|
||||
|
||||
def _pending_request(client, db, keyheaders, **kwargs):
|
||||
"""A request waiting for an admin, with auto-enroll back on afterwards.
|
||||
|
||||
Auto-enroll off is what puts a clean match in the queue instead of issuing
|
||||
at once; turning it back on is what made the old code hand the retry a
|
||||
credential, so every rejection test needs both halves.
|
||||
"""
|
||||
_setting(db, 'plugin.endpoints.autoenroll', False)
|
||||
response = _enroll(client, keyheaders, **kwargs)
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
_setting(db, 'plugin.endpoints.autoenroll', True)
|
||||
return response.get_json()['data']['requestid']
|
||||
|
||||
|
||||
def _reject(client, auth_headers, requestid):
|
||||
return client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/reject',
|
||||
headers=auth_headers)
|
||||
|
||||
|
||||
def _setting(db, key, value, valuetype='boolean'):
|
||||
Setting.set(key, value, valuetype=valuetype, category='plugin.endpoints')
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _code(response):
|
||||
return response.get_json()['data']['error']['code']
|
||||
|
||||
|
||||
# -- a rejection binds to the identity ---------------------------------------
|
||||
|
||||
def test_a_new_nonce_does_not_lift_a_rejection(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
"""The bug: reinstall the agent and the retry was auto-issued the
|
||||
credential an admin had just refused."""
|
||||
_computer(db, 'BAY-01')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
assert _reject(client, auth_headers, requestid).status_code == 200
|
||||
|
||||
retry = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
|
||||
assert retry.status_code == 202, retry.get_data(as_text=True)
|
||||
assert retry.get_json()['data']['reason'] == 'rejected'
|
||||
assert retry.get_json()['data']['requestid'] == requestid
|
||||
assert EndpointCredential.query.count() == 0
|
||||
row = db.session.get(EndpointEnrollRequest, requestid)
|
||||
assert row.status == EndpointEnrollRequest.STATUS_REJECTED
|
||||
assert row.decidedat is not None
|
||||
# Still one row for this identity: the retry did not queue a second.
|
||||
assert EndpointEnrollRequest.query.count() == 1
|
||||
|
||||
|
||||
def test_a_rejection_survives_however_often_the_pc_asks(client, db, app,
|
||||
keyheaders,
|
||||
auth_headers):
|
||||
_computer(db, 'BAY-01')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
_reject(client, auth_headers, requestid)
|
||||
|
||||
for index in range(5):
|
||||
again = _enroll(client, keyheaders, nonce=('x%d' % index) * 20)
|
||||
assert again.status_code == 202
|
||||
assert again.get_json()['data']['reason'] == 'rejected'
|
||||
|
||||
assert EndpointCredential.query.count() == 0
|
||||
assert EndpointEnrollRequest.query.count() == 1
|
||||
|
||||
|
||||
def test_a_rejection_does_not_touch_a_different_pc(client, db, app,
|
||||
keyheaders, auth_headers):
|
||||
"""Identity is hostname AND serial, so one PC's rejection is its own."""
|
||||
_computer(db, 'BAY-01', serialnumber='SER-1')
|
||||
other = _computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
_reject(client, auth_headers, requestid)
|
||||
|
||||
clean = _enroll(client, keyheaders, hostname='BAY-02',
|
||||
serialnumber='SER-2')
|
||||
|
||||
assert clean.status_code == 201, clean.get_data(as_text=True)
|
||||
assert EndpointCredential.query.filter_by(assetid=other).one() \
|
||||
.revokedat is None
|
||||
|
||||
|
||||
def test_an_admin_clears_a_rejection_by_approving_it(client, db, app,
|
||||
keyheaders, auth_headers):
|
||||
"""The way back is the admin's, and it works for the reinstalled agent that
|
||||
now carries a different nonce."""
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
_reject(client, auth_headers, requestid)
|
||||
_enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
|
||||
approve = client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
assert approve.status_code == 200, approve.get_data(as_text=True)
|
||||
|
||||
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
assert issued.status_code == 201, issued.get_data(as_text=True)
|
||||
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
|
||||
.enrolledvia == 'approved'
|
||||
|
||||
|
||||
def test_an_approval_out_of_a_rejection_still_binds_to_one_installation(
|
||||
client, db, app, keyheaders, auth_headers):
|
||||
"""It binds to the installation the PC was last seen with, so a third
|
||||
machine claiming the same hostname and serial still cannot collect it."""
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
_reject(client, auth_headers, requestid)
|
||||
_enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
client.post(f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
|
||||
impostor = _enroll(client, keyheaders, nonce='z' * 40)
|
||||
assert impostor.status_code == 403
|
||||
assert _code(impostor) == 'ENROLL_NONCE_MISMATCH'
|
||||
assert EndpointCredential.query.count() == 0
|
||||
|
||||
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
assert issued.status_code == 201, issued.get_data(as_text=True)
|
||||
|
||||
|
||||
# -- the pending cap ages out abandoned rows ---------------------------------
|
||||
|
||||
def _stale(db, hostname, days):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
seen = (datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(days=days))
|
||||
row = EndpointEnrollRequest(
|
||||
hostname=hostname, serialnumber=hostname + '-SER',
|
||||
enrollnonce=NONCE, status=EndpointEnrollRequest.STATUS_PENDING,
|
||||
requestedat=seen, lastseenat=seen)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def test_the_cap_ages_out_abandoned_rows_before_refusing(client, db, app):
|
||||
"""A queue full of PCs that stopped asking must not block the one that is."""
|
||||
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
|
||||
_stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
|
||||
_stale(db, 'GONE-02', PENDING_STALE_AFTER.days + 3)
|
||||
|
||||
response = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
|
||||
serialnumber='NEW-SER'))
|
||||
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
hostnames = {row.hostname for row in EndpointEnrollRequest.query.all()}
|
||||
assert hostnames == {'NEW-01'}
|
||||
|
||||
|
||||
def test_the_age_out_spares_rows_still_being_asked_for(client, db, app):
|
||||
"""Only rows nothing has touched go, and a decided row never goes: the
|
||||
rejection above has to outlive any queue pressure."""
|
||||
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
|
||||
fresh = _stale(db, 'LIVE-01', 0)
|
||||
stillasking = _stale(db, 'LIVE-02', 0)
|
||||
decided = _stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
|
||||
decided.status = EndpointEnrollRequest.STATUS_REJECTED
|
||||
db.session.commit()
|
||||
|
||||
refused = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
|
||||
serialnumber='NEW-SER'))
|
||||
|
||||
assert refused.status_code == 503
|
||||
assert _code(refused) == 'ENROLL_PENDING_FULL'
|
||||
for row in (fresh, stillasking, decided):
|
||||
assert db.session.get(EndpointEnrollRequest, row.requestid) is not None
|
||||
|
||||
|
||||
# -- revocation and the shared key -------------------------------------------
|
||||
|
||||
def test_a_revoked_pc_keeps_the_shared_key_by_default(client, db, app,
|
||||
keyheaders,
|
||||
auth_headers):
|
||||
"""Today's behaviour, and the setting default: a revoke is a rotation, not
|
||||
an eviction, so the PC keeps reporting while it waits to enroll again."""
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
assert _enroll(client, keyheaders).status_code == 201
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
headers=auth_headers)
|
||||
|
||||
assert client.get(ALERTS_URL + '?hostname=BAY-01',
|
||||
headers=keyheaders).status_code == 200
|
||||
|
||||
|
||||
def test_revoked_denies_shared_key_evicts_the_pc(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
assert _enroll(client, keyheaders).status_code == 201
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
json={'blockreenroll': True, 'reason': 'stolen'},
|
||||
headers=auth_headers)
|
||||
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||
|
||||
refused = client.get(ALERTS_URL + '?hostname=BAY-01', headers=keyheaders)
|
||||
|
||||
assert refused.status_code == 401
|
||||
assert _code(refused) == 'SHARED_KEY_REFUSED'
|
||||
assert 'revoked' in refused.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_pc_that_never_enrolled_is_never_evicted(client, db, app,
|
||||
keyheaders, auth_headers):
|
||||
"""The un-migrated fleet has no credential row at all, so the setting can be
|
||||
turned on without silencing it."""
|
||||
_computer(db, 'BAY-01', serialnumber='SER-1')
|
||||
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
assetid = _computer(db, 'BAY-03', serialnumber='SER-3')
|
||||
_enroll(client, keyheaders, hostname='BAY-03', serialnumber='SER-3')
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
headers=auth_headers)
|
||||
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||
|
||||
assert client.get(ALERTS_URL + '?hostname=BAY-01',
|
||||
headers=keyheaders).status_code == 200
|
||||
assert client.get(ALERTS_URL + '?hostname=BAY-02',
|
||||
headers=keyheaders).status_code == 200
|
||||
assert client.get(ALERTS_URL + '?hostname=BAY-03',
|
||||
headers=keyheaders).status_code == 401
|
||||
|
||||
|
||||
def test_the_collector_contract_follows_the_same_rule(client, db, app,
|
||||
keyheaders,
|
||||
auth_headers):
|
||||
"""The generic collector path asks the plugin, not bind_hostname, so it has
|
||||
to answer the same way."""
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
assert _enroll(client, keyheaders).status_code == 201
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
headers=auth_headers)
|
||||
refused = EndpointsPlugin().agent_shared_key_refused
|
||||
|
||||
with app.test_request_context():
|
||||
assert refused('BAY-01') is False
|
||||
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||
assert refused('BAY-01') is True
|
||||
assert refused('UNKNOWN') is False
|
||||
|
||||
|
||||
def test_the_eviction_switch_is_editable_and_defaults_to_today(client, db,
|
||||
auth_headers):
|
||||
"""A setting nobody can change is a constant with extra steps."""
|
||||
read = client.get('/api/endpoints/enrollment',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert read['settings']['revokeddeniessharedkey'] is False
|
||||
|
||||
put = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'revokeddeniessharedkey': True},
|
||||
headers=auth_headers)
|
||||
assert put.status_code == 200, put.get_data(as_text=True)
|
||||
assert put.get_json()['data']['revokeddeniessharedkey'] is True
|
||||
assert Setting.get(agentauth.REVOKED_DENIES_SHARED_KEY_SETTING) is True
|
||||
|
||||
bad = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'revokeddeniessharedkey': 'yes'},
|
||||
headers=auth_headers)
|
||||
assert bad.status_code == 400
|
||||
162
tests/test_plugins/test_endpoints_settings_declared.py
Normal file
162
tests/test_plugins/test_endpoints_settings_declared.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""The two endpoints switches are declared, seeded, and have a control.
|
||||
|
||||
Both were readable by their enforcement code before they were declared:
|
||||
|
||||
plugin.endpoints.revokeddeniessharedkey agentauth.revoked_denies_shared_key
|
||||
plugin.endpoints.bootstrapquerytoken bootstrap._query_token_allowed
|
||||
|
||||
An undeclared key has no row until somebody writes one, and until it was
|
||||
declared the settings API upserted it with valuetype 'string' - so
|
||||
bool(Setting.get(key, True)) read the text 'false' as True and the switch did
|
||||
nothing. Declaring it fixes both halves: the row is seeded at plugin
|
||||
install/enable, and a PUT lands as a real boolean.
|
||||
|
||||
The UI half is checked by reading the pages. A switch with no control on an
|
||||
air-gapped box can only be changed with SQL, which is not a setting.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.endpoints.api import agentauth, bootstrap
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
VIEWS = REPO / 'plugins' / 'endpoints' / 'frontend' / 'views'
|
||||
|
||||
REVOKED_KEY = agentauth.REVOKED_DENIES_SHARED_KEY_SETTING
|
||||
QUERY_KEY = bootstrap.QUERY_TOKEN_SETTING
|
||||
|
||||
|
||||
def _declared():
|
||||
return {entry['key']: entry for entry in
|
||||
EndpointsPlugin().get_settings_defaults()}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key,value', [(REVOKED_KEY, 'false'),
|
||||
(QUERY_KEY, 'true')])
|
||||
def test_the_switch_is_declared_with_todays_behaviour(key, value):
|
||||
entry = _declared().get(key)
|
||||
|
||||
assert entry is not None, key + ' is read but never declared'
|
||||
assert entry['valuetype'] == 'boolean'
|
||||
assert entry['category'] == 'plugin.endpoints'
|
||||
# The declared default must be what the code does with no row at all, or
|
||||
# seeding a site silently changes its behaviour.
|
||||
assert entry['value'] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', [REVOKED_KEY, QUERY_KEY])
|
||||
def test_the_description_names_what_turning_it_on_costs(key):
|
||||
"""An operator on the shop floor decides from this text alone."""
|
||||
description = _declared()[key]['description']
|
||||
|
||||
assert len(description) > 120, description
|
||||
if key == REVOKED_KEY:
|
||||
# The revoke + blockreenroll rollback onto an older agent stops working.
|
||||
assert 'rollback' in description
|
||||
assert 'enrolls again' in description
|
||||
else:
|
||||
assert 're-issued' in description or 're-issue' in description
|
||||
|
||||
|
||||
def test_the_declared_defaults_match_what_the_code_reads_with_no_row(db):
|
||||
"""No Setting rows at all: the readers fall back to these same values."""
|
||||
assert Setting.query.filter_by(key=REVOKED_KEY).first() is None
|
||||
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
|
||||
|
||||
assert agentauth.revoked_denies_shared_key() is False
|
||||
assert bootstrap._query_token_allowed() is True
|
||||
|
||||
|
||||
def test_the_upgrade_pass_seeds_them_on_a_site_that_installed_earlier(app, db):
|
||||
"""install/enable fire only on a state transition, so a fleet already
|
||||
running this plugin gets these rows from `flask plugin upgrade-all`, which
|
||||
calls sync_all_plugin_settings -> _seed_plugin_settings.
|
||||
|
||||
This drives _seed_plugin_settings on a manager built for this test rather
|
||||
than the process-wide plugin_manager singleton, and that is deliberate.
|
||||
create_app() re-points that singleton, eight test modules build their own
|
||||
app, and whatever ran last on this xdist worker owns it: the symptoms are
|
||||
"no such table: settings" (seeding against another app's in-memory SQLite)
|
||||
and "'NoneType' object has no attribute 'get_all'" (registry left None).
|
||||
Both land on whichever test follows, so they move with the split - this one
|
||||
passed locally and failed on CI for exactly that reason.
|
||||
|
||||
init_app is not called here either: it re-registers the plugin blueprints,
|
||||
and twice on one app raises "The name 'backups' is already registered".
|
||||
Setting _app and _db is all the seeding routine reads.
|
||||
"""
|
||||
from shopdb.plugins import PluginManager
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
|
||||
|
||||
manager = PluginManager()
|
||||
manager._app = app
|
||||
manager._db = db
|
||||
assert manager._seed_plugin_settings(EndpointsPlugin()) is True
|
||||
|
||||
for key, value in ((REVOKED_KEY, False), (QUERY_KEY, True)):
|
||||
row = Setting.query.filter_by(key=key).one()
|
||||
assert row.valuetype == 'boolean'
|
||||
assert row.category == 'plugin.endpoints'
|
||||
assert row.get_typed_value() is value
|
||||
|
||||
|
||||
def test_a_put_lands_as_a_boolean_and_flips_the_query_token(client, db,
|
||||
auth_headers):
|
||||
"""End to end for bootstrapquerytoken through the API the page uses.
|
||||
|
||||
The declaration is what makes the upsert boolean; as a string row the read
|
||||
below would stay True whatever was saved.
|
||||
"""
|
||||
saved = client.put('/api/settings/' + QUERY_KEY, json={'value': False},
|
||||
headers=auth_headers)
|
||||
|
||||
assert saved.status_code == 200, saved.get_json()
|
||||
body = saved.get_json()['data']
|
||||
assert body['value'] is False
|
||||
assert body['valuetype'] == 'boolean'
|
||||
assert body['category'] == 'plugin.endpoints'
|
||||
assert bootstrap._query_token_allowed() is False
|
||||
|
||||
back = client.put('/api/settings/' + QUERY_KEY, json={'value': True},
|
||||
headers=auth_headers)
|
||||
assert back.get_json()['data']['value'] is True
|
||||
assert bootstrap._query_token_allowed() is True
|
||||
|
||||
|
||||
def test_the_settings_api_lists_the_declared_switch(client, db, auth_headers):
|
||||
"""The agent-key page reads GET /api/settings/<key> to render the box."""
|
||||
client.put('/api/settings/' + QUERY_KEY, json={'value': True},
|
||||
headers=auth_headers)
|
||||
|
||||
resp = client.get('/api/settings/' + QUERY_KEY, headers=auth_headers)
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['value'] is True
|
||||
|
||||
|
||||
def test_the_enrollment_page_carries_the_eviction_checkbox():
|
||||
page = (VIEWS / 'EnrollmentSettings.vue').read_text(encoding='utf-8')
|
||||
|
||||
assert 'toggle-revokeddenies' in page, 'no checkbox for the eviction switch'
|
||||
assert 'onRevokedDeniesToggle' in page
|
||||
# It sits with the two switches it belongs beside.
|
||||
assert 'toggle-sharedkey' in page and 'toggle-autoenroll' in page
|
||||
# The operator has to be told what it breaks before clicking it.
|
||||
assert 'rollback' in page
|
||||
assert 'enrolls again' in page
|
||||
|
||||
|
||||
def test_the_agent_key_page_carries_the_query_token_control():
|
||||
page = (VIEWS / 'AgentKeySettings.vue').read_text(encoding='utf-8')
|
||||
|
||||
assert QUERY_KEY in page, 'the page does not touch the setting'
|
||||
assert 'toggle-querytoken' in page
|
||||
assert 'onQueryTokenToggle' in page
|
||||
# Turning it off breaks commands already handed out, so say so.
|
||||
assert 're-issue' in page
|
||||
@@ -289,6 +289,62 @@ def test_leaf_certificate_is_imported_into_root(client, db, auth_headers):
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [leafpin]
|
||||
|
||||
|
||||
def test_installer_restricts_the_registry_key_to_system_and_admins(
|
||||
client, db, auth_headers, servercert):
|
||||
"""The shared key stays in HKLM until every consumer is on a per-PC
|
||||
credential, so what matters meanwhile is who can read it. The key inherits a
|
||||
DACL that grants BUILTIN\\Users read, which is the exposure the per-PC
|
||||
migration exists to close - the installer must not leave it that way."""
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey='true'))
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert 'function Protect-HklmShopdbKey' in script
|
||||
assert 'Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"' in script
|
||||
# SYSTEM and Administrators by SID, so a localised Windows still matches.
|
||||
assert '"S-1-5-18", "S-1-5-32-544"' in script
|
||||
# Inheritance broken WITHOUT copying the inherited entries: copying them is
|
||||
# what silently preserves the Users read.
|
||||
assert 'SetAccessRuleProtection($true, $false)' in script
|
||||
assert 'RemoveAccessRule($rule)' in script
|
||||
|
||||
|
||||
def test_the_key_is_written_before_it_is_locked(client, db, auth_headers, servercert):
|
||||
"""SetAccessControl replaces the DACL rather than merging, so a lock that ran
|
||||
first would be undone by nothing - but a write that ran after a lock the
|
||||
installer no longer holds rights for would fail. Order is the guarantee."""
|
||||
script = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey='true'))['install.ps1'].decode('ascii')
|
||||
|
||||
write = script.index('-Name "CollectorKey" -Value $CollectorKey')
|
||||
lock = script.index('Protect-HklmShopdbKey -SubKey')
|
||||
assert write < lock
|
||||
|
||||
|
||||
def test_the_registry_lock_failing_does_not_fail_the_install(
|
||||
client, db, auth_headers, servercert):
|
||||
"""A bay that cannot take the ACL still needs its agent. It must say plainly
|
||||
that the key is readable there, rather than dying or staying quiet."""
|
||||
script = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey='true'))['install.ps1'].decode('ascii')
|
||||
|
||||
lock = script.index('Protect-HklmShopdbKey -SubKey')
|
||||
tail = script[lock:lock + 600]
|
||||
assert 'catch' in tail
|
||||
assert 'readable by any logged-on user' in tail
|
||||
|
||||
|
||||
def test_the_keyless_package_still_locks_the_key(client, db, auth_headers, servercert):
|
||||
"""ServerPins live in the same key and the registry is authoritative for
|
||||
pinning, so a standard user must not be able to rewrite them either."""
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey=False))
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert b'CollectorKey' not in files['install.ps1']
|
||||
assert 'Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"' in script
|
||||
|
||||
|
||||
def test_install_script_helper_without_pins_or_key():
|
||||
script = siteagent._install_script('https://shopdb.test', '', 900,
|
||||
serverpins=[], includesharedkey=False)
|
||||
|
||||
212
tests/test_plugins/test_kiosk_network_allowlist.py
Normal file
212
tests/test_plugins/test_kiosk_network_allowlist.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Network-position admission for the two unauthenticated kiosks.
|
||||
|
||||
The device token covers unattended screens an administrator provisions once. It
|
||||
does not cover the other caller: somebody opening the kiosk page on their own
|
||||
PC, who never logs in and whose browser nobody provisioned. Per-browser
|
||||
provisioning would mean touching every PC on the floor and losing it on the next
|
||||
reimage, so those callers are admitted by where they are on the network instead.
|
||||
|
||||
What these tests hold down:
|
||||
- the allowlist actually admits a caller with no token (the feature),
|
||||
- it stays shut when unset, when the caller is outside it, and when the kiosk
|
||||
itself is off (the flag still wins),
|
||||
- a malformed entry is skipped rather than widening the match, and
|
||||
- the two kiosks do not share an allowlist.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from shopdb.api import ip_in_cidrs
|
||||
|
||||
|
||||
PLANT = '10.134.48.17'
|
||||
PLANT_CIDR = '10.134.48.0/23'
|
||||
ELSEWHERE = '10.9.100.5'
|
||||
|
||||
|
||||
# -- the pure matcher ---------------------------------------------------------
|
||||
# Separated from the request so the fail-closed edges can be stated plainly.
|
||||
|
||||
@pytest.mark.parametrize('address, cidrs, expected', [
|
||||
(PLANT, PLANT_CIDR, True),
|
||||
(PLANT, ' 10.48.249.0/26 , 10.134.48.0/23 ', True),
|
||||
(ELSEWHERE, PLANT_CIDR, False),
|
||||
# Empty or absent setting means the feature is off, not "allow everyone".
|
||||
(PLANT, '', False),
|
||||
(PLANT, None, False),
|
||||
# A malformed entry must be skipped, never treated as a wildcard.
|
||||
(PLANT, 'not-a-cidr', False),
|
||||
(PLANT, 'not-a-cidr, 10.134.48.0/23', True),
|
||||
# An unparseable caller address matches nothing.
|
||||
('', PLANT_CIDR, False),
|
||||
('unknown', PLANT_CIDR, False),
|
||||
])
|
||||
def test_ip_in_cidrs(address, cidrs, expected):
|
||||
assert ip_in_cidrs(address, cidrs) is expected
|
||||
|
||||
|
||||
# -- USB kiosk ----------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def usbkiosk(db):
|
||||
"""Self-hosted USB, kiosk ON, a token set, one employee and one drive."""
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from plugins.usb.models import USBDevice
|
||||
|
||||
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
|
||||
valuetype='boolean', category='usb'))
|
||||
db.session.add(Setting(key='usb_kiosk_token', value='a-real-token',
|
||||
valuetype='string', category='usb'))
|
||||
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
|
||||
lastname='Reyes'))
|
||||
db.session.add(USBDevice(serialnumber='USB-1', label='Blue Kingston',
|
||||
ischeckedout=False))
|
||||
db.session.commit()
|
||||
return db
|
||||
|
||||
|
||||
def _allow(db, key, value):
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
if row is None:
|
||||
db.session.add(Setting(key=key, value=value, valuetype='string',
|
||||
category='usb'))
|
||||
else:
|
||||
row.value = value
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _session(client, address):
|
||||
"""A kiosk call carrying NO token, from `address`."""
|
||||
return client.post('/api/usb/kiosk/session', json={'badge': '270015376'},
|
||||
environ_base={'REMOTE_ADDR': address})
|
||||
|
||||
|
||||
def test_usb_kiosk_refuses_tokenless_caller_when_no_allowlist(usbkiosk, client):
|
||||
"""The state before this feature: no token, no way in."""
|
||||
assert _session(client, PLANT).status_code == 404
|
||||
|
||||
|
||||
def test_usb_kiosk_admits_tokenless_caller_on_allowed_network(usbkiosk, client):
|
||||
"""THE FEATURE. Fails without the allowlist branch in _kiosk_open."""
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_usb_kiosk_refuses_caller_outside_the_allowlist(usbkiosk, client):
|
||||
"""The PXE LAN is also 10.x, which is why the range has to be narrow."""
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, ELSEWHERE).status_code == 404
|
||||
|
||||
|
||||
def test_usb_kiosk_allowlist_does_not_override_the_off_switch(usbkiosk, client):
|
||||
"""Turning the kiosk off closes it to everyone, allowlisted or not.
|
||||
|
||||
The flag is the site's decision to have this door at all; the allowlist only
|
||||
decides who walks through an open one.
|
||||
"""
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
Setting.query.filter_by(key='usb_kiosk_enabled').first().value = 'false'
|
||||
usbkiosk.session.commit()
|
||||
assert _session(client, PLANT).status_code == 404
|
||||
|
||||
|
||||
def test_usb_kiosk_token_still_works_from_any_network(usbkiosk, client):
|
||||
"""Wall screens keep working, and are not confined to the allowlist."""
|
||||
response = client.post('/api/usb/kiosk/session',
|
||||
json={'badge': '270015376'},
|
||||
headers={'X-Kiosk-Token': 'a-real-token'},
|
||||
environ_base={'REMOTE_ADDR': ELSEWHERE})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_usb_allowlist_does_not_open_the_parts_kiosk(usbkiosk, client):
|
||||
"""Separate screens, separate rooms, separate settings.
|
||||
|
||||
The tokens are deliberately separate so provisioning one does not grant the
|
||||
other. An allowlist shared between them would hand back exactly that.
|
||||
"""
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
Setting.query.filter_by(key='usb_kiosk_enabled').first().value = 'true'
|
||||
usbkiosk.session.add(Setting(key='printedparts_kiosk_enabled', value='true',
|
||||
valuetype='boolean', category='printedparts'))
|
||||
usbkiosk.session.commit()
|
||||
response = client.get('/api/printedparts/kiosk/lookup?code=ANY',
|
||||
environ_base={'REMOTE_ADDR': PLANT})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# -- what the page probes on load ---------------------------------------------
|
||||
|
||||
def test_empty_badge_probe_separates_refused_from_admitted(usbkiosk, client):
|
||||
"""The kiosk page asks the server on load whether it may be used.
|
||||
|
||||
It cannot guess: the enabled flag is public and only says the site HAS a
|
||||
kiosk, and a caller admitted by network carries no token to look for. So the
|
||||
page sends a badge-less session request and reads the status.
|
||||
|
||||
That only works while the gate runs BEFORE badge handling - refused is 404,
|
||||
admitted is the badge validation error. If someone reorders those, the page
|
||||
starts telling admitted screens they are not set up, so hold it here.
|
||||
"""
|
||||
refused = client.post('/api/usb/kiosk/session', json={'badge': ''},
|
||||
environ_base={'REMOTE_ADDR': ELSEWHERE})
|
||||
assert refused.status_code == 404
|
||||
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
admitted = client.post('/api/usb/kiosk/session', json={'badge': ''},
|
||||
environ_base={'REMOTE_ADDR': PLANT})
|
||||
assert admitted.status_code != 404
|
||||
assert admitted.status_code < 500
|
||||
|
||||
|
||||
def test_the_probe_records_nothing(usbkiosk, client):
|
||||
"""A page load must not leave a checkout, a log row, or a resolved badge."""
|
||||
from plugins.usb.models import USBCheckout
|
||||
|
||||
_allow(usbkiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
client.post('/api/usb/kiosk/session', json={'badge': ''},
|
||||
environ_base={'REMOTE_ADDR': PLANT})
|
||||
assert USBCheckout.query.count() == 0
|
||||
|
||||
|
||||
# -- provisioning link --------------------------------------------------------
|
||||
# The settings API masks any key named '*token*', so the settings page shows
|
||||
# '********'. An administrator who copies that provisions a screen with the mask
|
||||
# and gets a silent 404 - the failure that cost real time on 2026-09-16. These
|
||||
# hold the way out: a named, permission-gated route that hands a working setup
|
||||
# link to somebody already allowed to set the token.
|
||||
|
||||
def test_provisioning_link_does_not_hand_back_the_mask(usbkiosk, client,
|
||||
auth_headers):
|
||||
"""It used to answer '/usb-kiosk?token=<value>' and this test pinned that
|
||||
literal string. The link has since become a single-use setup code, which is
|
||||
a better answer to the same problem - a query string ends up in history,
|
||||
referrers and proxy logs - so what is held here is the property rather than
|
||||
the shape: a usable link, and never the mask."""
|
||||
response = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
url = response.get_json()['data']['url']
|
||||
assert '*' not in url
|
||||
# The token itself must not ride in the URL either.
|
||||
assert 'a-real-token' not in url
|
||||
assert client.get(url).status_code == 302
|
||||
|
||||
|
||||
def test_provisioning_link_needs_permission(usbkiosk, client):
|
||||
"""Anonymous callers cannot read the token out of this route."""
|
||||
response = client.get('/api/usb/kiosk/provisioning-link')
|
||||
assert response.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_provisioning_link_says_so_when_no_token_is_set(usbkiosk, client,
|
||||
auth_headers):
|
||||
Setting.query.filter_by(key='usb_kiosk_token').first().value = ''
|
||||
usbkiosk.session.commit()
|
||||
response = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
assert 'Generate' in response.get_json()['data']['error']['message']
|
||||
77
tests/test_plugins/test_manifest_core_version_floor.py
Normal file
77
tests/test_plugins/test_manifest_core_version_floor.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""usb and printedparts declare a core_version floor they actually need.
|
||||
|
||||
Both reach for the network-position allowlist that arrived on the contract
|
||||
surface in 0.35.0 (commit "Admit kiosk callers by network position"): ip_in_cidrs
|
||||
and request_ip_allowlisted landed together in that bump, so either one witnesses
|
||||
the same floor. Their manifests claimed >=0.1.0 and >=0.16.0, so the loader would
|
||||
have accepted them on a core whose shopdb.api has no such name - the plugin then
|
||||
fails at first import of its routes instead of at the version check that exists
|
||||
to catch exactly this.
|
||||
|
||||
The witness is named PER PLUGIN and not shared. usb needs the matcher itself,
|
||||
because its admission is the allowlist minus the site's proxies and the
|
||||
addresses no caller can have, which the one-call helper does not express;
|
||||
printedparts is the plain case and uses ip_in_cidrs. A single shared
|
||||
name went red the moment usb moved, which said "the floor is wrong" when the
|
||||
floor was right.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import Version
|
||||
|
||||
from shopdb import __contract_version__
|
||||
|
||||
PLUGINS = pathlib.Path(__file__).resolve().parents[2] / 'plugins'
|
||||
|
||||
# The contract version that added request_ip_allowlisted / ip_in_cidrs.
|
||||
IP_ALLOWLIST_CONTRACT = '0.35.0'
|
||||
|
||||
# plugin, the 0.35.0 name it really imports, the floor that name implies
|
||||
CASES = [
|
||||
('usb', 'ip_in_cidrs', IP_ALLOWLIST_CONTRACT),
|
||||
# Was request_ip_allowlisted until 2026-09-18, when the parts kiosk moved to
|
||||
# client_ip + ip_in_cidrs so it could SUBTRACT proxy and loopback addresses
|
||||
# before consulting the allowlist. Same contract release, same floor.
|
||||
('printedparts', 'ip_in_cidrs', IP_ALLOWLIST_CONTRACT),
|
||||
]
|
||||
|
||||
|
||||
def _manifest(name):
|
||||
return json.loads((PLUGINS / name / 'manifest.json').read_text())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,witness,needed', CASES)
|
||||
def test_manifest_excludes_a_core_without_the_name(name, witness, needed):
|
||||
"""A core one minor below the name's arrival must be refused."""
|
||||
spec = SpecifierSet(_manifest(name)['core_version'])
|
||||
too_old = Version(needed)
|
||||
too_old = Version(f'{too_old.major}.{too_old.minor - 1}.0')
|
||||
assert too_old not in spec
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,witness,needed', CASES)
|
||||
def test_manifest_admits_the_running_core(name, witness, needed):
|
||||
"""And the floor must not have overshot what this core provides."""
|
||||
spec = SpecifierSet(_manifest(name)['core_version'])
|
||||
assert Version(__contract_version__) in spec
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,witness,needed', CASES)
|
||||
def test_plugin_really_imports_the_name(name, witness, needed):
|
||||
"""Pins the floor to the reason for it: drop the import and this test says
|
||||
the floor can come back down."""
|
||||
sources = (PLUGINS / name).rglob('*.py')
|
||||
assert any(witness in path.read_text() for path in sources), name
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,witness,needed', CASES)
|
||||
def test_the_name_is_on_the_contract_surface(name, witness, needed):
|
||||
"""Each witness is a contract name, not an internal import a plugin reached
|
||||
through - which is the only reason the manifest floor can speak for it."""
|
||||
import shopdb.api
|
||||
|
||||
assert witness in shopdb.api.__all__
|
||||
93
tests/test_plugins/test_notifications_alertscope_storage.py
Normal file
93
tests/test_plugins/test_notifications_alertscope_storage.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""alertscope is TEXT in the database, a dict in Python.
|
||||
|
||||
Migration 0006 ships the column as TEXT because a site still runs MySQL 5.6,
|
||||
which has no JSON type. The model declared db.JSON, so create-all built a column
|
||||
the migration path never produces and the two disagreed about the same column.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import Text, inspect
|
||||
|
||||
from plugins.notifications.models.notification import Notification
|
||||
|
||||
|
||||
def test_column_type_matches_the_migration(app):
|
||||
"""The model's storage type must be the one migration 0006 creates."""
|
||||
column = inspect(Notification).columns['alertscope']
|
||||
assert isinstance(column.type.impl_instance
|
||||
if hasattr(column.type, 'impl_instance')
|
||||
else column.type.impl, Text)
|
||||
|
||||
|
||||
def test_ddl_has_no_json_column(app, db):
|
||||
"""MySQL 5.6 fails on a JSON column with a bare syntax error, so the
|
||||
emitted DDL must not contain one."""
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
ddl = str(CreateTable(Notification.__table__).compile(db.engine))
|
||||
alertscope_line = [line for line in ddl.splitlines()
|
||||
if 'alertscope' in line][0]
|
||||
assert 'JSON' not in alertscope_line.upper()
|
||||
|
||||
|
||||
def test_dict_round_trips(app, db):
|
||||
"""A scope assigned as a dict reads back as a dict."""
|
||||
row = Notification(notification='fleet alert', alertfleet=True,
|
||||
alerttimeout=0,
|
||||
alertscope={'hostname': ['BAY-01', 'BAY-02']})
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
notificationid = row.notificationid
|
||||
db.session.expire_all()
|
||||
|
||||
reread = db.session.get(Notification, notificationid)
|
||||
assert reread.alertscope == {'hostname': ['BAY-01', 'BAY-02']}
|
||||
|
||||
|
||||
def test_stored_value_is_json_text(app, db):
|
||||
"""What lands in the column is a JSON string, readable by a site that
|
||||
queries the table directly."""
|
||||
row = Notification(notification='fleet alert', alertfleet=True,
|
||||
alerttimeout=0, alertscope={'computertype': 'CMM PC'})
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
|
||||
raw = db.session.execute(
|
||||
db.text('SELECT alertscope FROM notifications WHERE notificationid = :i'),
|
||||
{'i': row.notificationid}).scalar()
|
||||
assert isinstance(raw, str)
|
||||
assert json.loads(raw) == {'computertype': 'CMM PC'}
|
||||
|
||||
|
||||
def test_null_scope_stays_null(app, db):
|
||||
"""No scope means the whole fleet; it must not become the string 'null'."""
|
||||
row = Notification(notification='fleet alert', alertfleet=True,
|
||||
alerttimeout=0, alertscope=None)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
|
||||
raw = db.session.execute(
|
||||
db.text('SELECT alertscope FROM notifications WHERE notificationid = :i'),
|
||||
{'i': row.notificationid}).scalar()
|
||||
assert raw is None
|
||||
assert row.alertscope is None
|
||||
|
||||
|
||||
def test_unparseable_text_is_handed_back_raw(app, db):
|
||||
"""A hand-edited row must be visible and fixable, not an exception on read.
|
||||
The endpoints matcher already treats a non-dict scope as matching nothing.
|
||||
"""
|
||||
row = Notification(notification='fleet alert', alertfleet=True,
|
||||
alerttimeout=0)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
db.session.execute(
|
||||
db.text('UPDATE notifications SET alertscope = :v '
|
||||
'WHERE notificationid = :i'),
|
||||
{'v': 'not json at all', 'i': row.notificationid})
|
||||
db.session.commit()
|
||||
db.session.expire_all()
|
||||
|
||||
reread = db.session.get(Notification, row.notificationid)
|
||||
assert reread.alertscope == 'not json at all'
|
||||
92
tests/test_plugins/test_printedparts_kiosk_admission.py
Normal file
92
tests/test_plugins/test_printedparts_kiosk_admission.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""How the parts kiosk decides a caller is admitted by network position.
|
||||
|
||||
The USB kiosk closed this on 2026-09-18 and this one did not, so the two read
|
||||
differently for no reason anybody chose. The hole is the same one: a reverse
|
||||
proxy normally sits ON the network a site enters in the allowlist, so a request
|
||||
wearing the proxy's own address matched the range and was admitted while
|
||||
carrying no evidence at all about who sent it.
|
||||
|
||||
Shape of the requests here matches production: the layer in front (IIS with the
|
||||
X-Forwarded-For rewrite, or nginx plus ProxyFix) has already resolved the caller
|
||||
into REMOTE_ADDR, and the app never reads the header itself.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
PLANT = '10.134.48.17'
|
||||
PLANT_CIDR = '10.134.48.0/23'
|
||||
ELSEWHERE = '10.9.100.5'
|
||||
# Inside the allowlisted range, which is where a proxy actually lives and the
|
||||
# reason the un-subtracted check was wrong.
|
||||
PROXY = '10.134.48.5'
|
||||
PROXY_CIDR = '10.134.48.5/32'
|
||||
KIOSK_TOKEN = 'parts-kiosk-admission-token'
|
||||
|
||||
|
||||
def _set(key, value):
|
||||
Setting.set(key, value, valuetype='string', category='printedparts')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kiosk(app, db):
|
||||
_set('printedparts_kiosk_enabled', 'true')
|
||||
_set('printedparts_kiosk_token', KIOSK_TOKEN)
|
||||
_set('printedparts_kiosk_allowed_cidrs', '')
|
||||
_set('printedparts_kiosk_trusted_proxies', '')
|
||||
return app
|
||||
|
||||
|
||||
def _open_from(app, address):
|
||||
"""Ask the gate directly: it is the unit under test, not a route's shape.
|
||||
|
||||
The request context comes from the REAL app, so Setting.get reaches the same
|
||||
database the fixture wrote to.
|
||||
"""
|
||||
from plugins.printedparts.api.routes import _kiosk_open
|
||||
|
||||
with app.test_request_context('/', environ_base={'REMOTE_ADDR': address}):
|
||||
return _kiosk_open()
|
||||
|
||||
|
||||
def test_a_caller_on_an_allowed_network_is_admitted(kiosk):
|
||||
_set('printedparts_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _open_from(kiosk, PLANT) is True
|
||||
|
||||
|
||||
def test_a_caller_outside_every_allowed_network_is_refused(kiosk):
|
||||
_set('printedparts_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _open_from(kiosk, ELSEWHERE) is False
|
||||
|
||||
|
||||
def test_a_request_wearing_the_proxy_address_fails_closed(kiosk):
|
||||
"""THE FIX. The proxy forwarded nothing, so its own address arrived - and it
|
||||
sits inside the allowlist. No evidence about the sender means refuse."""
|
||||
_set('printedparts_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set('printedparts_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _open_from(kiosk, PROXY) is False
|
||||
# ... and the feature still works for a real caller on the same network, or
|
||||
# the subtraction has taken the allowlist with it.
|
||||
assert _open_from(kiosk, PLANT) is True
|
||||
|
||||
|
||||
def test_loopback_is_never_a_kiosk_client(kiosk):
|
||||
"""Needs no configuration: no site's kiosk is the server itself. This is
|
||||
also the shape a misconfigured front end produces, where every request
|
||||
arrives as 127.0.0.1."""
|
||||
_set('printedparts_kiosk_allowed_cidrs', '127.0.0.0/8, ' + PLANT_CIDR)
|
||||
assert _open_from(kiosk, '127.0.0.1') is False
|
||||
|
||||
|
||||
def test_the_kiosk_flag_still_governs(kiosk):
|
||||
"""Network position admits nobody while the kiosk is off."""
|
||||
_set('printedparts_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set('printedparts_kiosk_enabled', 'false')
|
||||
assert _open_from(kiosk, PLANT) is False
|
||||
|
||||
|
||||
def test_an_empty_allowlist_admits_nobody(kiosk):
|
||||
"""Token-only, which is the shipped default."""
|
||||
assert _open_from(kiosk, PLANT) is False
|
||||
499
tests/test_plugins/test_usb_kiosk_admission.py
Normal file
499
tests/test_plugins/test_usb_kiosk_admission.py
Normal file
@@ -0,0 +1,499 @@
|
||||
"""How the USB kiosk decides a caller is admitted, and what that costs.
|
||||
|
||||
The kiosk writes without a login, so everything here is about the two things
|
||||
standing in for one: where the caller is on the network, and a device token.
|
||||
Three properties this file holds down, each of which was missing:
|
||||
|
||||
- a caller cannot choose its own address. X-Forwarded-For is a request
|
||||
header; unless the site has said where its proxies are, it is ignored.
|
||||
- a range that admits everybody cannot be saved by accident.
|
||||
- the device token does not travel in a URL, and the badge-to-name answer
|
||||
cannot be walked by whoever is admitted.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.api import cache
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
PLANT = '10.134.48.17'
|
||||
PLANT_CIDR = '10.134.48.0/23'
|
||||
ELSEWHERE = '10.9.100.5'
|
||||
# The reverse proxy sits ON the plant network, which is the normal place for it
|
||||
# and the reason the old fail-closed claim was false: its own address matched
|
||||
# the allowlist. A proxy parked outside the range, as this file used to have,
|
||||
# proves nothing.
|
||||
PROXY = '10.134.48.5'
|
||||
PROXY_CIDR = '10.134.48.5/32'
|
||||
KIOSK_TOKEN = 'usb-kiosk-admission-token'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kiosk(db):
|
||||
"""Self-hosted USB, kiosk ON, a token set, one employee, one drive."""
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from plugins.usb.models import USBDevice
|
||||
|
||||
# Session-scoped app means one SimpleCache for the whole run, and the badge
|
||||
# limiter counts in it. Start every test with an empty budget.
|
||||
cache.clear()
|
||||
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
|
||||
valuetype='boolean', category='usb'))
|
||||
db.session.add(Setting(key='usb_kiosk_token', value=KIOSK_TOKEN,
|
||||
valuetype='string', category='usb'))
|
||||
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
|
||||
lastname='Reyes'))
|
||||
db.session.add(USBDevice(serialnumber='USB-1', label='Blue Kingston',
|
||||
ischeckedout=False))
|
||||
db.session.commit()
|
||||
return db
|
||||
|
||||
|
||||
def _set(db, key, value, valuetype='string'):
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
if row is None:
|
||||
db.session.add(Setting(key=key, value=value, valuetype=valuetype,
|
||||
category='usb'))
|
||||
else:
|
||||
row.value = value
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _session(client, address, badge='270015376', **headers):
|
||||
return client.post('/api/usb/kiosk/session', json={'badge': badge},
|
||||
environ_base={'REMOTE_ADDR': address},
|
||||
headers=headers)
|
||||
|
||||
|
||||
# -- where the caller is ------------------------------------------------------
|
||||
# The stack that ships resolves this BEFORE Flask: IIS -> HttpPlatformHandler ->
|
||||
# waitress on loopback with --trusted-proxy=127.0.0.1 --trusted-proxy-count=1,
|
||||
# which takes the last X-Forwarded-For hop into REMOTE_ADDR. So REMOTE_ADDR in
|
||||
# these tests is what that layer decided, and the header beside it is what the
|
||||
# app is required to ignore.
|
||||
|
||||
def test_the_app_admits_on_the_address_the_wsgi_layer_resolved(kiosk, client):
|
||||
"""Production shape: waitress already promoted the caller into REMOTE_ADDR."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_a_caller_cannot_forward_itself_into_the_allowlist(kiosk, client):
|
||||
"""THE FIX. The forwarding header is not evidence of anything here.
|
||||
|
||||
Without it, anyone who could reach the app sent
|
||||
'X-Forwarded-For: <an allowlisted address>' and was admitted to an
|
||||
unauthenticated write from anywhere on the network.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, ELSEWHERE,
|
||||
**{'X-Forwarded-For': PLANT}).status_code == 404
|
||||
|
||||
|
||||
def test_the_header_cannot_take_an_admitted_caller_away_either(kiosk, client):
|
||||
"""Read in either direction, the header is simply not consulted.
|
||||
|
||||
Stated as its own case because a half-measure that only ignored the header
|
||||
when it would have ADMITTED would still leave the app parsing it, and the
|
||||
next person would wire the other branch back up.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PLANT,
|
||||
**{'X-Forwarded-For': ELSEWHERE}).status_code == 200
|
||||
|
||||
|
||||
def test_declaring_a_proxy_does_not_turn_header_reading_back_on(kiosk, client):
|
||||
"""usb_kiosk_trusted_proxies subtracts addresses; it never adds a source.
|
||||
|
||||
The version this replaced read X-Forwarded-For once this setting named the
|
||||
peer. On the Windows stack the peer is never the proxy - waitress replaced
|
||||
it with what the proxy forwarded - so that branch could not fire in
|
||||
production, and anywhere it could it handed the header back its authority.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', '10.20.30.40/32')
|
||||
assert _session(client, '10.20.30.40',
|
||||
**{'X-Forwarded-For': PLANT}).status_code == 404
|
||||
|
||||
|
||||
def test_a_request_wearing_the_proxy_address_fails_closed(kiosk, client):
|
||||
"""THE FIX, and the case the old "fails closed" test did not cover.
|
||||
|
||||
A proxy on the plant network forwards nothing - it is misconfigured, or
|
||||
something bypassed it - so the request arrives as the proxy's own address,
|
||||
which is inside usb_kiosk_allowed_cidrs. It carries no evidence about who
|
||||
sent it and must be refused, not admitted.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _session(client, PROXY).status_code == 404
|
||||
# And a caller on the same allowlisted network is still admitted, or the
|
||||
# subtraction has taken the feature with it.
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_the_proxy_is_refused_even_carrying_a_forwarding_header(kiosk, client):
|
||||
"""Its own address is what arrived, whatever it says about somebody else."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _session(client, PROXY,
|
||||
**{'X-Forwarded-For': PLANT}).status_code == 404
|
||||
|
||||
|
||||
def test_loopback_is_never_a_kiosk_client(kiosk, client):
|
||||
"""The shipped Windows default with no X-Forwarded-For at all.
|
||||
|
||||
HttpPlatformHandler connects from 127.0.0.1, waitress has nothing to
|
||||
promote, and every caller then wears the app server's own address. A site
|
||||
whose allowlist happens to cover loopback must not turn that into admission
|
||||
for everyone who can reach the server.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', '127.0.0.0/8, ' + PLANT_CIDR)
|
||||
assert _session(client, '127.0.0.1').status_code == 404
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_a_proxy_address_is_refused_even_when_nobody_declared_it(kiosk, client):
|
||||
"""Honest limit, written down. Loopback is refused with no setting at all;
|
||||
any OTHER proxy address is indistinguishable from a PC on that network, so
|
||||
the site has to name it. This test is what says so out loud."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PROXY).status_code == 200
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _session(client, PROXY).status_code == 404
|
||||
|
||||
|
||||
def test_the_token_still_works_from_a_proxy_address(kiosk, client):
|
||||
"""Subtracting proxies is about admission BY NETWORK. A screen presenting
|
||||
its device token has said who it is and is not affected."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, 'usb_kiosk_trusted_proxies', PROXY_CIDR)
|
||||
assert _session(client, PROXY,
|
||||
**{'X-Kiosk-Token': KIOSK_TOKEN}).status_code == 200
|
||||
|
||||
|
||||
def test_todays_deployment_is_unchanged(kiosk, client):
|
||||
"""No trusted proxy set - the default - still admits by connecting peer."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
# -- what may be saved in an allowlist ---------------------------------------
|
||||
|
||||
@pytest.mark.parametrize('value, refused', [
|
||||
('', False),
|
||||
('192.0.2.0/24', False),
|
||||
(' 10.134.48.0/23 , 10.48.249.0/26 ', False),
|
||||
('0.0.0.0/0', True),
|
||||
('::/0', True),
|
||||
('10.0.0.0/8', True),
|
||||
('192.0.2.0/24, 0.0.0.0/0', True),
|
||||
('not-a-cidr', True),
|
||||
])
|
||||
def test_kiosk_cidr_problem(value, refused):
|
||||
from plugins.usb.api.routes import kiosk_cidr_problem
|
||||
|
||||
assert (kiosk_cidr_problem(value) is not None) is refused
|
||||
|
||||
|
||||
def test_a_wide_range_is_allowed_when_a_site_says_so_deliberately():
|
||||
from plugins.usb.api.routes import kiosk_cidr_problem
|
||||
|
||||
assert kiosk_cidr_problem('10.0.0.0/8', allowwide=True) is None
|
||||
# Never, though: the override is about a big site, not about no bound.
|
||||
assert kiosk_cidr_problem('0.0.0.0/0', allowwide=True) is not None
|
||||
|
||||
|
||||
def test_saving_everything_is_refused_with_a_reason(kiosk, client, auth_headers):
|
||||
"""THE FIX, at the place the administrator is standing."""
|
||||
response = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '0.0.0.0/0'}, headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'without a login' in message
|
||||
assert Setting.query.filter_by(key='usb_kiosk_allowed_cidrs').first() is None
|
||||
|
||||
|
||||
def test_a_sane_range_still_saves(kiosk, client, auth_headers):
|
||||
response = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': PLANT_CIDR}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert Setting.get('usb_kiosk_allowed_cidrs') == PLANT_CIDR
|
||||
|
||||
|
||||
def test_the_override_lets_a_flat_site_save_its_range(kiosk, client,
|
||||
auth_headers):
|
||||
assert client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '10.0.0.0/8'},
|
||||
headers=auth_headers).status_code == 400
|
||||
_set(kiosk, 'usb_kiosk_allow_wide_cidrs', 'true', valuetype='boolean')
|
||||
assert client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '10.0.0.0/8'},
|
||||
headers=auth_headers).status_code == 200
|
||||
|
||||
|
||||
def test_an_override_turned_off_stays_off(kiosk, client, auth_headers):
|
||||
"""Stored as a string, 'false' is a truthy Python value. It must not be one."""
|
||||
_set(kiosk, 'usb_kiosk_allow_wide_cidrs', 'false')
|
||||
assert client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '10.0.0.0/8'},
|
||||
headers=auth_headers).status_code == 400
|
||||
|
||||
|
||||
def test_the_trusted_proxy_list_is_validated_too(kiosk, client, auth_headers):
|
||||
"""A proxy list that matches everything would trust every caller's header."""
|
||||
assert client.put('/api/settings/usb_kiosk_trusted_proxies',
|
||||
json={'value': '0.0.0.0/0'},
|
||||
headers=auth_headers).status_code == 400
|
||||
|
||||
|
||||
def test_other_settings_are_not_touched_by_the_guard(kiosk, client,
|
||||
auth_headers):
|
||||
"""The before_request hook runs on every save; it may only judge its own."""
|
||||
response = client.put('/api/settings/site_name',
|
||||
json={'value': '0.0.0.0/0'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# -- who may find out what the rules are --------------------------------------
|
||||
# The validator runs in a before_request, which is ahead of the view and so
|
||||
# ahead of @require_permission. That ordering answered an anonymous caller 400
|
||||
# with the reason a range was refused, while an anonymous caller who happened to
|
||||
# send a sane range fell through to 401 - the difference being a free oracle on
|
||||
# the allowlist rules.
|
||||
|
||||
def test_an_anonymous_probe_is_refused_before_the_value_is_judged(kiosk, client):
|
||||
"""THE FIX. Authentication decides first."""
|
||||
refused = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '0.0.0.0/0'})
|
||||
assert refused.status_code in (401, 403)
|
||||
assert 'without a login' not in refused.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_the_probe_cannot_tell_a_bad_range_from_a_good_one(kiosk, client):
|
||||
"""Both answers must be the same one, or the oracle is still there."""
|
||||
bad = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': '0.0.0.0/0'})
|
||||
good = client.put('/api/settings/usb_kiosk_allowed_cidrs',
|
||||
json={'value': PLANT_CIDR})
|
||||
assert bad.status_code == good.status_code
|
||||
assert (bad.get_json()['data']['error']['code']
|
||||
== good.get_json()['data']['error']['code'])
|
||||
assert Setting.query.filter_by(key='usb_kiosk_allowed_cidrs').first() is None
|
||||
|
||||
|
||||
# -- the badge budget is a setting, not a config file -------------------------
|
||||
# It was app-config only, which on an air-gapped server means editing
|
||||
# instance/config.py and restarting IIS. In practice that is no off switch at
|
||||
# all, and the only lever anybody had was turning the whole kiosk off.
|
||||
|
||||
def test_the_badge_limiter_settings_are_declared(kiosk):
|
||||
"""Declared where the app seeds defaults, or nothing creates the rows."""
|
||||
from plugins.usb.plugin import USBPlugin
|
||||
|
||||
declared = {entry['key']: entry
|
||||
for entry in USBPlugin().get_settings_defaults()}
|
||||
assert declared['usb_kiosk_badge_limit_enabled']['value'] == 'true'
|
||||
assert declared['usb_kiosk_badge_limit_enabled']['valuetype'] == 'boolean'
|
||||
assert declared['usb_kiosk_badge_miss_max']['value'] == '15'
|
||||
assert declared['usb_kiosk_badge_miss_max']['valuetype'] == 'integer'
|
||||
|
||||
|
||||
def test_the_budget_setting_moves_the_bound(kiosk, client, app):
|
||||
"""A site dials the number down and the kiosk stops there."""
|
||||
from plugins.usb.api.routes import KIOSK_BADGE_MISS_MAX
|
||||
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
_set(kiosk, KIOSK_BADGE_MISS_MAX, '2', valuetype='integer')
|
||||
# The app-config override the suite normally uses has to be out of the way,
|
||||
# or it would answer instead of the setting.
|
||||
previous = app.config.pop('USB_KIOSK_BADGE_MISS_MAX', None)
|
||||
app.config['USB_KIOSK_BADGE_LIMIT_ENABLED'] = True
|
||||
try:
|
||||
for _ in range(2):
|
||||
assert _session(client, PLANT, badge='111111111').status_code == 422
|
||||
assert _session(client, PLANT, badge='111111111').status_code == 429
|
||||
finally:
|
||||
app.config.pop('USB_KIOSK_BADGE_LIMIT_ENABLED', None)
|
||||
if previous is not None:
|
||||
app.config['USB_KIOSK_BADGE_MISS_MAX'] = previous
|
||||
|
||||
|
||||
def test_a_nonsense_budget_falls_back_to_the_shipped_one(kiosk, app):
|
||||
"""Zero is not "no limit" here - it would refuse the first scan of every
|
||||
window - and a settings page can store a blank or a typo just as easily."""
|
||||
from plugins.usb.api.routes import (BADGE_MISS_MAX, KIOSK_BADGE_MISS_MAX,
|
||||
_badge_miss_budget)
|
||||
|
||||
previous = app.config.pop('USB_KIOSK_BADGE_MISS_MAX', None)
|
||||
try:
|
||||
for stored in ('', '0', '-4', 'lots'):
|
||||
_set(kiosk, KIOSK_BADGE_MISS_MAX, stored, valuetype='integer')
|
||||
with app.test_request_context('/'):
|
||||
assert _badge_miss_budget()[1] == BADGE_MISS_MAX
|
||||
finally:
|
||||
if previous is not None:
|
||||
app.config['USB_KIOSK_BADGE_MISS_MAX'] = previous
|
||||
|
||||
|
||||
def test_the_kill_switch_is_reachable_without_editing_a_config_file(kiosk, app):
|
||||
"""And it defaults to today's behaviour: on.
|
||||
|
||||
Off under TESTING is an arrangement for this suite, not the default, so the
|
||||
first assertion here is also what stops "the limiter is off" quietly
|
||||
becoming true everywhere.
|
||||
"""
|
||||
from plugins.usb.api.routes import (KIOSK_BADGE_LIMIT_ENABLED,
|
||||
_badge_limit_enabled)
|
||||
|
||||
previous = app.config.pop('USB_KIOSK_BADGE_LIMIT_ENABLED', None)
|
||||
try:
|
||||
with app.test_request_context('/'):
|
||||
app.config['TESTING'] = False
|
||||
try:
|
||||
assert _badge_limit_enabled() is True
|
||||
_set(kiosk, KIOSK_BADGE_LIMIT_ENABLED, 'false',
|
||||
valuetype='boolean')
|
||||
assert _badge_limit_enabled() is False
|
||||
finally:
|
||||
app.config['TESTING'] = True
|
||||
finally:
|
||||
if previous is not None:
|
||||
app.config['USB_KIOSK_BADGE_LIMIT_ENABLED'] = previous
|
||||
|
||||
|
||||
# -- provisioning a screen ----------------------------------------------------
|
||||
|
||||
def test_the_setup_link_does_not_carry_the_token(kiosk, client, auth_headers):
|
||||
"""THE FIX. A query string ends up in history, referrers and proxy logs."""
|
||||
response = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
url = response.get_json()['data']['url']
|
||||
assert KIOSK_TOKEN not in url
|
||||
assert url.startswith('/api/usb/kiosk/setup?code=')
|
||||
|
||||
|
||||
def test_opening_the_setup_link_provisions_that_browser(kiosk, client,
|
||||
auth_headers):
|
||||
"""One open on the screen, and its later calls are admitted with no token."""
|
||||
url = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers).get_json()['data']['url']
|
||||
|
||||
redirected = client.get(url)
|
||||
assert redirected.status_code == 302
|
||||
assert redirected.headers['Location'].endswith('/usb-kiosk')
|
||||
cookie = redirected.headers['Set-Cookie']
|
||||
assert 'usbkiosktoken=' + KIOSK_TOKEN in cookie
|
||||
assert 'HttpOnly' in cookie
|
||||
|
||||
# No X-Kiosk-Token header, no allowlist: the cookie is the whole credential.
|
||||
assert _session(client, ELSEWHERE).status_code == 200
|
||||
|
||||
|
||||
def test_a_setup_code_is_good_once(kiosk, client, auth_headers):
|
||||
url = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers).get_json()['data']['url']
|
||||
assert client.get(url).status_code == 302
|
||||
|
||||
second = client.get(url)
|
||||
assert second.status_code == 410
|
||||
assert 'Generate' in second.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_made_up_setup_code_provisions_nothing(kiosk, client):
|
||||
response = client.get('/api/usb/kiosk/setup?code=not-a-real-code')
|
||||
assert response.status_code == 410
|
||||
assert 'Set-Cookie' not in response.headers
|
||||
|
||||
|
||||
def test_an_expired_setup_code_is_dead(kiosk, client, auth_headers):
|
||||
from plugins.usb.api.routes import KIOSK_SETUP_CODE
|
||||
|
||||
url = client.get('/api/usb/kiosk/provisioning-link',
|
||||
headers=auth_headers).get_json()['data']['url']
|
||||
pending = Setting.get(KIOSK_SETUP_CODE)
|
||||
_set(kiosk, KIOSK_SETUP_CODE, pending.split('|')[0] + '|1')
|
||||
assert client.get(url).status_code == 410
|
||||
|
||||
|
||||
def test_the_old_header_still_admits_screens_already_on_the_floor(kiosk,
|
||||
client):
|
||||
"""Screens provisioned before this change must not need a visit."""
|
||||
assert _session(client, ELSEWHERE,
|
||||
**{'X-Kiosk-Token': KIOSK_TOKEN}).status_code == 200
|
||||
|
||||
|
||||
# -- the badge-to-name answer -------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def smallbudget(app):
|
||||
"""Three misses, so the test states the rule rather than counting to 15.
|
||||
|
||||
Also turns the limiter on: it defaults off under TESTING, because the
|
||||
counter lives in a cache that outlives a test and is shared across whatever
|
||||
else runs on the same worker. Same arrangement as the login limiter.
|
||||
"""
|
||||
previous = {key: app.config.get(key)
|
||||
for key in ('USB_KIOSK_BADGE_MISS_MAX',
|
||||
'USB_KIOSK_BADGE_LIMIT_ENABLED')}
|
||||
app.config['USB_KIOSK_BADGE_MISS_MAX'] = 3
|
||||
app.config['USB_KIOSK_BADGE_LIMIT_ENABLED'] = True
|
||||
yield
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
app.config.pop(key, None)
|
||||
else:
|
||||
app.config[key] = value
|
||||
|
||||
|
||||
def test_badge_guessing_is_bounded_per_source(kiosk, client, smallbudget):
|
||||
"""THE FIX. The kiosk answers with a NAME, so it is a lookup to be walked.
|
||||
|
||||
Admission by network position can mean a whole VLAN, not one screen by the
|
||||
cabinet, which is what makes an unbounded lookup worth somebody's loop.
|
||||
"""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
for _ in range(3):
|
||||
assert _session(client, PLANT, badge='111111111').status_code == 422
|
||||
refused = _session(client, PLANT, badge='111111111')
|
||||
assert refused.status_code == 429
|
||||
# And a badge that WOULD resolve gets nothing either, or the bound is a
|
||||
# speed bump with a hole in it.
|
||||
assert _session(client, PLANT).status_code == 429
|
||||
|
||||
|
||||
def test_the_bound_is_per_caller_not_per_kiosk(kiosk, client, smallbudget):
|
||||
"""One PC guessing must not shut the cabinet screen down."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
for _ in range(4):
|
||||
_session(client, PLANT, badge='111111111')
|
||||
assert _session(client, PLANT).status_code == 429
|
||||
assert _session(client, '10.134.48.99').status_code == 200
|
||||
|
||||
|
||||
def test_ordinary_use_never_spends_the_budget(kiosk, client, smallbudget):
|
||||
"""Only a badge this site has never heard of counts."""
|
||||
_set(kiosk, 'usb_kiosk_allowed_cidrs', PLANT_CIDR)
|
||||
for _ in range(6):
|
||||
assert _session(client, PLANT).status_code == 200
|
||||
|
||||
|
||||
def test_enabling_the_plugin_on_a_running_server_still_works():
|
||||
"""init_app also runs when an operator switches the plugin on live.
|
||||
|
||||
Flask refuses a setup method once the app has served a request, and the
|
||||
loader turns any exception out of init_app into a plugin that failed to
|
||||
load - so an unguarded before_request would have made "enable USB" fail on
|
||||
every running server.
|
||||
"""
|
||||
from flask import Flask
|
||||
|
||||
from plugins.usb.plugin import USBPlugin
|
||||
|
||||
running = Flask(__name__)
|
||||
running.test_client().get('/')
|
||||
USBPlugin().init_app(running, None)
|
||||
@@ -137,3 +137,66 @@ def test_the_report_is_registered_so_it_appears_in_the_catalogue(client, usbdata
|
||||
assert report['id'] == 'usb-audit'
|
||||
# The endpoint is what the reports page fetches; a wrong one renders blank.
|
||||
assert report['endpoint'] == '/api/usb/reports/audit'
|
||||
|
||||
|
||||
# -- sanitized / virus-scan state in the audit ---------------------------------
|
||||
# CMMC evidence: the report answered who had which drive and when, but not
|
||||
# whether it was wiped or scanned on the way back - which is the question the
|
||||
# record exists to answer. Both stores held the values; neither reported them.
|
||||
|
||||
def test_audit_reports_wipe_and_scan_state_on_returns(db, client, auth_headers):
|
||||
from plugins.usb.models import USBDevice, USBCheckout
|
||||
from shopdb.core.models import Setting
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
device = USBDevice(serialnumber='USB-9', label='Grey SanDisk')
|
||||
db.session.add(device)
|
||||
db.session.flush()
|
||||
now = datetime.utcnow()
|
||||
db.session.add(USBCheckout(
|
||||
usbdeviceid=device.usbdeviceid, sso='270015376', checkoutname='Dana',
|
||||
checkouttime=now - timedelta(hours=2), checkintime=now - timedelta(hours=1),
|
||||
waswiped=True, scannedviruses=False))
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/api/usb/reports/audit?days=7', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()['data']
|
||||
assert 'sanitized' in data['columns']
|
||||
assert 'scanned_viruses' in data['columns']
|
||||
|
||||
checkin = [r for r in data['rows'] if r['action'] == 'check-in'][0]
|
||||
checkout = [r for r in data['rows'] if r['action'] == 'check-out'][0]
|
||||
assert checkin['sanitized'] == 'yes'
|
||||
assert checkin['scanned_viruses'] == 'no'
|
||||
# A take carries neither: both are recorded when the drive comes back.
|
||||
assert checkout['sanitized'] == ''
|
||||
assert checkout['scanned_viruses'] == ''
|
||||
|
||||
|
||||
def test_unrecorded_wipe_is_blank_not_no(db, client, auth_headers):
|
||||
"""A null means nobody wrote it down. Rendering that as 'no' would assert
|
||||
something no one ever claimed, in the one record kept to be trustworthy."""
|
||||
from plugins.usb.models import USBDevice, USBCheckout
|
||||
from shopdb.core.models import Setting
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
device = USBDevice(serialnumber='USB-10', label='Old stock')
|
||||
db.session.add(device)
|
||||
db.session.flush()
|
||||
now = datetime.utcnow()
|
||||
db.session.add(USBCheckout(
|
||||
usbdeviceid=device.usbdeviceid, sso='270015376', checkoutname='Dana',
|
||||
checkouttime=now - timedelta(hours=3), checkintime=now - timedelta(hours=2),
|
||||
waswiped=None, scannedviruses=None))
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/api/usb/reports/audit?days=7', headers=auth_headers)
|
||||
checkin = [r for r in response.get_json()['data']['rows']
|
||||
if r['action'] == 'check-in'][0]
|
||||
assert checkin['sanitized'] == ''
|
||||
assert checkin['scanned_viruses'] == ''
|
||||
|
||||
Reference in New Issue
Block a user