diff --git a/diagnostic/build-45f49127.json b/diagnostic/build-45f49127.json new file mode 100644 index 000000000..aab5528e8 --- /dev/null +++ b/diagnostic/build-45f49127.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-06-24T02:30:03.682131+00:00", + "commit": "45f49127", + "diagnostic_logd": "diagnostic/build-45f49127.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "bc8ff9d39d3268fbcc63", + "decrypt_command": "encryptly unpack diagnostic/build-45f49127.logd --password bc8ff9d39d3268fbcc63", + "total_modules": 1, + "passed": 1, + "failed": 0, + "modules": [ + { + "name": "python-config-tests", + "status": "PASS", + "elapsed_seconds": 0.0, + "artifact": null, + "output": "5 unittest cases passed; development output passed; production missing secrets failed; production with secrets passed with redacted output" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-45f49127.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-45f49127.logd b/diagnostic/build-45f49127.logd new file mode 100644 index 000000000..6c95c2669 Binary files /dev/null and b/diagnostic/build-45f49127.logd differ diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 58642e7b9..2f88d2bc9 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -243,6 +243,34 @@ was dissolved in the 2023 reorg. The current model uses a simple linear regression based on the last 6 months of data, which doesn't account for seasonality or business cycles. +## Production Configuration Secret Validation + +`tools/config_generator.py --env production` validates required production +secrets before writing or printing generated configuration. The generator reads +these values from environment variables: + +| Config key | Environment variable | +|------------|----------------------| +| `database.password` | `DATABASE_PASSWORD` | +| `redis.password` | `REDIS_PASSWORD` | +| `auth.jwt_secret` | `AUTH_JWT_SECRET` | + +Empty and placeholder-like values such as `change-me`, `placeholder`, +`replace-*`, and `your-secret-here` fail validation. Error messages identify the +missing config key but never print secret values. + +Example: + +```bash +DATABASE_PASSWORD="$DATABASE_PASSWORD" \ +REDIS_PASSWORD="$REDIS_PASSWORD" \ +AUTH_JWT_SECRET="$AUTH_JWT_SECRET" \ +python3 tools/config_generator.py --env production --format yaml --output config.yaml +``` + +Development and staging generation remain compatible and do not require these +production secret variables. + ## Security ### Access Control diff --git a/tests/test_config_generator_production_secrets.py b/tests/test_config_generator_production_secrets.py new file mode 100644 index 000000000..4167039a2 --- /dev/null +++ b/tests/test_config_generator_production_secrets.py @@ -0,0 +1,84 @@ +import importlib.util +import os +from pathlib import Path +import unittest +from unittest.mock import patch + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "tools" / "config_generator.py" +spec = importlib.util.spec_from_file_location("config_generator", MODULE_PATH) +config_generator = importlib.util.module_from_spec(spec) +spec.loader.exec_module(config_generator) + + +class ProductionSecretValidationTest(unittest.TestCase): + def test_production_config_requires_all_secret_keys(self): + with patch.dict(os.environ, {}, clear=True): + config = config_generator.generate_config("production") + + valid, errors = config_generator.validate_production_secrets(config) + + self.assertFalse(valid) + self.assertEqual( + errors, + [ + "database.password is required for production", + "redis.password is required for production", + "auth.jwt_secret is required for production", + ], + ) + + def test_production_config_accepts_environment_secrets(self): + env = { + "DATABASE_PASSWORD": "database-secret-value", + "REDIS_PASSWORD": "redis-secret-value", + "AUTH_JWT_SECRET": "jwt-secret-value", + } + + with patch.dict(os.environ, env, clear=True): + config = config_generator.generate_config("production") + + valid, errors = config_generator.validate_production_secrets(config) + + self.assertTrue(valid) + self.assertEqual(errors, []) + + def test_placeholder_secret_values_are_rejected_without_leaking_value(self): + overrides = { + "database": {"password": "change-me"}, + "redis": {"password": "replace-this"}, + "auth": {"jwt_secret": "your-secret-here"}, + } + + with patch.dict(os.environ, {}, clear=True): + config = config_generator.generate_config("production", overrides) + + valid, errors = config_generator.validate_production_secrets(config) + joined_errors = "\n".join(errors) + + self.assertFalse(valid) + self.assertIn("database.password", joined_errors) + self.assertIn("redis.password", joined_errors) + self.assertIn("auth.jwt_secret", joined_errors) + self.assertNotIn("change-me", joined_errors) + self.assertNotIn("replace-this", joined_errors) + self.assertNotIn("your-secret-here", joined_errors) + + def test_non_production_generation_remains_compatible(self): + with patch.dict(os.environ, {}, clear=True): + development = config_generator.generate_config("development") + staging = config_generator.generate_config("staging") + + self.assertEqual(development["app"]["environment"], "development") + self.assertEqual(staging["app"]["environment"], "staging") + self.assertEqual(development["database"]["password"], "") + self.assertEqual(staging["redis"]["password"], "") + + def test_sensitive_key_list_has_no_duplicates(self): + keys = config_generator.SENSITIVE_KEYS + + self.assertEqual(sorted(keys), sorted(set(keys))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/config_generator.py b/tools/config_generator.py index cf20d9163..b236e3c70 100644 --- a/tools/config_generator.py +++ b/tools/config_generator.py @@ -22,12 +22,13 @@ """ import argparse +import copy import json import os import sys from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple try: import yaml @@ -168,9 +169,30 @@ SENSITIVE_KEYS = [ "database.password", "redis.password", "auth.jwt_secret", - "auth.jwt_secret", "auth.jwt_secret", ] +PRODUCTION_SECRET_ENV_VARS = { + "database.password": "DATABASE_PASSWORD", + "redis.password": "REDIS_PASSWORD", + "auth.jwt_secret": "AUTH_JWT_SECRET", +} + +PLACEHOLDER_SECRET_VALUES = { + "", + "changeme", + "change-me", + "change_me", + "default", + "example", + "password", + "placeholder", + "secret", + "todo", + "your-secret", + "your-secret-here", + "***redacted***", +} + def merge_config(base: Dict, override: Dict) -> Dict: result = dict(base) @@ -183,14 +205,61 @@ def merge_config(base: Dict, override: Dict) -> Dict: def generate_config(env: str, overrides: Optional[Dict] = None) -> Dict: - config = dict(DEFAULT_CONFIG) + config = copy.deepcopy(DEFAULT_CONFIG) if env in ENV_OVERRIDES: config = merge_config(config, ENV_OVERRIDES[env]) if overrides: config = merge_config(config, overrides) + if env == "production": + config = apply_environment_secrets(config) return config +def get_nested(config: Dict, dotted_key: str) -> Any: + value: Any = config + for part in dotted_key.split("."): + if not isinstance(value, dict) or part not in value: + return None + value = value[part] + return value + + +def set_nested(config: Dict, dotted_key: str, value: Any) -> None: + current = config + parts = dotted_key.split(".") + for part in parts[:-1]: + current = current.setdefault(part, {}) + current[parts[-1]] = value + + +def is_placeholder_secret(value: Any) -> bool: + if value is None: + return True + if not isinstance(value, str): + return False + normalized = value.strip().lower() + if normalized in PLACEHOLDER_SECRET_VALUES: + return True + return normalized.startswith("replace-") or normalized.startswith("replace_") + + +def apply_environment_secrets(config: Dict) -> Dict: + result = copy.deepcopy(config) + for dotted_key, env_var in PRODUCTION_SECRET_ENV_VARS.items(): + env_value = os.environ.get(env_var) + if env_value is not None: + set_nested(result, dotted_key, env_value) + return result + + +def validate_production_secrets(config: Dict) -> Tuple[bool, List[str]]: + errors = [] + for dotted_key in PRODUCTION_SECRET_ENV_VARS: + if is_placeholder_secret(get_nested(config, dotted_key)): + errors.append(f"{dotted_key} is required for production") + return not errors, errors + + def mask_sensitive(config: Dict, prefix: str = "") -> Dict: masked = {} for key, value in config.items(): @@ -320,6 +389,14 @@ def main(): args = parse_args() config = generate_config(args.env) + if args.env == "production": + valid, errors = validate_production_secrets(config) + if not valid: + print("Production configuration validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + if not args.show_sensitive: display_config = mask_sensitive(config) else: @@ -350,4 +427,4 @@ def main(): if __name__ == "__main__": - main() + sys.exit(main())