新增了包括密钥加解密、配置健康检查、原子写入、审计、解析器以及 scrubber 在内的完整 secrets 模块,实现了明文密钥检测替换、密钥引用解析和配置安全校验能力
285 lines
8.7 KiB
Python
285 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from yuxi.channel.secrets.models import (
|
|
SecretRef,
|
|
SecretsAuditFinding,
|
|
SecretsAuditReport,
|
|
SecretSource,
|
|
is_expected_resolved_secret_value,
|
|
is_non_empty_string,
|
|
is_secret_field_name,
|
|
is_secret_ref,
|
|
parse_secret_ref,
|
|
secret_ref_key,
|
|
)
|
|
from yuxi.channel.secrets.providers import (
|
|
SecretsProvider,
|
|
)
|
|
from yuxi.channel.secrets.resolver import SecretResolver
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SecretsAuditResult:
|
|
def __init__(
|
|
self,
|
|
report: SecretsAuditReport,
|
|
exit_code: int = 0,
|
|
):
|
|
self.report = report
|
|
self.exit_code = exit_code
|
|
|
|
|
|
def _resolve_json_path(path: str, key: str) -> str:
|
|
if not path:
|
|
return key
|
|
return f"{path}.{key}"
|
|
|
|
|
|
def _find_plaintext_secrets(
|
|
config: dict,
|
|
path: str = "",
|
|
file_label: str = "config",
|
|
) -> list[SecretsAuditFinding]:
|
|
findings: list[SecretsAuditFinding] = []
|
|
|
|
def _walk(current: Any, current_path: str):
|
|
if not isinstance(current, dict):
|
|
return
|
|
|
|
for key, value in current.items():
|
|
entry_path = _resolve_json_path(current_path, key)
|
|
|
|
if is_secret_ref(value):
|
|
continue
|
|
|
|
if isinstance(value, dict):
|
|
_walk(value, entry_path)
|
|
elif isinstance(value, list):
|
|
for i, item in enumerate(value):
|
|
if isinstance(item, dict) and not is_secret_ref(item):
|
|
_walk(item, f"{entry_path}[{i}]")
|
|
elif is_secret_field_name(key) and is_non_empty_string(value) and value != "***":
|
|
findings.append(
|
|
SecretsAuditFinding(
|
|
code="PLAINTEXT_FOUND",
|
|
severity="warn",
|
|
file=file_label,
|
|
json_path=entry_path,
|
|
message=f"{entry_path} is stored as plaintext.",
|
|
)
|
|
)
|
|
|
|
_walk(config, path)
|
|
return findings
|
|
|
|
|
|
async def _check_ref_resolvability(
|
|
findings: list[SecretsAuditFinding],
|
|
config: dict,
|
|
providers: dict[SecretSource, SecretsProvider] | None = None,
|
|
allow_exec: bool = True,
|
|
) -> dict[str, Any]:
|
|
resolver = SecretResolver(providers)
|
|
refs_collected: list[tuple[SecretRef, str, str]] = []
|
|
|
|
def _collect_refs(current: Any, current_path: str, file_label: str):
|
|
if not isinstance(current, dict):
|
|
return
|
|
|
|
for key, value in current.items():
|
|
entry_path = _resolve_json_path(current_path, key)
|
|
|
|
if is_secret_ref(value):
|
|
ref = parse_secret_ref(value)
|
|
if ref:
|
|
refs_collected.append((ref, entry_path, file_label))
|
|
elif isinstance(value, dict):
|
|
_collect_refs(value, entry_path, file_label)
|
|
elif isinstance(value, list):
|
|
for i, item in enumerate(value):
|
|
if isinstance(item, dict):
|
|
_collect_refs(item, f"{entry_path}[{i}]", file_label)
|
|
|
|
_collect_refs(config, "", "config")
|
|
|
|
if not allow_exec:
|
|
exec_refs = [r for r, _, _ in refs_collected if r.source == SecretSource.EXEC]
|
|
skipped = len(exec_refs)
|
|
refs_collected = [(r, p, f) for r, p, f in refs_collected if r.source != SecretSource.EXEC]
|
|
else:
|
|
skipped = 0
|
|
|
|
refs = [r for r, _, _ in refs_collected]
|
|
if not refs:
|
|
return {"refs_checked": 0, "skipped_exec_refs": skipped, "resolvability_complete": True}
|
|
|
|
try:
|
|
resolved = await resolver.resolve_secret_ref_values(refs)
|
|
except Exception as e:
|
|
for ref, entry_path, file_label in refs_collected:
|
|
findings.append(
|
|
SecretsAuditFinding(
|
|
code="REF_UNRESOLVED",
|
|
severity="error",
|
|
file=file_label,
|
|
json_path=entry_path,
|
|
message=f"Failed to resolve {secret_ref_key(ref)} ({e}).",
|
|
)
|
|
)
|
|
return {
|
|
"refs_checked": len(refs),
|
|
"skipped_exec_refs": skipped,
|
|
"resolvability_complete": skipped == 0,
|
|
}
|
|
|
|
for ref, entry_path, file_label in refs_collected:
|
|
key = secret_ref_key(ref)
|
|
if key not in resolved or resolved[key] is None:
|
|
findings.append(
|
|
SecretsAuditFinding(
|
|
code="REF_UNRESOLVED",
|
|
severity="error",
|
|
file=file_label,
|
|
json_path=entry_path,
|
|
message=f"Failed to resolve {key} (resolved value is missing).",
|
|
)
|
|
)
|
|
elif not is_expected_resolved_secret_value(resolved[key], "string"):
|
|
findings.append(
|
|
SecretsAuditFinding(
|
|
code="REF_UNRESOLVED",
|
|
severity="error",
|
|
file=file_label,
|
|
json_path=entry_path,
|
|
message=f"Failed to resolve {key} (not a non-empty string).",
|
|
)
|
|
)
|
|
|
|
return {
|
|
"refs_checked": len(refs),
|
|
"skipped_exec_refs": skipped,
|
|
"resolvability_complete": skipped == 0,
|
|
}
|
|
|
|
|
|
def _unquote_value(value: str) -> str:
|
|
"""Remove matching outer quotes and handle escaped quotes."""
|
|
value = value.strip()
|
|
if len(value) >= 2:
|
|
if value[0] == '"' and value[-1] == '"':
|
|
return value[1:-1].replace('\\"', '"')
|
|
if value[0] == "'" and value[-1] == "'":
|
|
return value[1:-1].replace("\\'", "'")
|
|
return value
|
|
|
|
|
|
def _scan_env_file(env_path: str | Path, known_env_vars: set[str]) -> list[SecretsAuditFinding]:
|
|
findings: list[SecretsAuditFinding] = []
|
|
env_path = Path(env_path)
|
|
if not env_path.exists():
|
|
return findings
|
|
|
|
try:
|
|
content = env_path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return findings
|
|
|
|
pattern = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$")
|
|
for line in content.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
match = pattern.match(line)
|
|
if not match:
|
|
continue
|
|
key = match.group(1)
|
|
if key not in known_env_vars:
|
|
continue
|
|
value = _unquote_value(match.group(2))
|
|
if value:
|
|
findings.append(
|
|
SecretsAuditFinding(
|
|
code="PLAINTEXT_FOUND",
|
|
severity="warn",
|
|
file=str(env_path),
|
|
json_path=f"$env.{key}",
|
|
message=f"Potential secret found in .env ({key}).",
|
|
)
|
|
)
|
|
return findings
|
|
|
|
|
|
async def run_secrets_audit(
|
|
config: dict[str, Any],
|
|
providers: dict[SecretSource, SecretsProvider] | None = None,
|
|
env_path: str | Path | None = None,
|
|
known_env_vars: set[str] | None = None,
|
|
allow_exec: bool = True,
|
|
) -> SecretsAuditResult:
|
|
files_scanned: list[str] = ["config"]
|
|
|
|
findings = _find_plaintext_secrets(config)
|
|
|
|
resolution = await _check_ref_resolvability(
|
|
findings,
|
|
config,
|
|
providers=providers,
|
|
allow_exec=allow_exec,
|
|
)
|
|
|
|
if env_path and known_env_vars:
|
|
env_findings = _scan_env_file(env_path, known_env_vars)
|
|
findings.extend(env_findings)
|
|
files_scanned.append(str(env_path))
|
|
|
|
plaintext_count = sum(1 for f in findings if f.code == "PLAINTEXT_FOUND")
|
|
unresolved_count = sum(1 for f in findings if f.code == "REF_UNRESOLVED")
|
|
|
|
if unresolved_count > 0:
|
|
status = "unresolved"
|
|
elif findings:
|
|
status = "findings"
|
|
else:
|
|
status = "clean"
|
|
|
|
report = SecretsAuditReport(
|
|
version=1,
|
|
status=status,
|
|
resolution=resolution,
|
|
files_scanned=files_scanned,
|
|
summary={
|
|
"plaintextCount": plaintext_count,
|
|
"unresolvedRefCount": unresolved_count,
|
|
},
|
|
findings=findings,
|
|
)
|
|
|
|
exit_code = 2 if unresolved_count > 0 else 0
|
|
|
|
return SecretsAuditResult(report=report, exit_code=exit_code)
|
|
|
|
|
|
async def run_secrets_audit_strict(
|
|
config: dict[str, Any],
|
|
providers: dict[SecretSource, SecretsProvider] | None = None,
|
|
env_path: str | Path | None = None,
|
|
known_env_vars: set[str] | None = None,
|
|
allow_exec: bool = True,
|
|
) -> SecretsAuditResult:
|
|
result = await run_secrets_audit(
|
|
config=config,
|
|
providers=providers,
|
|
env_path=env_path,
|
|
known_env_vars=known_env_vars,
|
|
allow_exec=allow_exec,
|
|
)
|
|
if result.report.status == "findings" and result.exit_code == 0:
|
|
result.exit_code = 1
|
|
return result
|