From 858e0f582f756f0d526b725aa4c092815bf39436 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Wed, 10 Jun 2026 19:09:09 +0000 Subject: [PATCH] fix(harness): redact secret-named meta values in the customization manifest (rcust) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversary heads-up (inbox 2026-06-10T19:06Z): meta values are repo-public by construction, but the manifest lands on the dashboard — a field literally named SECRET_KEY_BASE showing a value (plausible's committed CI dummy) is needless secret-scan noise. Mask values whose key NAME is secret-shaped (SECRET|PASSWORD|TOKEN|CREDENTIAL|word-segment KEY), top-level and nested dict keys; the key name stays visible. Unit test pins redacted vs passthrough (KEYCLOAK_URL). Co-Authored-By: Claude Fable 5 --- runner/harness/manifest.py | 18 +++++++++++++++--- tests/unit/test_manifest.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/runner/harness/manifest.py b/runner/harness/manifest.py index d60e4f6..7a2a00c 100644 --- a/runner/harness/manifest.py +++ b/runner/harness/manifest.py @@ -16,14 +16,26 @@ from . import meta as meta_mod _PRE_OP_RE = re.compile(r"^def (pre_[a-z]+)\(", re.MULTILINE) +# Meta values are repo-public by construction (recipe_meta.py is committed; real secrets are +# class-B generated, never meta), but the manifest lands on the dashboard — mask values whose +# key NAME is secret-shaped so a field literally called SECRET_KEY_BASE never shows a value +# (defense in depth + keeps dashboard secret-scans quiet). `KEY` matches only as a word segment +# (API_KEY yes, KEYCLOAK_URL no). +_SENSITIVE_NAME_RE = re.compile(r"SECRET|PASSWORD|TOKEN|CREDENTIAL|(^|_)KEY(_|$)", re.IGNORECASE) -def _jsonable(v): + +def _jsonable(v, name=""): """Manifest values must be JSON-serializable + deterministic: hooks render as '', - tuples become lists.""" + tuples become lists, secret-named entries (by key name, incl. nested dict keys) as + ''.""" if callable(v): return "" + if name and _SENSITIVE_NAME_RE.search(name): + return "" if isinstance(v, tuple): return list(v) + if isinstance(v, dict): + return {k: _jsonable(x, name=str(k)) for k, x in v.items()} return v @@ -89,7 +101,7 @@ def build(recipe: str, meta, repo_local: str | None) -> dict: return { "meta_non_default": { - k: _jsonable(v) for k, v in sorted(meta_mod.non_default(meta).items()) + k: _jsonable(v, name=k) for k, v in sorted(meta_mod.non_default(meta).items()) }, "hooks": hooks, "overlays": overlays, diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py index 42c8830..e415c5e 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_manifest.py @@ -132,6 +132,36 @@ def test_manifest_env_overrides_and_ci_flag(tmp_path, monkeypatch): assert "!! dev-only override active in CI" in manifest.render(RECIPE, m) +def test_manifest_redacts_sensitive_named_values(tmp_path, monkeypatch): + # Meta values are repo-public by construction, but the manifest lands on the dashboard: + # secret-NAMED entries (top-level or nested dict keys, e.g. plausible's + # EXTRA_ENV["SECRET_KEY_BASE"] dummy) render as '' — name shown, value masked. + # Non-sensitive names (incl. KEYCLOAK_* — 'KEY' matches only as a word segment) pass through. + ccci_root = tmp_path / "cc-ci-tests" + d = ccci_root / RECIPE + d.mkdir(parents=True) + (d / "recipe_meta.py").write_text( + "EXTRA_ENV = {\n" + " 'SECRET_KEY_BASE': 'dummy-ci-constant',\n" + " 'API_KEY': 'also-dummy',\n" + " 'KEYCLOAK_URL': 'https://kc.example',\n" + "}\n" + ) + monkeypatch.setattr(discovery, "cc_ci_dir", lambda r: str(ccci_root / r)) + monkeypatch.setattr(meta_mod, "TESTS_DIR", str(ccci_root)) + monkeypatch.setenv("CCCI_REPO_LOCAL_APPROVED_FILE", str(tmp_path / "missing.txt")) + meta = meta_mod.load(RECIPE, tests_dir=str(ccci_root)) + m = manifest.build(RECIPE, meta, None) + assert m["meta_non_default"]["EXTRA_ENV"] == { + "SECRET_KEY_BASE": "", + "API_KEY": "", + "KEYCLOAK_URL": "https://kc.example", + } + out = manifest.render(RECIPE, m) + assert "dummy-ci-constant" not in out and "also-dummy" not in out + assert "SECRET_KEY_BASE" in out # the key NAME stays visible + + def test_render_lists_every_surface(tmp_path, monkeypatch): meta, rl = _mk_synthetic(tmp_path, monkeypatch) out = manifest.render(RECIPE, manifest.build(RECIPE, meta, rl))