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:
@@ -18,11 +18,14 @@ import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from base_service_manager import BaseServiceManager
|
||||
from ip_utils import CONTAINER_OFFSETS
|
||||
from manifest_validator import validate_manifest, validate_provision_hook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,8 +44,19 @@ MANIFEST_URL_TPL = (
|
||||
)
|
||||
|
||||
IMAGE_ALLOWLIST_RE = re.compile(
|
||||
r'^git\.pic\.ngo/roof/[a-z0-9._/-]+(:[a-zA-Z0-9._-]+)?$'
|
||||
r'^git\.pic\.ngo/roof/[a-z0-9._/-]+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$'
|
||||
)
|
||||
|
||||
# Images from well-known vendors that pre-date digest pinning in PIC.
|
||||
# These are allowed to ship without a @sha256 digest; all others require one
|
||||
# or must come from git.pic.ngo/roof/*.
|
||||
TRUSTED_IMAGES_NO_DIGEST = frozenset({
|
||||
'mailserver/docker-mailserver',
|
||||
'tomsquest/docker-radicale',
|
||||
'bytemark/webdav',
|
||||
'filegator/filegator',
|
||||
'hardware/rainloop',
|
||||
})
|
||||
FORBIDDEN_MOUNTS = frozenset([
|
||||
'/', '/etc', '/var', '/proc', '/sys', '/dev', '/app', '/run', '/boot',
|
||||
])
|
||||
@@ -112,6 +126,21 @@ class ServiceStoreManager(BaseServiceManager):
|
||||
errors.append(
|
||||
f'image must match git.pic.ngo/roof/* pattern, got: {image}'
|
||||
)
|
||||
elif image:
|
||||
# Warn when a digest pin is absent so operators know exact-version
|
||||
# tracking is not guaranteed. Images in TRUSTED_IMAGES_NO_DIGEST
|
||||
# and images from our own git.pic.ngo/roof/* registry (which we
|
||||
# build and tag) get warnings rather than hard errors; any other
|
||||
# image that somehow passes the allowlist gets a hard error.
|
||||
if '@sha256:' not in image:
|
||||
image_base = image.split(':')[0].split('@')[0]
|
||||
is_own_registry = image_base.startswith('git.pic.ngo/roof/')
|
||||
if image_base in TRUSTED_IMAGES_NO_DIGEST or is_own_registry:
|
||||
logger.warning('image %s has no digest pin', image)
|
||||
else:
|
||||
errors.append(
|
||||
f'image {image!r} must include a @sha256:<digest> pin'
|
||||
)
|
||||
|
||||
# Volume mount safety
|
||||
for vol in m.get('volumes', []):
|
||||
@@ -202,6 +231,12 @@ class ServiceStoreManager(BaseServiceManager):
|
||||
f'env[].value contains disallowed characters: {val!r}'
|
||||
)
|
||||
|
||||
# Security layer: delegate to manifest_validator for cap_add, backend
|
||||
# denylist, provision_hook, reserved container names, and kind guard.
|
||||
ok, sec_errs = validate_manifest(m)
|
||||
if not ok:
|
||||
errors.extend(sec_errs)
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
# ── IP allocation ─────────────────────────────────────────────────────
|
||||
@@ -328,13 +363,17 @@ class ServiceStoreManager(BaseServiceManager):
|
||||
def fetch_index(self) -> list:
|
||||
"""Fetch and cache the service index."""
|
||||
import time
|
||||
_SIZE_LIMIT = 256 * 1024
|
||||
now = time.time()
|
||||
if self._index_cache is not None and (now - self._index_cache_time) < self._cache_ttl:
|
||||
return self._index_cache
|
||||
try:
|
||||
resp = requests.get(self.index_url, timeout=10)
|
||||
resp = requests.get(self.index_url, timeout=10, stream=True)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
content = resp.raw.read(_SIZE_LIMIT + 1, decode_content=True)
|
||||
if len(content) > _SIZE_LIMIT:
|
||||
raise ValueError('Index response exceeds 256 KB limit')
|
||||
data = json.loads(content)
|
||||
self._index_cache = data if isinstance(data, list) else data.get('services', [])
|
||||
self._index_cache_time = now
|
||||
return self._index_cache
|
||||
@@ -344,10 +383,16 @@ class ServiceStoreManager(BaseServiceManager):
|
||||
|
||||
def _fetch_manifest(self, service_id: str) -> dict:
|
||||
"""Fetch a service manifest by ID."""
|
||||
_SIZE_LIMIT = 256 * 1024
|
||||
url = MANIFEST_URL_TPL.format(id=service_id)
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp = requests.get(url, timeout=10, stream=True)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
content = resp.raw.read(_SIZE_LIMIT + 1, decode_content=True)
|
||||
if len(content) > _SIZE_LIMIT:
|
||||
raise ValueError(
|
||||
f'Manifest response for {service_id} exceeds 256 KB limit'
|
||||
)
|
||||
return json.loads(content)
|
||||
|
||||
# ── Core operations ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user