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:
2026-05-29 07:10:12 -04:00
parent 5e438aa991
commit c40919d374
6 changed files with 1412 additions and 38 deletions
+31 -3
View File
@@ -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))