diff --git a/core/contract_resolver.py b/core/contract_resolver.py index a53105d..ff7d7dd 100644 --- a/core/contract_resolver.py +++ b/core/contract_resolver.py @@ -64,6 +64,21 @@ def _load_json(path): return json.load(fh) +# P14 (REQ-178): cache loaded JSON schemas so resolve() doesn't re-read +# from disk on every call. +_SCHEMA_CACHE: dict = {} + + +def _load_schema(path): + """Load a JSON schema with caching (P14, REQ-178).""" + cached = _SCHEMA_CACHE.get(path) + if cached is not None: + return cached + schema = _load_json(path) + _SCHEMA_CACHE[path] = schema + return schema + + def _load_yaml(path): with open(path, "r") as fh: return yaml.safe_load(fh) @@ -468,7 +483,7 @@ def resolve(contract_path, repo_root=None, environment_override=None): contract["environment"] = environment_override # Load schemas - contract_schema = _load_json(os.path.join(repo_root, "schemas", "contract.schema.json")) + contract_schema = _load_schema(os.path.join(repo_root, "schemas", "contract.schema.json")) # Validate contract against schema jsonschema.validate(contract, contract_schema) @@ -588,7 +603,7 @@ def resolve(contract_path, repo_root=None, environment_override=None): stack_instance["outputs"] = merged_outputs # Validate against stack schema - stack_schema = _load_json(os.path.join(repo_root, "schemas", "stack.schema.json")) + stack_schema = _load_schema(os.path.join(repo_root, "schemas", "stack.schema.json")) jsonschema.validate(stack_instance, stack_schema) return stack_instance diff --git a/core/output_publisher.py b/core/output_publisher.py index 9543e47..3e1815a 100644 --- a/core/output_publisher.py +++ b/core/output_publisher.py @@ -38,8 +38,10 @@ from core import env as _envhelper SSM_PREFIX = "/nova" KMS_KEY_ID_ENV = "NOVA_KMS_KEY_ID" -# Outputs that are safe to display in a PR comment (no secrets). -SAFE_OUTPUT_NAMES = { +# P14 (REQ-178): SAFE_OUTPUT_NAMES is schema-driven (derived from +# modules/l1/*/interface.json outputs that don't have sensitive:true). +# Falls back to the hardcoded set if the interfaces can't be read. +_HARDCODED_SAFE_OUTPUTS = { "distribution_domain_name", "bucket_arn", "bucket_name", @@ -59,6 +61,37 @@ SAFE_OUTPUT_NAMES = { } +def _load_safe_output_names(): + """Derive the safe-output allowlist from interface.json outputs. + + P14 (REQ-178): scan modules/l1/*/interface.json; an output is safe if + its spec does not set sensitive:true. Falls back to the hardcoded set + if no interfaces are readable. + """ + import json + from pathlib import Path + root = Path(__file__).resolve().parent.parent + safe = set() + try: + for iface in (root / "modules" / "l1").glob("*/interface.json"): + d = json.loads(iface.read_text()) + outs = d.get("outputs", {}) + if isinstance(outs, dict): + for name, spec in outs.items(): + if not (isinstance(spec, dict) and spec.get("sensitive")): + safe.add(name) + elif isinstance(outs, list): + for out in outs: + if isinstance(out, dict) and not out.get("sensitive"): + safe.add(out.get("name", "")) + except (OSError, ValueError): + pass + return safe or _HARDCODED_SAFE_OUTPUTS + + +SAFE_OUTPUT_NAMES = _load_safe_output_names() + + def _ssm_client(): if boto3 is None: raise RuntimeError("boto3 is required for SSM publishing")