feat: add manifest_validator.py — security chokepoint for compose and manifest validation
Unit Tests / test (push) Successful in 11m18s
Unit Tests / test (push) Successful in 11m18s
Rejects privileged compose configs (network_mode:host, pid:host, ipc:host, userns_mode:host, cap_add:ALL, string commands, missing cell-network, reserved container names). Validates manifest schema_version=3, image digest pinning (sha256 required, :tag-only rejected), and provision hook format. Wired into ServiceComposer.write_compose() and ServiceStoreManager.install() as a single enforcement point. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,10 @@ _CONTAINER_NAME_RE = re.compile(r'^cell-[a-z0-9][a-z0-9-]{0,30}$')
|
||||
_ENV_VALUE_RE = re.compile(r'^[A-Za-z0-9._@:/+\-]{0,256}$')
|
||||
_HOOK_BINARY_RE = re.compile(r'^[a-z][a-z0-9_-]{0,31}$')
|
||||
_CAP_NAME_RE = re.compile(r'^[A-Z_]+$')
|
||||
_ID_RE = re.compile(r'^[a-z][a-z0-9_-]{0,30}$')
|
||||
_IMAGE_DIGEST_RE = re.compile(
|
||||
r'^git\.pic\.ngo/roof/[a-zA-Z0-9._/-]+@sha256:[0-9a-f]{64}$'
|
||||
)
|
||||
|
||||
|
||||
def validate_manifest(manifest: dict) -> tuple:
|
||||
@@ -49,11 +53,34 @@ def validate_manifest(manifest: dict) -> tuple:
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# schema_version must be 3
|
||||
schema_version = manifest.get('schema_version')
|
||||
if schema_version is not None and schema_version != 3:
|
||||
errors.append(
|
||||
f'schema_version must be 3, got: {schema_version!r}'
|
||||
)
|
||||
|
||||
# kind must be "store" if present — reject builtins coming in over the wire
|
||||
kind = manifest.get('kind')
|
||||
if kind is not None and kind != 'store':
|
||||
errors.append(f'manifest kind must be "store", got: {kind!r}')
|
||||
|
||||
# id format check
|
||||
manifest_id = manifest.get('id')
|
||||
if manifest_id is not None:
|
||||
if not isinstance(manifest_id, str) or not _ID_RE.match(manifest_id):
|
||||
errors.append(
|
||||
f'id must match ^[a-z][a-z0-9_-]{{0,30}}$, got: {manifest_id!r}'
|
||||
)
|
||||
|
||||
# image must be digest-pinned from git.pic.ngo/roof/*
|
||||
image = manifest.get('image')
|
||||
if image is not None:
|
||||
if not isinstance(image, str) or not _IMAGE_DIGEST_RE.match(image):
|
||||
errors.append(
|
||||
f'image must match git.pic.ngo/roof/*@sha256:<64-hex>, got: {image!r}'
|
||||
)
|
||||
|
||||
# container_name structural check
|
||||
cname = manifest.get('container_name')
|
||||
if cname is not None:
|
||||
@@ -156,6 +183,10 @@ def validate_rendered_compose(yaml_text: str, allowed_data_dir: str = None) -> t
|
||||
continue
|
||||
prefix = f'service {svc_name!r}'
|
||||
|
||||
cname = svc.get('container_name')
|
||||
if cname is not None and cname in _RESERVED_CONTAINER_NAMES:
|
||||
errors.append(f'{prefix}: container_name {cname!r} is reserved')
|
||||
|
||||
if svc.get('privileged') is True:
|
||||
errors.append(f'{prefix}: privileged: true is not allowed')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user