"""Guard public client builds from server-only secrets.
This script enforces the policy in app/config/client_env_policy.yaml. It is
deliberately stdlib-only so it can run in git hooks, Codemagic, and GitHub
Actions before language-specific setup.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
POLICY_PATH = ROOT / "app" / "config" / "client_env_policy.yaml"
APP_LIB = ROOT / "app" / "lib"
LEGACY_DIRECT_PROVIDER_ALLOW_COMMENT = "public-client-secret-boundary: legacy-direct-provider"
def load_policy() -> dict:
with POLICY_PATH.open(encoding="utf-8") as handle:
return json.load(handle)
def compile_patterns(policy: dict) -> list[re.Pattern[str]]:
patterns = policy["server_secret_env"]["denied_name_patterns"]
return [re.compile(pattern) for pattern in patterns]
def denied_names(policy: dict) -> set[str]:
return set(policy["server_secret_env"]["denied_exact"])
def allowed_names(policy: dict) -> set[str]:
return set(policy["public_client_env"]["allowed"]) | set(
policy.get("legacy_public_client_env", {}).get("allowed", [])
)
def allowed_public_names(policy: dict) -> set[str]:
return allowed_names(policy) | set(policy.get("public_web_build_args", {}).get("allowed", []))
def name_is_denied(name: str, exact: set[str], patterns: list[re.Pattern[str]]) -> bool:
return name in exact or any(pattern.search(name) for pattern in patterns)
def variable_like_tokens(text: str) -> set[str]:
return set(re.findall(r"\b[A-Z][A-Z0-9_]{2,}\b", text))
def line_reads_env_or_config(line: str) -> bool:
lowered = line.lower()
return any(marker in lowered for marker in ("env", "getenv", "process.env", "platform.environment", "buildconfig"))
def git_files() -> list[Path]:
result = subprocess.run(
["git", "ls-files"],
cwd=ROOT,
check=True,
text=True,
stdout=subprocess.PIPE,
)
return [ROOT / line for line in result.stdout.splitlines() if line]
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="ignore")
def display_path(path: Path) -> str:
try:
return str(path.resolve().relative_to(ROOT))
except ValueError:
return str(path)
def denied_env_values(policy: dict) -> dict[str, str]:
exact = denied_names(policy)
patterns = compile_patterns(policy)
return {
name: value
for name, value in os.environ.items()
if value
and len(value) >= 8
and not name.startswith(("PUBLIC_", "NEXT_PUBLIC_"))
and name_is_denied(name, exact, patterns)
}
def check_duplicate_yaml_keys(path: Path) -> list[str]:
errors: list[str] = []
seen_by_indent: dict[int, set[str]] = {}
block_scalar_indent: int | None = None
key_re = re.compile(r"^(\s*)(?:[\"']?)([A-Za-z0-9_.-]+)(?:[\"']?):(?:\s.*)?$")
for lineno, raw in enumerate(read_text(path).splitlines(), start=1):
if not raw.strip() or raw.lstrip().startswith("#"):
continue
indent = len(raw) - len(raw.lstrip(" "))
if block_scalar_indent is not None:
if indent > block_scalar_indent:
continue
block_scalar_indent = None
if raw.lstrip().startswith("- "):
for existing_indent in list(seen_by_indent):
if existing_indent > indent:
del seen_by_indent[existing_indent]
match = key_re.match(raw)
if not match:
continue
key = match.group(2)
line_tail = raw.split(":", 1)[1].strip()
if line_tail in {"|", "|-", "|+", ">", ">-", ">+"}:
block_scalar_indent = indent
for existing_indent in list(seen_by_indent):
if existing_indent > indent:
del seen_by_indent[existing_indent]
seen = seen_by_indent.setdefault(indent, set())
if key in seen:
errors.append(f"{display_path(path)}:{lineno}: duplicate YAML key {key!r} at indent {indent}")
seen.add(key)
return errors
def check_policy_shape(policy: dict) -> list[str]:
errors: list[str] = []
allowed = allowed_names(policy)
public_prefixed = set(policy["public_client_env"]["allowed"])
exact = denied_names(policy)
patterns = compile_patterns(policy)
restricted = policy.get("restricted_public_client_keys", {})
direct_provider_exceptions = policy.get("legacy_direct_provider_domain_exceptions", {})
allowed_build_secret_source_refs = set(policy.get("allowed_build_secret_source_references", []))
for name in sorted(public_prefixed):
if not name.startswith("PUBLIC_"):
errors.append(f"{POLICY_PATH}: public client env name must use PUBLIC_ prefix: {name}")
if name_is_denied(name, exact, patterns) and name not in restricted:
errors.append(f"{POLICY_PATH}: denied-looking public client env name {name} needs restricted metadata")
for name, metadata in sorted(restricted.items()):
if name not in allowed:
errors.append(f"{POLICY_PATH}: restricted public key {name} must also be in public_client_env.allowed")
for field in ("owner", "purpose", "restriction", "revocation"):
if not str(metadata.get(field, "")).strip():
errors.append(f"{POLICY_PATH}: restricted public key {name} is missing {field}")
for path, metadata in sorted(direct_provider_exceptions.items()):
exception_path = ROOT / path
if not exception_path.exists():
errors.append(f"{POLICY_PATH}: legacy direct-provider exception points at missing file: {path}")
for field in ("owner", "reason", "migration"):
if not str(metadata.get(field, "")).strip():
errors.append(f"{POLICY_PATH}: legacy direct-provider exception {path} is missing {field}")
allowed_occurrences = metadata.get("allowed_occurrences")
if not isinstance(allowed_occurrences, dict) or not allowed_occurrences:
errors.append(f"{POLICY_PATH}: legacy direct-provider exception {path} must pin allowed_occurrences")
else:
for domain, count in allowed_occurrences.items():
if domain not in policy.get("direct_provider_domains_denied_in_app", []):
errors.append(
f"{POLICY_PATH}: legacy direct-provider exception {path} pins unknown domain {domain}"
)
if not isinstance(count, int) or count < 0:
errors.append(
f"{POLICY_PATH}: legacy direct-provider exception {path} has invalid count for {domain}"
)
if exception_path.exists():
text = read_text(exception_path)
for domain in policy.get("direct_provider_domains_denied_in_app", []):
actual = text.count(domain)
expected = int(allowed_occurrences.get(domain, 0))
if actual != expected:
errors.append(
f"{path}: legacy direct-provider domain {domain} count changed: expected {expected}, found {actual}"
)
for lineno, line in enumerate(text.splitlines(), start=1):
for domain in policy.get("direct_provider_domains_denied_in_app", []):
if domain in line and LEGACY_DIRECT_PROVIDER_ALLOW_COMMENT not in line:
errors.append(
f"{path}:{lineno}: legacy direct-provider domain {domain} needs inline allow comment"
)
for name in sorted(allowed_build_secret_source_refs):
if not name_is_denied(name, exact, patterns):
errors.append(f"{POLICY_PATH}: allowed build secret source reference is not denied by policy: {name}")
return errors
def check_env_file(path: Path, policy: dict) -> list[str]:
errors: list[str] = []
allowed = allowed_names(policy)
exact = denied_names(policy)
patterns = compile_patterns(policy)
private_values = denied_env_values(policy)
if not path.exists():
return errors
for lineno, raw in enumerate(read_text(path).splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
name = line.split("=", 1)[0].strip()
if not name:
continue
if name not in allowed:
errors.append(f"{path}:{lineno}: {name} is not in public_client_env.allowed")
if name not in allowed and name_is_denied(name, exact, patterns):
errors.append(f"{path}:{lineno}: {name} is server-only and cannot enter public client env")
value = line.split("=", 1)[1] if "=" in line else ""
for env_name, private_value in private_values.items():
if value == private_value:
errors.append(f"{path}:{lineno}: value matches current private env value {env_name}")
return errors
def check_app_source(policy: dict) -> list[str]:
errors: list[str] = []
exact = denied_names(policy)
patterns = compile_patterns(policy)
denied_domains = set(policy.get("direct_provider_domains_denied_in_app", []))
legacy_domain_exceptions = set(policy.get("legacy_direct_provider_domain_exceptions", {}).keys())
allowed_build_secret_source_refs = set(policy.get("allowed_build_secret_source_references", []))
allowed_public = allowed_public_names(policy) | set(policy.get("allowed_public_client_tokens", []))
source_roots = [
APP_LIB,
ROOT / "app" / "ios",
ROOT / "app" / "android",
ROOT / "app" / "macos",
ROOT / "app" / "setup",
ROOT / "app" / "setup.sh",
ROOT / "app" / "initialsetup.bash",
]
suffixes = {
".dart",
".swift",
".kt",
".java",
".m",
".mm",
".h",
".plist",
".xml",
".gradle",
".sh",
".bash",
".ps1",
".json",
".env",
}
files = [
path
for path in git_files()
if path.exists()
if path in source_roots
or any(path.is_relative_to(root) for root in source_roots if root.is_dir())
and path.suffix in suffixes
and not path.name.endswith(".g.dart")
and not path.name.endswith(".gen.dart")
]
for path in files:
text = read_text(path)
rel = path.relative_to(ROOT)
rel_text = str(rel)
for name in exact:
if name in text and name not in allowed_build_secret_source_refs and name not in allowed_public:
errors.append(f"{rel}: references server-only env name {name}")
for lineno, line in enumerate(text.splitlines(), start=1):
if not line_reads_env_or_config(line):
continue
for token in variable_like_tokens(line):
if (
token not in allowed_build_secret_source_refs
and token not in allowed_public
and name_is_denied(token, exact, patterns)
):
errors.append(f"{rel}:{lineno}: references server-only env-like token {token}")
for domain in denied_domains:
if domain in text and rel_text not in legacy_domain_exceptions:
errors.append(f"{rel}: directly references denied provider domain {domain}")
generated = [ROOT / "app" / "lib" / "env" / "prod_env.g.dart", ROOT / "app" / "lib" / "env" / "dev_env.g.dart"]
for path in generated:
if not path.exists():
continue
text = read_text(path)
rel = path.relative_to(ROOT)
for name in exact:
if name in text and name not in allowed_public:
errors.append(f"{rel}: generated Envied output contains server-only env name {name}")
for token in variable_like_tokens(text):
if token not in allowed_public and name_is_denied(token, exact, patterns):
errors.append(f"{rel}: generated Envied output contains server-only env-like token {token}")
return errors
PUBLIC_ENV_FILE_RE = re.compile(r"(?:^|/)(?:\.client(?:\.dev)?\.env|\.env)(?:[\"']|\s|$)")
ENV_WRITE_RE = re.compile(
r"\b(?:echo|printf)\b.*(?:>|>>)\s*[\"']?(?:[^\"'\s>]*/)?(\.client(?:\.dev)?\.env|\.env)[\"']?"
)
PUBLIC_ASSIGNMENT_RE = re.compile(r"^\s*(?:export\s+)?(PUBLIC_[A-Z0-9_]*)=(.+?)(?:\s*\\)?$")
PUBLIC_ASSIGNMENT_ANYWHERE_RE = re.compile(r"(?:^|\s)(PUBLIC_[A-Z0-9_]*)=(\"[^\"]*\"|'[^']*'|[^\s\\]+)")
ENV_REF_RE = re.compile(r"\$(?:\{?([A-Z][A-Z0-9_]*)\}?|{{\s*secrets\.([A-Z][A-Z0-9_]*)\s*}})")
PRINTENV_REF_RE = re.compile(r"printenv\s+([A-Z][A-Z0-9_]*)")
GITHUB_SECRET_RE = re.compile(r"secrets(?:\.|\[['\"])([A-Z][A-Z0-9_]*)")
SHELL_ASSIGNMENT_RE = re.compile(r"(?:^|\s)([A-Z][A-Z0-9_]*)=(\"[^\"]*\"|'[^']*'|[^\s\\]+)")
PUBLIC_ENV_TARGET_RE = re.compile(
r"(?:^|[\s/\"'])(?:\.client(?:\.dev)?\.env|\.env|\.env\.production|\.env\.local)(?:[\"']|\s|$)"
)
SENSITIVE_ECHO_RE = re.compile(r"\b(?:echo|printf)\b")
SHELL_TRACE_RE = re.compile(r"(^|\s|[;&|]\s*)set\s+(?:-[A-Za-z]*x[A-Za-z]*|-o\s+xtrace)(\s|$|[;&|])")
SHELL_TRACE_OFF_RE = re.compile(r"(^|\s|[;&|]\s*)set\s+\+x(\s|$)")
STDOUT_REDIRECT_RE = re.compile(r"(^|\s|[;&|]\s*)(?:1?>|1?>>|&>|&>>)\s*\S")
SAFE_STDIN_SECRET_SINK_RE = re.compile(
r"\|\s*(?:\"[^\"]*sign_update\"|\S*sign_update)\b[^|]*--ed-key-file\s+-"
r"|\|\s*docker\s+login\b[^|]*--password-stdin"
)
def check_codemagic(policy: dict) -> list[str]:
errors: list[str] = []
path = ROOT / "codemagic.yaml"
if not path.exists():
return errors
allowed = allowed_names(policy)
allowed_public = allowed_public_names(policy)
allowed_public_sources = set(policy.get("public_client_env_sources", {}).get("allowed", []))
exact = denied_names(policy)
patterns = compile_patterns(policy)
text = read_text(path)
pending_public_assignments: list[tuple[int, str, str]] = []
errors.extend(check_duplicate_yaml_keys(path))
for lineno, line in enumerate(text.splitlines(), start=1):
assignment_match = PUBLIC_ASSIGNMENT_RE.match(line)
if assignment_match:
pending_public_assignments.append((lineno, assignment_match.group(1), assignment_match.group(2)))
if "create-public-client-env.sh" in line:
for inline_assignment in PUBLIC_ASSIGNMENT_ANYWHERE_RE.finditer(line):
pending_public_assignments.append((lineno, inline_assignment.group(1), inline_assignment.group(2)))
for assignment_lineno, public_name, rhs in pending_public_assignments:
if public_name not in allowed_public:
errors.append(
f"{path.relative_to(ROOT)}:{assignment_lineno}: {public_name} is not allowlisted public config"
)
if "$(" in rhs or "`" in rhs:
errors.append(
f"{path.relative_to(ROOT)}:{assignment_lineno}: {public_name} uses command substitution"
)
for printenv_match in PRINTENV_REF_RE.finditer(rhs):
ref_name = printenv_match.group(1)
if ref_name != public_name and ref_name not in allowed_public_sources:
errors.append(
f"{path.relative_to(ROOT)}:{assignment_lineno}: maps non-approved source {ref_name} into {public_name}"
)
for ref_match in ENV_REF_RE.finditer(rhs):
ref_name = ref_match.group(1) or ref_match.group(2)
if not ref_name:
continue
if ref_name != public_name and ref_name not in allowed_public_sources:
errors.append(
f"{path.relative_to(ROOT)}:{assignment_lineno}: maps non-approved source {ref_name} into {public_name}"
)
if (
ref_name not in allowed_public
and ref_name not in allowed_public_sources
and name_is_denied(ref_name, exact, patterns)
):
errors.append(
f"{path.relative_to(ROOT)}:{assignment_lineno}: maps server-only {ref_name} into {public_name}"
)
pending_public_assignments.clear()
elif line.strip() and not line.rstrip().endswith("\\") and not assignment_match:
pending_public_assignments.clear()
env_write = ENV_WRITE_RE.search(line) or ("tee" in line and PUBLIC_ENV_FILE_RE.search(line))
if env_write or PUBLIC_ENV_FILE_RE.search(line):
for token in variable_like_tokens(line):
if token in allowed_public:
continue
if name_is_denied(token, exact, patterns):
errors.append(f"{path.relative_to(ROOT)}:{lineno}: public env write references server-only {token}")
for ref_match in ENV_REF_RE.finditer(line):
ref_name = ref_match.group(1) or ref_match.group(2)
if ref_name and ref_name not in allowed_public and name_is_denied(ref_name, exact, patterns):
errors.append(f"{path.relative_to(ROOT)}:{lineno}: public env write reads server-only {ref_name}")
if "Set up App .env" in line:
errors.append(
f"{path.relative_to(ROOT)}:{lineno}: use Generate public client config, not hand-written App .env"
)
return errors
def line_env_refs(line: str) -> set[str]:
refs: set[str] = set()
for ref_match in ENV_REF_RE.finditer(line):
ref_name = ref_match.group(1) or ref_match.group(2)
if ref_name:
refs.add(ref_name)
for secret_match in GITHUB_SECRET_RE.finditer(line):
refs.add(secret_match.group(1))
return refs
def denied_refs_in_line(
line: str,
exact: set[str],
patterns: list[re.Pattern[str]],
allowed_public: set[str],
allowed_secret_sources: set[str],
) -> set[str]:
refs = set(line_env_refs(line))
for assignment_match in SHELL_ASSIGNMENT_RE.finditer(line):
refs.add(assignment_match.group(1))
return {
ref
for ref in refs
if ref not in allowed_public
and ref not in allowed_secret_sources
and not ref.endswith(("_NAME", "_PATH", "_FILE"))
and name_is_denied(ref, exact, patterns)
}
def release_hygiene_files() -> list[Path]:
files: list[Path] = []
for path in git_files():
if not path.exists():
continue
rel = path.relative_to(ROOT)
if rel == Path("codemagic.yaml"):
files.append(path)
elif path.match(".github/workflows/*.y*ml"):
files.append(path)
elif path.name == "release.sh" and (
path.is_relative_to(ROOT / "app")
or path.is_relative_to(ROOT / "mcp")
or path.is_relative_to(ROOT / "plugins")
or path.is_relative_to(ROOT / "sdks")
or path.is_relative_to(ROOT / "web")
):
files.append(path)
return files
def logical_shell_line(lines: list[str], line_index: int) -> str:
command_parts = [lines[line_index]]
for next_line in lines[line_index + 1 : line_index + 8]:
if not command_parts[-1].rstrip().endswith("\\"):
break
command_parts[-1] = command_parts[-1].rstrip()[:-1]
command_parts.append(next_line)
return " ".join(part.strip() for part in command_parts)
def redirects_stdout(line: str) -> bool:
return bool(STDOUT_REDIRECT_RE.search(line))
def check_release_log_secret_hygiene(policy: dict) -> list[str]:
errors: list[str] = []
exact = denied_names(policy)
patterns = compile_patterns(policy)
allowed_public = allowed_public_names(policy)
allowed_secret_sources = set(policy.get("allowed_build_secret_source_references", []))
for path in release_hygiene_files():
rel = path.relative_to(ROOT)
lines = read_text(path).splitlines()
for line_index, line in enumerate(lines):
lineno = line_index + 1
denied_refs = denied_refs_in_line(line, exact, patterns, allowed_public, allowed_secret_sources)
if PUBLIC_ENV_TARGET_RE.search(line) and denied_refs:
errors.append(
f"{rel}:{lineno}: public env file write references server-only {', '.join(sorted(denied_refs))}"
)
logical_line = logical_shell_line(lines, line_index)
writes_to_stdout = not redirects_stdout(logical_line) and not SAFE_STDIN_SECRET_SINK_RE.search(logical_line)
if SENSITIVE_ECHO_RE.search(line) and denied_refs and writes_to_stdout and "::add-mask::" not in line:
errors.append(
f"{rel}:{lineno}: shell output command references server-only {', '.join(sorted(denied_refs))}"
)
if not SHELL_TRACE_RE.search(line):
continue
trace_refs: set[str] = set()
for offset, traced_line in enumerate(lines[lineno:], start=lineno + 1):
if SHELL_TRACE_OFF_RE.search(traced_line):
break
if offset - lineno > 80:
break
trace_refs.update(
denied_refs_in_line(traced_line, exact, patterns, allowed_public, allowed_secret_sources)
)
if trace_refs:
errors.append(f"{rel}:{lineno}: set -x traces nearby server-only refs {', '.join(sorted(trace_refs))}")
return errors
LEGACY_SETUP_ENV_RE = re.compile(
r"(?:^|[\s/\"'>=:\\(])(?:\.env|\.dev\.env|\.prod\.env|\.env\.local|\.env\.production)(?=[\"'\s),;]|$)"
)
APPROVED_SETUP_ENV_RE = re.compile(r"(?:^|[\s/\"'>=:\\(])\.client(?:\.dev)?\.env(?=[\"'\s),;]|$)")
ENV_WRITE_COMMAND_RE = re.compile(
r"\b(?:echo|printf|cat|cp|copy|copy-item|set-content|out-file|tee|writealltext)\b", re.IGNORECASE
)
def check_setup_env_writes() -> list[str]:
errors: list[str] = []
setup_paths = [
ROOT / "app" / "setup.sh",
ROOT / "app" / "initialsetup.bash",
ROOT / "app" / "setup" / "scripts" / "setup.ps1",
ROOT / "app" / "setup" / "scripts" / "initialsetup.ps1",
]
for path in setup_paths:
if not path.exists():
continue
rel = path.relative_to(ROOT)
for lineno, line in enumerate(read_text(path).splitlines(), start=1):
if not ENV_WRITE_COMMAND_RE.search(line):
continue
if LEGACY_SETUP_ENV_RE.search(line):
errors.append(f"{rel}:{lineno}: setup scripts must not write legacy public env files")
if ".client" in line and not APPROVED_SETUP_ENV_RE.search(line):
errors.append(f"{rel}:{lineno}: setup scripts may only write .client.env or .client.dev.env")
return errors
def check_public_templates(policy: dict) -> list[str]:
errors: list[str] = []
exact = denied_names(policy)
templates = [ROOT / "app" / ".env.template", ROOT / "app" / ".client.env.example"]
for path in templates:
if not path.exists():
continue
text = read_text(path)
rel = path.relative_to(ROOT)
for name in exact:
if name in text:
errors.append(f"{rel}: public app env template references server-only {name}")
for token in variable_like_tokens(text):
if token not in allowed_names(policy) and name_is_denied(token, exact, compile_patterns(policy)):
errors.append(f"{rel}: public app env template references server-only env-like token {token}")
return errors
def check_docker_secret_baking(policy: dict) -> list[str]:
errors: list[str] = []
exact = denied_names(policy)
patterns = compile_patterns(policy)
allowed_public = allowed_public_names(policy)
allowed_build_secret_source_refs = set(policy.get("allowed_build_secret_source_references", []))
dockerfiles = [
path
for path in git_files()
if path.exists()
and (path.name == "Dockerfile" or path.name.startswith("Dockerfile."))
and (
path.is_relative_to(ROOT / "web")
or path.is_relative_to(ROOT / "plugins")
or path.is_relative_to(ROOT / "mcp")
or path.is_relative_to(ROOT / "desktop")
)
]
for path in dockerfiles:
text = read_text(path)
rel = path.relative_to(ROOT)
secret_args: set[str] = set()
for lineno, line in enumerate(text.splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
for token in variable_like_tokens(stripped):
if token in allowed_public or token in allowed_build_secret_source_refs:
continue
if name_is_denied(token, exact, patterns):
errors.append(f"{rel}:{lineno}: Dockerfile references server-only env-like token {token}")
if (
re.search(r"--(?:secret|ssh|build-context)(?:\s|=|$)", stripped)
or "type=secret" in stripped
or "type=ssh" in stripped
):
errors.append(
f"{rel}:{lineno}: Docker build-time secret/context mount is not allowed in public client images"
)
arg_match = re.match(r"(?i)^ARG\s+([A-Z0-9_]+)(?:=.*)?$", stripped)
if arg_match:
name = arg_match.group(1)
if name not in allowed_public:
errors.append(f"{rel}:{lineno}: build ARG {name} is not allowlisted as public client config")
if name not in allowed_public and name_is_denied(name, exact, patterns):
secret_args.add(name)
errors.append(f"{rel}:{lineno}: server-only build ARG {name} can leak through image history")
if re.match(r"(?i)^ENV\s+", stripped):
for name in exact | secret_args:
if name in stripped and name not in allowed_public:
errors.append(f"{rel}:{lineno}: server-only {name} is promoted into final image ENV")
workflow_files = [path for path in git_files() if path.exists() and path.match(".github/workflows/*.y*ml")]
for path in workflow_files:
text = read_text(path)
rel = path.relative_to(ROOT)
errors.extend(check_duplicate_yaml_keys(path))
in_docker_build = False
for lineno, line in enumerate(text.splitlines(), start=1):
if "docker build" in line or "docker/build-push-action" in line:
in_docker_build = True
if in_docker_build and re.search(r"--(?:secret|ssh|build-context)(?:\s|=|$)", line):
errors.append(
f"{rel}:{lineno}: docker build secret/context flags are not allowed for public client builds"
)
if "--build-arg" not in line:
if in_docker_build and not line.rstrip().endswith("\\"):
in_docker_build = False
continue
for name in exact:
if name in line and name not in allowed_public:
errors.append(f"{rel}:{lineno}: server-only {name} is passed as docker build-arg")
for match in re.finditer(r"--build-arg(?:=|\s+)([A-Z0-9_]+)(?:=(.*?))?(?=\s+-{1,2}[A-Za-z]|\s*\\?$)", line):
name = match.group(1)
rhs = match.group(2) or f"${name}"
if name not in allowed_public:
errors.append(f"{rel}:{lineno}: build arg {name} is not allowlisted as public client config")
if name not in allowed_public and name_is_denied(name, exact, patterns):
errors.append(f"{rel}:{lineno}: server-only {name} is passed as docker build-arg")
for ref_match in ENV_REF_RE.finditer(rhs):
ref_name = ref_match.group(1) or ref_match.group(2)
if ref_name and ref_name not in allowed_public and name_is_denied(ref_name, exact, patterns):
errors.append(f"{rel}:{lineno}: build arg {name} reads server-only {ref_name}")
for secret_match in GITHUB_SECRET_RE.finditer(rhs):
ref_name = secret_match.group(1)
if ref_name != name:
errors.append(f"{rel}:{lineno}: build arg {name} reads non-matching GitHub secret {ref_name}")
if in_docker_build and not line.rstrip().endswith("\\"):
in_docker_build = False
return errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--env-file", action="append", default=[], help="public client env file to validate")
parser.add_argument(
"--strict",
action="store_true",
help="run the full legacy-baseline scan; default hook mode checks only clean policy/env-file contracts",
)
args = parser.parse_args()
policy = load_policy()
errors: list[str] = []
env_files = [Path(raw).resolve() for raw in args.env_file]
env_files.extend(
[ROOT / "app" / ".client.env", ROOT / "app" / ".client.dev.env", ROOT / "app" / ".client.env.example"]
)
for path in env_files:
errors.extend(check_env_file(path, policy))
if args.strict:
errors.extend(check_policy_shape(policy))
errors.extend(check_app_source(policy))
errors.extend(check_codemagic(policy))
errors.extend(check_setup_env_writes())
errors.extend(check_public_templates(policy))
errors.extend(check_release_log_secret_hygiene(policy))
errors.extend(check_docker_secret_baking(policy))
if errors:
print("Public client secret boundary check failed:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
return 1
print("Public client secret boundary check passed.")
return 0
if __name__ == "__main__":
sys.exit(main())