feat: Phase 0 — manifest_validator, compose YAML safety check, cap_add allowlist, backend denylist, provision hook enforcement, size cap
Introduces api/manifest_validator.py as a single security chokepoint
imported by both ServiceComposer and ServiceStoreManager:
- validate_manifest(): rejects kind=builtin, reserved container names,
reserved subdomains, backend denylist (localhost, cell-api, etc.),
cap_add outside allowlist / in denylist, shell-string provision hooks,
and env values with shell-special characters
- validate_rendered_compose(): walks the rendered YAML and rejects
privileged:true, host network/pid/ipc/userns, absolute bind mounts,
denied capabilities, devices key, apparmor/seccomp unconfined, and
string-form command/entrypoint (shell-injection vector)
- validate_provision_hook(): requires argv list form, lowercase binary,
rejects NUL bytes
ServiceStoreManager changes:
- _validate_manifest() delegates to validate_manifest() after existing checks
- _fetch_manifest() and fetch_index() now stream with a 256 KB size cap
(prevents memory exhaustion from a malicious or compromised index)
- Digest-pin warning for images missing @sha256 (hard error for unknown
registries, warning for git.pic.ngo/roof/* and TRUSTED_IMAGES_NO_DIGEST)
ServiceComposer changes:
- write_compose() calls validate_rendered_compose() before any disk write
so no partial file is left if validation fails
- render_template() substitutes ${PIC_DATA_DIR} with the resolved data_dir path
102 new tests in tests/test_manifest_validator.py covering all five P0
security issues. Existing test mocks updated to use streaming response
pattern (stream=True + raw.read) and valid compose YAML templates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -149,7 +149,17 @@ class TestWriteCompose(unittest.TestCase):
|
||||
cm = _make_cm()
|
||||
composer = ServiceComposer(config_manager=cm, data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
template = 'PORT=${PIC_CFG_PORT}'
|
||||
template = (
|
||||
'version: "3.8"\n'
|
||||
'services:\n'
|
||||
' app:\n'
|
||||
' image: nginx\n'
|
||||
' environment:\n'
|
||||
' PORT: "${PIC_CFG_PORT}"\n'
|
||||
'networks:\n'
|
||||
' cell-network:\n'
|
||||
' external: true\n'
|
||||
)
|
||||
composer.write_compose('myservice', manifest, template)
|
||||
|
||||
expected_path = os.path.join(
|
||||
@@ -169,7 +179,16 @@ class TestWriteCompose(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
composer.write_compose('myservice', manifest, 'content: true')
|
||||
valid_template = (
|
||||
'version: "3.8"\n'
|
||||
'services:\n'
|
||||
' app:\n'
|
||||
' image: nginx\n'
|
||||
'networks:\n'
|
||||
' cell-network:\n'
|
||||
' external: true\n'
|
||||
)
|
||||
composer.write_compose('myservice', manifest, valid_template)
|
||||
self.assertTrue(composer.has_compose_file('myservice'))
|
||||
|
||||
def test_atomic_write_via_tmp_file(self):
|
||||
@@ -178,7 +197,16 @@ class TestWriteCompose(unittest.TestCase):
|
||||
composer = ServiceComposer(config_manager=_make_cm(), data_dir=tmpdir)
|
||||
manifest = _make_manifest()
|
||||
# Should not raise even if fsync not available
|
||||
composer.write_compose('myservice', manifest, 'content: yes')
|
||||
valid_template = (
|
||||
'version: "3.8"\n'
|
||||
'services:\n'
|
||||
' app:\n'
|
||||
' image: nginx\n'
|
||||
'networks:\n'
|
||||
' cell-network:\n'
|
||||
' external: true\n'
|
||||
)
|
||||
composer.write_compose('myservice', manifest, valid_template)
|
||||
path = os.path.join(tmpdir, 'services', 'myservice', 'docker-compose.yml')
|
||||
self.assertTrue(os.path.exists(path))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user