Windows installer: payload hash-locking, SBOM, client-IP handling, operator docs
This commit is contained in:
231
tests/test_bundle_lock.py
Normal file
231
tests/test_bundle_lock.py
Normal 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]
|
||||
55
tests/test_installer_defaults.py
Normal file
55
tests/test_installer_defaults.py
Normal 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
154
tests/test_sbom.py
Normal 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']
|
||||
Reference in New Issue
Block a user