ForcePilot/backend/package/yuxi/channel/secrets/audit.py
Kris 3ce713eb9e feat(secrets): 新增完整的密钥管理工具链
新增了包括密钥加解密、配置健康检查、原子写入、审计、解析器以及 scrubber 在内的完整 secrets 模块,实现了明文密钥检测替换、密钥引用解析和配置安全校验能力
2026-05-21 10:32:14 +08:00

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