Windows installer: payload hash-locking, SBOM, client-IP handling, operator docs

This commit is contained in:
2026-08-03 15:08:20 -04:00
parent ff61a4c5e9
commit 3d43cb3d86
51 changed files with 9002 additions and 190 deletions

231
tests/test_bundle_lock.py Normal file
View File

@@ -0,0 +1,231 @@
"""The installer bundle lock: fail-closed behaviour, and parity between the two
implementations that read it.
There are two verifiers on purpose - bundle-lock.ps1 runs at install time on a
server where PowerShell is the only thing present, verify_bundle_lock.py runs in
the Linux builder without adding pwsh as a build dependency. Two implementations
of one rule drift. These tests run BOTH against the same fixtures and fail if
they disagree, so the drift shows up here rather than as a bundle that one of
them waves through.
The PowerShell half is skipped where pwsh is absent; the Python half always runs.
"""
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
INSTALLER = Path(__file__).resolve().parents[1] / 'deploy' / 'windows' / 'installer'
VERIFY_PY = INSTALLER / 'verify_bundle_lock.py'
LOCK_PS1 = INSTALLER / 'bundle-lock.ps1'
PWSH = shutil.which('pwsh') or shutil.which('powershell')
pytestmark = pytest.mark.skipif(not VERIFY_PY.exists(), reason='installer not in this tree')
def build_bundle(root):
"""A minimal bundle holding every REQUIRED payload directory."""
for name, files in (
('wheels', {'alembic-1.18.4-py3-none-any.whl': b'wheel-a',
'cffi-2.1.0-cp314-cp314-win_amd64.whl': b'wheel-b'}),
('python', {'python-3.14.6-amd64.exe': b'py'}),
('httpplatformhandler', {'httpPlatformHandler_amd64.msi': b'hph'}),
):
directory = root / name
directory.mkdir(parents=True)
for filename, content in files.items():
(directory / filename).write_bytes(content)
return root
def write_lock(bundle, lock_path):
"""Generate the lock the same way refresh-bundle-lock.ps1 does."""
sys.path.insert(0, str(INSTALLER))
import verify_bundle_lock as verifier
payloads = {}
for name, required, _what in verifier.PAYLOADS:
directory = bundle / name
if not directory.is_dir():
continue
payloads[name] = {'required': required, 'files': verifier.payload_files(str(directory))}
lock_path.write_text(json.dumps({
'schema': 1, 'generated': '2026-08-03T00:00:00Z',
'pythontag': 'cp314', 'platform': 'win_amd64', 'payloads': payloads,
}))
def check_python(bundle, lock_path):
result = subprocess.run(
[sys.executable, str(VERIFY_PY), str(bundle), str(lock_path)],
capture_output=True, text=True)
problems = [line for line in result.stdout.splitlines() if line.strip()]
# Exit status and output must agree, or a caller that checks only one of them
# gets a different answer from a caller that checks the other.
assert (result.returncode != 0) == bool(problems), result.stdout
return problems
def check_powershell(bundle, lock_path):
script = (
". '%s'; $p = Test-BundleLock -BundleRoot '%s' -Lock (Read-BundleLock '%s'); "
"if ($p) { $p -join \"`n\" }" % (LOCK_PS1, bundle, lock_path))
result = subprocess.run([PWSH, '-NoProfile', '-Command', script],
capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return [line for line in result.stdout.splitlines() if line.strip()]
def check_both(bundle, lock_path):
"""Returns the Python verdict, asserting PowerShell reports the same set.
Compared as sets: both sort their output, but PowerShell's Sort-Object is
culture-aware and Python's sorted() is ordinal, so the two can order the same
findings differently. What must never differ is WHICH problems are found.
"""
problems = check_python(bundle, lock_path)
if PWSH and LOCK_PS1.exists():
assert sorted(check_powershell(bundle, lock_path)) == sorted(problems), (
'verify_bundle_lock.py and bundle-lock.ps1 disagree')
return problems
@pytest.fixture
def locked(tmp_path):
bundle = build_bundle(tmp_path / 'bundle')
lock = tmp_path / 'bundle-lock.json'
write_lock(bundle, lock)
return bundle, lock
def test_untouched_bundle_passes(locked):
bundle, lock = locked
assert check_both(bundle, lock) == []
def test_extra_file_fails(locked):
"""The gap pip's own hash checking leaves: a stale wheel nobody asked for."""
bundle, lock = locked
(bundle / 'wheels' / 'stale-0.1-py3-none-any.whl').write_bytes(b'left over')
problems = check_both(bundle, lock)
assert any('stale-0.1' in p and 'NOT in the lock' in p for p in problems)
def test_altered_file_fails(locked):
bundle, lock = locked
(bundle / 'wheels' / 'cffi-2.1.0-cp314-cp314-win_amd64.whl').write_bytes(b'swapped')
problems = check_both(bundle, lock)
assert any('does NOT match the lock' in p for p in problems)
def test_missing_file_fails(locked):
bundle, lock = locked
(bundle / 'wheels' / 'alembic-1.18.4-py3-none-any.whl').unlink()
problems = check_both(bundle, lock)
assert any('missing from the bundle' in p for p in problems)
def test_missing_required_payload_fails(locked):
bundle, lock = locked
shutil.rmtree(bundle / 'python')
problems = check_both(bundle, lock)
assert any(p.startswith('python/') for p in problems)
def test_unlocked_payload_directory_fails(locked):
"""An optional payload dropped in after the lock was made is still unreviewed."""
bundle, lock = locked
(bundle / 'mysql').mkdir()
(bundle / 'mysql' / 'mysql-8.0.46-winx64.msi').write_bytes(b'msi')
problems = check_both(bundle, lock)
assert any('mysql/ is present but is not in bundle-lock.json' in p for p in problems)
def test_absent_optional_payload_is_fine(locked):
bundle, lock = locked
assert not (bundle / 'mysql').exists()
assert check_both(bundle, lock) == []
def test_missing_lock_fails_closed(locked):
"""No lock is a failure, not a pass. The build must not fall through to 'fine'."""
bundle, lock = locked
lock.unlink()
result = subprocess.run([sys.executable, str(VERIFY_PY), str(bundle), str(lock)],
capture_output=True, text=True)
assert result.returncode != 0
assert 'no bundle-lock.json' in result.stdout
def test_malformed_lock_reports_rather_than_crashing(locked):
"""A truncated lock must produce a sentence, not a stack trace.
shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
that is not there throws. Both verifiers have to survive a lock missing the
section they read first.
"""
bundle, lock = locked
lock.write_text(json.dumps({'schema': 1, 'pythontag': 'cp314'}))
problems = check_both(bundle, lock)
assert problems == ['bundle-lock.json has no "payloads" section']
def test_lock_missing_files_section_is_not_a_silent_pass(locked):
"""A payload entry with no file list must not read as 'nothing expected, fine'."""
bundle, lock = locked
data = json.loads(lock.read_text())
del data['payloads']['wheels']['files']
lock.write_text(json.dumps(data))
problems = check_both(bundle, lock)
assert problems, 'an entry with no file list let every wheel through'
assert all('wheels/' in p for p in problems)
def test_wheelhouse_must_cover_requirements(locked):
"""The regression that motivated the check.
A wheelhouse assembled anywhere but Windows omits colorama - pip evaluates
sys_platform markers against the downloading machine. The lock alone cannot
catch it, because the lock records what is there, not what is needed.
"""
bundle, lock = locked
app = bundle / 'app'
app.mkdir()
(app / 'requirements.txt').write_text(
"alembic==1.18.4 \\\n --hash=sha256:abc\n"
"colorama==0.4.6 ; sys_platform == 'win32' \\\n --hash=sha256:def\n")
problems = check_both(bundle, lock)
assert any('colorama==0.4.6' in p for p in problems), problems
assert not any('alembic' in p for p in problems), 'alembic has a wheel and must not be flagged'
def test_wheelhouse_coverage_normalizes_names(locked):
"""mysql-connector-python pins as dashes and ships as mysql_connector_python."""
bundle, lock = locked
app = bundle / 'app'
app.mkdir()
(app / 'requirements.txt').write_text('mysql-connector-python==9.7.0\n')
assert any('mysql_connector_python==9.7.0' in p for p in check_both(bundle, lock))
wheel = bundle / 'wheels' / 'mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl'
wheel.write_bytes(b'connector')
write_lock(bundle, lock) # re-lock so the new wheel is not an 'extra'
assert check_both(bundle, lock) == []
def test_payload_list_matches_powershell():
"""Both files enumerate the payload directories. Same names, same required
flags, same order - a directory that is required in one and optional in the
other means the builder and the installer disagree about what may be absent.
"""
sys.path.insert(0, str(INSTALLER))
import verify_bundle_lock as verifier
declared = re.findall(r"Name\s*=\s*'([a-z]+)'\s*;\s*Required\s*=\s*\$(true|false)",
LOCK_PS1.read_text())
assert declared, 'could not find $script:BundlePayloads in bundle-lock.ps1'
assert declared == [(name, str(required).lower()) for name, required, _ in verifier.PAYLOADS]

View File

@@ -0,0 +1,55 @@
"""The installer wizard's pre-ticked feature list must match the plugin manifests.
The wizard cannot read manifest.json - Inno's Pascal Script has no JSON parser -
so the default set is a hardcoded list in ShopDBFlask.iss. It drifted: two plugins
that ship "default_enabled": false were pre-ticked, so every site taking the
defaults installed and enabled them against their own manifests.
A hardcoded list is fine; a hardcoded list nobody checks is not.
"""
import json
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
ISS = REPO / 'deploy' / 'windows' / 'installer' / 'ShopDBFlask.iss'
PLUGINS = REPO / 'plugins'
pytestmark = pytest.mark.skipif(not ISS.exists(), reason='installer not in this tree')
def manifests_defaulting_off():
off = set()
for manifest in PLUGINS.glob('*/manifest.json'):
try:
data = json.loads(manifest.read_text())
except (ValueError, OSError):
continue
if data.get('default_enabled') is False:
off.add(manifest.parent.name)
return off
def wizard_excludes():
"""The names PluginDefault returns False for."""
body = re.search(r'function PluginDefault.*?\nend;', ISS.read_text(), re.S)
assert body, 'PluginDefault not found in ShopDBFlask.iss'
return set(re.findall(r"Name <> '([a-z_]+)'", body.group(0)))
def test_wizard_defaults_match_the_manifests():
off = manifests_defaulting_off()
assert off, 'no plugin declares default_enabled false - has the field moved?'
assert wizard_excludes() == off, (
'ShopDBFlask.iss PluginDefault disagrees with the manifests.\n'
' manifests default_enabled=false: %s\n'
' wizard leaves unticked: %s' % (sorted(off), sorted(wizard_excludes())))
def test_every_wizard_exclusion_is_a_real_plugin():
"""A typo in the .iss list silently pre-ticks the plugin it meant to exclude."""
for name in wizard_excludes():
assert (PLUGINS / name / 'manifest.json').exists(), (
'%s is excluded by the wizard but has no manifest' % name)

154
tests/test_sbom.py Normal file
View File

@@ -0,0 +1,154 @@
"""The generated SBOM: shape, completeness, and the properties it is relied on for.
The point of shipping an SBOM to an air-gapped site is answering "are we exposed
to this CVE, and where" without scanning the box. That only works if the document
actually lists everything that ships, at the right versions, with identifiers a
scanner recognises. These tests hold it to that.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
GENERATOR = REPO / 'scripts' / 'generate_sbom.py'
FIXED_TIMESTAMP = '2026-01-01T00:00:00Z'
def generate(tmp_path, name='sbom.cdx.json'):
out = tmp_path / name
result = subprocess.run(
[sys.executable, str(GENERATOR), str(REPO), '-o', str(out),
'--timestamp', FIXED_TIMESTAMP],
capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(out.read_text()), out
@pytest.fixture(scope='module')
def sbom(tmp_path_factory):
document, _ = generate(tmp_path_factory.mktemp('sbom'))
return document
def test_is_valid_cyclonedx(sbom):
assert sbom['bomFormat'] == 'CycloneDX'
assert sbom['specVersion'] == '1.6'
assert sbom['serialNumber'].startswith('urn:uuid:')
assert sbom['version'] == 1
def test_carries_the_ntia_minimum_elements(sbom):
"""Supplier, component name, version, unique identifier, dependency
relationship, author, timestamp."""
meta = sbom['metadata']
assert meta['supplier']['name']
assert meta['authors']
assert meta['timestamp'] == FIXED_TIMESTAMP
assert meta['component']['name'] and meta['component']['version']
assert sbom['dependencies']
for component in sbom['components']:
assert component['name']
assert component['version']
assert component['purl'], '%s has no unique identifier' % component['name']
def test_every_python_pin_is_present(sbom):
"""The SBOM must not be quietly narrower than what the installer installs."""
import re
pinned = set()
for line in (REPO / 'requirements.txt').read_text().splitlines():
match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line.strip())
if match:
pinned.add((match.group(1).lower().replace('_', '-'), match.group(2)))
listed = {(c['name'].lower().replace('_', '-'), c['version'])
for c in sbom['components'] if c['purl'].startswith('pkg:pypi/')}
assert pinned == listed
def test_marked_out_dependencies_are_still_listed(sbom):
"""colorama is win32-only. It installs on the target, so it must appear -
the same blind spot that left it out of the wheelhouse."""
names = {c['name'] for c in sbom['components'] if c['purl'].startswith('pkg:pypi/')}
assert 'colorama' in names
def test_frontend_packages_are_covered(sbom):
"""The npm tree is the reason this exists: nothing else records what version
of leaflet or dompurify ends up inside the compiled SPA."""
npm = {c['name']: c for c in sbom['components'] if c['purl'].startswith('pkg:npm/')}
assert len(npm) > 100
for shipped in ('leaflet', 'dompurify', 'vue'):
assert shipped in npm, '%s is missing from the SBOM' % shipped
assert npm[shipped]['scope'] == 'required'
def test_build_only_packages_are_marked_not_dropped(sbom):
"""Dev packages do not ship. Recorded as 'excluded' rather than omitted, so a
reader can tell 'not here' from 'not looked for'."""
npm = {c['name']: c for c in sbom['components'] if c['purl'].startswith('pkg:npm/')}
assert npm['vite']['scope'] == 'excluded'
assert any(c['scope'] == 'required' for c in npm.values())
def test_bom_refs_are_unique(sbom):
"""CycloneDX forbids duplicate bom-refs, and scanners reject a document that
has them. npm installs the same package at several depths - node_modules/vite
and node_modules/vitest/node_modules/vite - which emitted it twice."""
refs = [c['bom-ref'] for c in sbom['components']]
duplicates = {ref for ref in refs if refs.count(ref) > 1}
assert not duplicates, 'duplicate bom-refs: %s' % sorted(duplicates)[:5]
def test_a_package_present_outside_the_dev_tree_counts_as_shipped(tmp_path):
"""Merging duplicates must not mark a shipped package build-only, which would
hide it from a CVE search on the server."""
sys.path.insert(0, str(REPO / 'scripts'))
import generate_sbom
lock = tmp_path / 'package-lock.json'
lock.write_text(json.dumps({'lockfileVersion': 3, 'packages': {
'': {'name': 'x', 'version': '1.0.0', 'dependencies': {'shared': '^1'}},
'node_modules/shared': {'version': '1.0.0'},
'node_modules/builder': {'version': '2.0.0', 'dev': True},
'node_modules/builder/node_modules/shared': {'version': '1.0.0', 'dev': True},
}}))
packages, direct = generate_sbom.parse_package_lock(str(lock))
shared = [p for p in packages if p['name'] == 'shared']
assert len(shared) == 1, 'the two copies of shared were not merged'
assert shared[0]['dev'] is False
assert direct == ['shared']
def test_components_carry_integrity_hashes(sbom):
missing = [c['name'] for c in sbom['components'] if not c.get('hashes')]
assert not missing, 'components without a hash: %s' % missing[:5]
def test_dependency_graph_is_real_not_flat(sbom):
"""A flat 'root depends on everything' graph cannot answer what was chosen
versus what was dragged in."""
root = sbom['metadata']['component']['bom-ref']
edges = {entry['ref']: entry['dependsOn'] for entry in sbom['dependencies']}
assert root in edges
assert len(edges) > 1, 'no edges below the root'
assert len(edges[root]) < len(sbom['components']), 'root depends on everything'
refs = {c['bom-ref'] for c in sbom['components']}
for ref, children in edges.items():
if ref == root:
continue
assert ref in refs, 'edge from an unknown component: %s' % ref
for child in children:
assert child in refs, 'edge to an unknown component: %s' % child
def test_output_is_byte_identical_across_runs(tmp_path):
"""Regenerating must not churn. A document that differs every build gets
re-committed without being read."""
first, first_path = generate(tmp_path, 'a.json')
_, second_path = generate(tmp_path, 'b.json')
assert first_path.read_bytes() == second_path.read_bytes()
assert first['serialNumber'] == json.loads(second_path.read_text())['serialNumber']