From e3f58e84f05966c51606e048bcc341df5f17bca1 Mon Sep 17 00:00:00 2001 From: leo202000 Date: Mon, 22 Jun 2026 22:53:05 +0800 Subject: [PATCH 1/2] feat(config): validate required production secrets Add focused validation for required production secrets before a production config is accepted. The generator now fails fast when database.password, redis.password, or auth.jwt_secret is empty, missing, or placeholder-like. - validate_required_secrets() returns human-readable errors identifying the key path but never the secret value - generate_config('production') raises SecretValidationError on invalid secrets - load_secret_overrides() reads TOT_DATABASE_PASSWORD / TOT_REDIS_PASSWORD / TOT_JWT_SECRET so production configs can be generated from a vault/env - Non-production environments skip validation so sample generation is unchanged - Add tests/test_config_secret_validation.py (valid, missing, placeholder, non-prod compatibility, no-value-leak, env-var loading) - Document the validation behavior in docs/OPERATIONS.md --- docs/OPERATIONS.md | 43 ++++++++ tests/test_config_secret_validation.py | 137 +++++++++++++++++++++++++ tools/config_generator.py | 127 ++++++++++++++++++++++- 3 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 tests/test_config_secret_validation.py diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 58642e7b..c506b4ba 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -276,6 +276,49 @@ Audit logs are retained for 365 days and include: | Penetration test | Quarterly | External vendor | | Compliance audit | Annually | External auditor | +### Production Secret Validation + +The config generator (`tools/config_generator.py`) validates that required +production secrets are present and not placeholder-like before a production +config is accepted. This prevents deployments that silently inherit empty or +default secret values from the base template. + +**Required secrets (production only):** + +| Config key | Environment variable | +|------------|----------------------| +| `database.password` | `TOT_DATABASE_PASSWORD` | +| `redis.password` | `TOT_REDIS_PASSWORD` | +| `auth.jwt_secret` | `TOT_JWT_SECRET` | + +**Behavior:** + +- Generating a production config fails fast when any required secret is empty, + missing, or resembles a placeholder (e.g. `changeme`, `placeholder`, `todo`, + ``, or any value shorter than 8 characters). +- Validation error messages identify the offending key path but never print the + secret value itself. +- Non-production environments (`development`, `staging`) are **not** validated, + so sample config generation remains compatible. +- Set the secrets via the environment variables above (or inject them from a + vault) before running a production generation: + + ```bash + export TOT_DATABASE_PASSWORD="$(vault kv get -field=password secret/tot/db)" + export TOT_REDIS_PASSWORD="$(vault kv get -field=password secret/tot/redis)" + export TOT_JWT_SECRET="$(vault kv get -field=secret secret/tot/jwt)" + python3 tools/config_generator.py --env production --format json --output config.json + ``` + +- Generated configs mask secret values by default (`***REDACTED***`); pass + `--show-sensitive` only in trusted, non-logged contexts. + +**Failure example:** + +``` +error: Required production secret 'database.password' is empty or placeholder-like; set a real value via the TOT_DATABASE_PASSWORD environment variable or a vault. +``` + ## Troubleshooting ### Common Issues diff --git a/tests/test_config_secret_validation.py b/tests/test_config_secret_validation.py new file mode 100644 index 00000000..86513e97 --- /dev/null +++ b/tests/test_config_secret_validation.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +"""Tests for required production secret validation in config_generator.py.""" +import os +import sys +import unittest +from unittest import mock + +TOOLS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "tools")) +if TOOLS_DIR not in sys.path: + sys.path.insert(0, TOOLS_DIR) + +import config_generator as cg + + +def _valid_secrets(): + """Override dict that satisfies production secret validation.""" + return { + "database": {"password": "9f2a7c4e1b3d5860"}, + "redis": {"password": "r3d1s-cred-7q2z-9988"}, + "auth": {"jwt_secret": "jwt-s3cr3t-0a1b2c3d4e5f"}, + } + + +class ValidateRequiredSecretsTests(unittest.TestCase): + def test_passes_when_all_secrets_set(self): + config = cg.generate_config("production", overrides=_valid_secrets()) + self.assertEqual(cg.validate_required_secrets(config, "production"), []) + + def test_fails_when_a_secret_is_empty(self): + config = cg.generate_config("production", overrides=_valid_secrets()) + config["database"]["password"] = "" + errors = cg.validate_required_secrets(config, "production") + self.assertTrue(any("database.password" in e for e in errors)) + + def test_fails_when_a_secret_is_placeholder_like(self): + for bad in ("changeme123", "PLACEHOLDER", "", "todo-later"): + config = cg.generate_config("production", overrides=_valid_secrets()) + config["redis"]["password"] = bad + errors = cg.validate_required_secrets(config, "production") + self.assertTrue( + any("redis.password" in e for e in errors), + f"expected placeholder '{bad}' to be flagged", + ) + + def test_fails_when_a_secret_key_is_missing(self): + config = cg.generate_config("production", overrides=_valid_secrets()) + del config["auth"]["jwt_secret"] + errors = cg.validate_required_secrets(config, "production") + self.assertTrue(any("jwt_secret" in e and "missing" in e for e in errors)) + + def test_short_secret_is_treated_as_placeholder(self): + config = cg.generate_config("production", overrides=_valid_secrets()) + config["database"]["password"] = "short" + errors = cg.validate_required_secrets(config, "production") + self.assertTrue(any("database.password" in e for e in errors)) + + def test_non_production_environments_skip_validation(self): + for env in ("development", "staging"): + # Defaults have empty secrets but non-prod must still validate clean. + config = cg.generate_config(env) + self.assertEqual(cg.validate_required_secrets(config, env), []) + + def test_error_messages_do_not_leak_secret_values(self): + config = cg.generate_config("production", overrides=_valid_secrets()) + leak = "changeme-UNIQUELEAK123" + config["auth"]["jwt_secret"] = leak + errors = cg.validate_required_secrets(config, "production") + joined = " ".join(errors) + self.assertTrue(any("jwt_secret" in e for e in errors)) + self.assertNotIn(leak, joined) + self.assertNotIn("UNIQUELEAK123", joined) + + +class GenerateConfigValidationTests(unittest.TestCase): + def test_production_raises_on_empty_default_secrets(self): + with self.assertRaises(cg.SecretValidationError) as ctx: + cg.generate_config("production") + self.assertEqual(len(ctx.exception.errors), 3) + + def test_production_succeeds_when_secrets_provided(self): + config = cg.generate_config("production", overrides=_valid_secrets()) + self.assertEqual(config["app"]["environment"], "production") + self.assertEqual(config["database"]["password"], "9f2a7c4e1b3d5860") + + def test_development_remains_compatible(self): + config = cg.generate_config("development") + self.assertEqual(config["app"]["environment"], "development") + self.assertEqual(config["database"]["password"], "") + + def test_staging_remains_compatible(self): + config = cg.generate_config("staging") + self.assertEqual(config["app"]["environment"], "staging") + + +class LoadSecretOverridesTests(unittest.TestCase): + def test_reads_env_vars_into_nested_overrides(self): + env = { + "TOT_DATABASE_PASSWORD": "env-db-pwd-12345678", + "TOT_REDIS_PASSWORD": "env-redis-pwd-12345", + "TOT_JWT_SECRET": "env-jwt-cred-abcdef", + } + with mock.patch.dict(os.environ, env, clear=False): + overrides = cg.load_secret_overrides() + self.assertEqual(overrides["database"]["password"], "env-db-pwd-12345678") + self.assertEqual(overrides["redis"]["password"], "env-redis-pwd-12345") + self.assertEqual(overrides["auth"]["jwt_secret"], "env-jwt-cred-abcdef") + + def test_unset_env_vars_are_skipped(self): + clean = { + k: v for k, v in os.environ.items() + if k not in cg.SECRET_ENV_VARS.values() + } + with mock.patch.dict(os.environ, clean, clear=True): + self.assertEqual(cg.load_secret_overrides(), {}) + + def test_env_vars_make_production_generation_pass(self): + env = { + "TOT_DATABASE_PASSWORD": "env-db-pwd-12345678", + "TOT_REDIS_PASSWORD": "env-redis-pwd-12345", + "TOT_JWT_SECRET": "env-jwt-cred-abcdef", + } + with mock.patch.dict(os.environ, env, clear=False): + config = cg.generate_config("production", overrides=cg.load_secret_overrides()) + self.assertEqual(config["database"]["password"], "env-db-pwd-12345678") + + +class PlaceholderDetectionTests(unittest.TestCase): + def test_none_and_non_string_are_placeholder(self): + self.assertTrue(cg._is_placeholder_like(None)) + self.assertTrue(cg._is_placeholder_like(12345)) + + def test_real_value_is_not_placeholder(self): + self.assertFalse(cg._is_placeholder_like("9f2a7c4e1b3d5860")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/config_generator.py b/tools/config_generator.py index cf20d916..42aed1c2 100644 --- a/tools/config_generator.py +++ b/tools/config_generator.py @@ -172,6 +172,122 @@ ] +# Required production secrets that must be explicitly set before a production +# config is accepted. Each entry is a dotted path into the config dict. +REQUIRED_SECRETS: List[str] = [ + "database.password", + "redis.password", + "auth.jwt_secret", +] + +# Environment variables that populate the required secrets. Set these (or use a +# vault) before generating a production config. +SECRET_ENV_VARS: Dict[str, str] = { + "database.password": "TOT_DATABASE_PASSWORD", + "redis.password": "TOT_REDIS_PASSWORD", + "auth.jwt_secret": "TOT_JWT_SECRET", +} + +# Substrings that indicate a secret value is a placeholder rather than a real +# value. Matching is case-insensitive. +_PLACEHOLDER_TOKENS = ( + "changeme", "change-me", "change_me", "placeholder", "todo", "tbd", + "xxx", "yyy", "zzz", "secret", "your-", "your_", "example", "replace", + "default", "dummy", "sample", "none", "null", "<", ">", "insert", + "set-me", "set_me", "fill", "fixme", +) + + +class SecretValidationError(Exception): + """Raised when required production secrets are missing or placeholder-like. + + The exception message lists the offending key paths but never includes the + secret values themselves. + """ + + def __init__(self, errors: List[str]): + self.errors = errors + super().__init__( + "Production secret validation failed:\n" + "\n".join(errors) + ) + + +def _get_nested(config: Dict, dotted_key: str, default: Any = None) -> Any: + """Fetch a value from a nested dict using a dotted path.""" + current: Any = config + for part in dotted_key.split("."): + if isinstance(current, dict) and part in current: + current = current[part] + else: + return default + return current + + +def _is_placeholder_like(value: Any) -> bool: + """Return True if a secret value is empty or resembles a placeholder.""" + if value is None: + return True + if not isinstance(value, str): + return True + if value.strip() == "": + return True + lowered = value.lower() + for token in _PLACEHOLDER_TOKENS: + if token in lowered: + return True + # Trivially short values are unsafe placeholders. + if len(value.strip()) < 8: + return True + return False + + +def validate_required_secrets(config: Dict, env: str = "production") -> List[str]: + """Validate that required production secrets are set and not placeholder-like. + + Returns a list of human-readable error messages. Each message identifies the + offending key path but never includes the secret value. Only the production + environment is validated; non-production sample config generation always + passes so development/staging workflows stay compatible. + """ + if env != "production": + return [] + errors: List[str] = [] + for key_path in REQUIRED_SECRETS: + value = _get_nested(config, key_path) + env_hint = SECRET_ENV_VARS.get(key_path, "?") + if value is None: + errors.append( + f"Required production secret '{key_path}' is missing; set it " + f"via the {env_hint} environment variable or a vault." + ) + elif _is_placeholder_like(value): + errors.append( + f"Required production secret '{key_path}' is empty or " + f"placeholder-like; set a real value via the {env_hint} " + f"environment variable or a vault." + ) + return errors + + +def load_secret_overrides() -> Dict[str, Any]: + """Build a nested overrides dict for required secrets from environment vars. + + Only environment variables that are actually set are included, so unset + values do not clobber existing config. + """ + overrides: Dict[str, Any] = {} + for key_path, env_name in SECRET_ENV_VARS.items(): + value = os.environ.get(env_name) + if value is None: + continue + parts = key_path.split(".") + node = overrides + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = value + return overrides + + def merge_config(base: Dict, override: Dict) -> Dict: result = dict(base) for key, value in override.items(): @@ -188,6 +304,9 @@ def generate_config(env: str, overrides: Optional[Dict] = None) -> Dict: config = merge_config(config, ENV_OVERRIDES[env]) if overrides: config = merge_config(config, overrides) + errors = validate_required_secrets(config, env) + if errors: + raise SecretValidationError(errors) return config @@ -318,7 +437,13 @@ def parse_args(): def main(): args = parse_args() - config = generate_config(args.env) + try: + overrides = load_secret_overrides() + config = generate_config(args.env, overrides=overrides) + except SecretValidationError as exc: + for error in exc.errors: + print(f"error: {error}", file=sys.stderr) + return 1 if not args.show_sensitive: display_config = mask_sensitive(config) From 8fb11bf64bce46e43b2f347af5396903a1ade1f0 Mon Sep 17 00:00:00 2001 From: leo202000 Date: Mon, 22 Jun 2026 22:54:55 +0800 Subject: [PATCH 2/2] Add diagnostic build log for e3f58e84 --- diagnostic/build-e3f58e84.json | 87 +++++++++++++++++++++++++++++++++ diagnostic/build-e3f58e84.logd | Bin 0 -> 15382 bytes 2 files changed, 87 insertions(+) create mode 100644 diagnostic/build-e3f58e84.json create mode 100644 diagnostic/build-e3f58e84.logd diff --git a/diagnostic/build-e3f58e84.json b/diagnostic/build-e3f58e84.json new file mode 100644 index 00000000..f30d75ff --- /dev/null +++ b/diagnostic/build-e3f58e84.json @@ -0,0 +1,87 @@ +{ + "generated_at": "2026-06-22T14:54:38.861652+00:00", + "commit": "e3f58e84", + "diagnostic_logd": "diagnostic/build-e3f58e84.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "0e38cd2462c8c961fae1", + "decrypt_command": "encryptly unpack diagnostic/build-e3f58e84.logd --password 0e38cd2462c8c961fae1", + "total_modules": 10, + "passed": 2, + "failed": 8, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0.074, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'cargo'" + }, + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 45.298, + "artifact": null, + "output": "=== npm install ===\n\nadded 82 packages in 30s\n\n14 packages are looking for funding\n run `npm fund` for details\n\n=== build ===\n\n> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\nvite v6.4.3 building for production...\ntransforming...\n\u2713 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.63 kB \u2502 gzip: 0.35 kB\ndist/assets/state-BkjSKDbY.js 8.91 kB \u2502 gzip: 3.54 kB \u2502 map: 57.15 kB\ndist/assets/vendor-CREcWLHI.js 48.93 kB \u2502 gzip: 17.25 kB \u2502 map: 481.27 kB\ndist/assets/index-CyxcoTyU.js 231.32 kB \u2502 gzip: 72.16 kB \u2502 map: 1,045.57 kB\n\u2713 built in 3.92s\n" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0.112, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'go'" + }, + { + "name": "frailbox", + "status": "PASS", + "elapsed_seconds": 1.947, + "artifact": null, + "output": "=== build ===\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/arena.c -o build/src/arena.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/logger.c -o build/src/logger.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/sandbox.c -o build/src/sandbox.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c main.c -o build/main.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude build/src/arena.o build/src/logger.o build/src/sandbox.o build/main.o -o frailbox -pie -z relro -z now\nsrc/arena.c: In function \u2018arena_contains\u2019:\nsrc/arena.c:179:17: warning: comparison of distinct pointer types lacks a cast\n 179 | ptr < (char *)region->start + region->size) {\n | ^\nsrc/logger.c: In function \u2018log_message\u2019:\nsrc/logger.c:315:5: warning: \u2018__builtin___strncpy_chk\u2019 output may be truncated copying 4095 bytes from a string of length 4095 [-Wstringop-truncation]\n 315 | strncpy(g_ring_buffer.entries[g_ring_buffer.head], message, MAX_LOG_LINE - 1);\n | ^\n" + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0.116, + "artifact": null, + "output": "=== build ===\nCMake Error: The current CMakeCache.txt directory /mnt/e/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build/CMakeCache.txt is different than the directory e:/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build where CMakeCache.txt was created. This may result in binaries being created in the wrong place. If you are not sure, reedit the CMakeCache.txt\nError: could not create CMAKE_GENERATOR \"Visual Studio 18 2026\"\n" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0.08, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'javac'" + }, + { + "name": "v2-market-stream", + "status": "FAIL", + "elapsed_seconds": 0.081, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'ruby'" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0.069, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'luac'" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0.077, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'ghc'" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0.067, + "artifact": null, + "output": "ERROR: [Errno 2] No such file or directory: 'luac'" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-e3f58e84.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." +} \ No newline at end of file diff --git a/diagnostic/build-e3f58e84.logd b/diagnostic/build-e3f58e84.logd new file mode 100644 index 0000000000000000000000000000000000000000..b11c09f8151c41b22a4a52796d775d335ed8baf0 GIT binary patch literal 15382 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ʡ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≪yZgEk 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