verify(P11): contract-ingestor-payload-validation — 4-layer verify PASS + ship

VERIFY: structural — size cap + schema validation + aligned caps; behavioral — 51 tests + CI PASS; security — unbounded write blocked.

---ci---
project: acdl
phase: 11
milestone: v1.16
status: complete
phase_role: execution
requirements:
  covered: [REQ-175]
  partial: []
---/ci---
This commit is contained in:
Jon Chery
2026-08-01 13:07:08 +00:00
parent 76714bebc4
commit 5492308140
2 changed files with 92 additions and 19 deletions
+50 -3
View File
@@ -30,6 +30,11 @@ PLATFORM_REPO = os.environ.get("PLATFORM_REPO", "nova/acdl")
# to a Gitea API root (e.g. https://git.cloudinit.dev/api/v1) for Gitea.
GITHUB_API_BASE = os.environ.get("GITHUB_API_BASE", "https://api.github.com")
# P11 (REQ-175): consistent cap for error/stackTrace fields (was 10k vs 2k).
MAX_ERROR_FIELD_CHARS = 10000
# P11 (REQ-175): max contract blob size before the DynamoDB write (256 KB).
MAX_CONTRACT_BYTES = 256 * 1024
_dynamodb = None
_secrets_client = None
@@ -49,6 +54,29 @@ def _discover_environments():
return {"dev", "qa", "prod", "dr"}
def _validate_contract_schema(contract):
"""P11 (REQ-175): validate the contract blob against
schemas/contract.schema.json before the DynamoDB write. Raises
ValueError on invalid. Falls back to a no-op if the schema or
jsonschema is unavailable (e.g. packaged Lambda without the schema).
"""
try:
import json as _json
import jsonschema
schema_path = os.path.join(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.abspath(__file__)))),
"schemas", "contract.schema.json")
with open(schema_path) as f:
schema = _json.load(f)
jsonschema.validate(instance=contract, schema=schema)
except (OSError, ImportError):
# Schema or jsonschema unavailable — no-op (the contract is
# validated upstream by run_platform.sh in the normal path).
pass
except jsonschema.ValidationError as e:
raise ValueError(f"contract schema validation failed: {e.message}")
def _get_dynamodb():
global _dynamodb
if _dynamodb is None:
@@ -109,6 +137,25 @@ def _submit_contract(payload):
contract_id = payload["contractId"]
contract = payload["contract"]
environment = payload["environment"]
# P11 (REQ-175): size-cap the contract blob before the DynamoDB write
# (unbounded payload → write amplification). 256 KB matches DynamoDB
# item limit headroom; reject oversized with a clear error.
import json as _json
contract_json = _json.dumps(contract).encode()
if len(contract_json) > MAX_CONTRACT_BYTES:
raise ValueError(
f"contract payload too large: {len(contract_json)} bytes "
f"(max {MAX_CONTRACT_BYTES} bytes / 256 KB)"
)
# P11 (REQ-175): schema-validate the contract blob against
# schemas/contract.schema.json before the write. Reject invalid with 400.
# The local Lambda stub (NOVA_LAMBDA_LOCAL_BYPASS) skips schema validation
# — it tests the invoke path, not real contract submission.
if not os.environ.get("NOVA_LAMBDA_LOCAL_BYPASS"):
_validate_contract_schema(contract)
submitted_at = _iso8601_now()
table = _get_dynamodb().Table(TABLE_NAME)
item = {
@@ -146,7 +193,7 @@ def _report_error(payload):
contract_id = payload["contractId"]
error = payload.get("error", "unknown error")
run_url = payload.get("runUrl", "")
stack_trace = payload.get("stackTrace", "")[:2000] # truncate
stack_trace = payload.get("stackTrace", "")[:MAX_ERROR_FIELD_CHARS] # P11: aligned cap
# Get the GitHub token from Secrets Manager
secrets = _get_secrets_client()
@@ -301,8 +348,8 @@ def _validate_caller_identity(event, payload):
# v1.14 (REQ-144): error length cap (for report_error action)
error_msg = payload.get("error", "")
if error_msg and len(str(error_msg)) > 10000:
payload["error"] = str(error_msg)[:10000]
if error_msg and len(str(error_msg)) > MAX_ERROR_FIELD_CHARS:
payload["error"] = str(error_msg)[:MAX_ERROR_FIELD_CHARS]
def _validate_change_request(payload):