feat(wave-1): 适配器框架与安全基座(schema校验/dry-run/exec沙箱/密钥库/日志脱敏/字段拆分)
This commit is contained in:
@@ -3,5 +3,15 @@ name = "agentdock-secrets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "系统密钥库封装(keyring)+ 日志脱敏(架构 §4.1 / §4.3)"
|
||||
|
||||
[dependencies]
|
||||
regex = "1"
|
||||
|
||||
# keyring 为架构 §1/§4.1 指定的密钥库后端:Windows Credential Manager /
|
||||
# Linux Secret Service。按平台仅编译对应后端,避免把 dbus 栈拖进 Windows 构建。
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["sync-secret-service"] }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! 密钥库错误类型。
|
||||
//!
|
||||
//! 铁律:错误信息(Display / Debug)绝不携带密钥明文,也不携带后端原始错误
|
||||
//! 文本(原始错误可能夹带敏感信息),只保留中文说明。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SecretStoreError {
|
||||
/// 无此条目
|
||||
NotFound,
|
||||
/// 后端不可用(中文说明,不含明文)
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for SecretStoreError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SecretStoreError::NotFound => write!(f, "未找到对应密钥条目"),
|
||||
SecretStoreError::Backend(m) => write!(f, "密钥库错误: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SecretStoreError {}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! 生产密钥库后端:keyring → Windows Credential Manager(优先)/ Linux Secret Service
|
||||
//! (架构 §4.1)
|
||||
//!
|
||||
//! 错误映射时只保留中文说明,绝不把后端原始错误或密钥明文带入错误信息。
|
||||
|
||||
use crate::error::SecretStoreError;
|
||||
use crate::store::SecretStore;
|
||||
|
||||
/// keyring 后端封装。
|
||||
#[derive(Default)]
|
||||
pub struct KeyringSecretStore;
|
||||
|
||||
impl KeyringSecretStore {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod platform {
|
||||
use keyring::Entry;
|
||||
|
||||
use crate::error::SecretStoreError;
|
||||
|
||||
pub fn set(service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
|
||||
let entry = Entry::new(service, account).map_err(map_err)?;
|
||||
entry.set_password(secret).map_err(map_err)
|
||||
}
|
||||
|
||||
pub fn get(service: &str, account: &str) -> Result<String, SecretStoreError> {
|
||||
let entry = Entry::new(service, account).map_err(map_err)?;
|
||||
entry.get_password().map_err(map_err)
|
||||
}
|
||||
|
||||
pub fn has(service: &str, account: &str) -> bool {
|
||||
Entry::new(service, account)
|
||||
.and_then(|e| e.get_password())
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn delete(service: &str, account: &str) -> Result<(), SecretStoreError> {
|
||||
let entry = Entry::new(service, account).map_err(map_err)?;
|
||||
entry.delete_credential().map_err(map_err)
|
||||
}
|
||||
|
||||
fn map_err(e: keyring::Error) -> SecretStoreError {
|
||||
// NoEntry 表示无此条目;其余错误统一映射为后端失败,绝不回显原始错误文本。
|
||||
if matches!(e, keyring::Error::NoEntry) {
|
||||
SecretStoreError::NotFound
|
||||
} else {
|
||||
SecretStoreError::Backend("系统密钥库操作失败(Windows Credential Manager)".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod platform {
|
||||
use crate::error::SecretStoreError;
|
||||
|
||||
pub fn set(_service: &str, _account: &str, _secret: &str) -> Result<(), SecretStoreError> {
|
||||
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service)".into()))
|
||||
}
|
||||
|
||||
pub fn get(_service: &str, _account: &str) -> Result<String, SecretStoreError> {
|
||||
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service)".into()))
|
||||
}
|
||||
|
||||
pub fn has(_service: &str, _account: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn delete(_service: &str, _account: &str) -> Result<(), SecretStoreError> {
|
||||
Err(SecretStoreError::Backend("Linux 密钥库后端自 Wave 2 起接入(Secret Service)".into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for KeyringSecretStore {
|
||||
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
|
||||
platform::set(service, account, secret)
|
||||
}
|
||||
|
||||
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError> {
|
||||
platform::get(service, account)
|
||||
}
|
||||
|
||||
fn has(&self, service: &str, account: &str) -> bool {
|
||||
platform::has(service, account)
|
||||
}
|
||||
|
||||
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError> {
|
||||
platform::delete(service, account)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
//! agentdock-secrets —— 系统密钥库封装与日志脱敏(架构 §4.1 / §4.3)
|
||||
//!
|
||||
//! 职责:keyring 封装(Windows Credential Manager / Linux Secret Service)、
|
||||
//! 敏感值脱敏。Wave 0:空骨架,随 Wave 1 落地。
|
||||
//! - `store` / `mock` / `keyring`:密钥库封装(set/get/has/delete),
|
||||
//! 生产后端走 keyring(Windows Credential Manager / Linux Secret Service),
|
||||
//! 测试用 mock 内存后端。
|
||||
//! - `redact`:日志脱敏器(sk-/xai-/ghp_/gho_/JWT 与敏感键值)。
|
||||
|
||||
/// 密钥层能力标记(占位)
|
||||
pub const LAYER: &str = "agentdock-secrets";
|
||||
pub mod error;
|
||||
pub mod keyring;
|
||||
pub mod mock;
|
||||
pub mod redact;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layer_identity() {
|
||||
assert_eq!(LAYER, "agentdock-secrets");
|
||||
}
|
||||
}
|
||||
pub use error::SecretStoreError;
|
||||
pub use keyring::KeyringSecretStore;
|
||||
pub use mock::MockSecretStore;
|
||||
pub use redact::{REDACTED, redact, redact_value};
|
||||
pub use store::SecretStore;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 内存 mock 密钥库后端(测试与无系统钥匙串环境用)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::error::SecretStoreError;
|
||||
use crate::store::SecretStore;
|
||||
|
||||
/// 线程安全的内存后端。仅用于测试与开发联调,不落盘。
|
||||
#[derive(Default)]
|
||||
pub struct MockSecretStore {
|
||||
inner: Mutex<HashMap<(String, String), String>>,
|
||||
}
|
||||
|
||||
impl MockSecretStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 返回当前条目数量(测试断言用)。
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.lock().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for MockSecretStore {
|
||||
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError> {
|
||||
let mut map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
|
||||
map.insert((service.to_string(), account.to_string()), secret.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError> {
|
||||
let map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
|
||||
map.get(&(service.to_string(), account.to_string()))
|
||||
.cloned()
|
||||
.ok_or(SecretStoreError::NotFound)
|
||||
}
|
||||
|
||||
fn has(&self, service: &str, account: &str) -> bool {
|
||||
self.get(service, account).is_ok()
|
||||
}
|
||||
|
||||
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError> {
|
||||
let mut map = self.inner.lock().map_err(|_| SecretStoreError::Backend("内存锁中毒".into()))?;
|
||||
map.remove(&(service.to_string(), account.to_string()))
|
||||
.map(|_| ())
|
||||
.ok_or(SecretStoreError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_set_get() {
|
||||
let store = MockSecretStore::new();
|
||||
store.set("agentdock.codex", "api_key", "sk-verysecret").unwrap();
|
||||
assert_eq!(store.get("agentdock.codex", "api_key").unwrap(), "sk-verysecret");
|
||||
assert!(store.has("agentdock.codex", "api_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_missing_returns_not_found_without_secret() {
|
||||
let store = MockSecretStore::new();
|
||||
let err = store.get("agentdock.codex", "nope").unwrap_err();
|
||||
assert!(matches!(err, SecretStoreError::NotFound));
|
||||
// 错误信息不得含任何密钥明文
|
||||
let secret = "sk-do-not-leak";
|
||||
assert!(!format!("{err}").contains(secret));
|
||||
assert!(!format!("{err:?}").contains(secret));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_removes_entry() {
|
||||
let store = MockSecretStore::new();
|
||||
store.set("agentdock.claude", "api_key", "xai-123").unwrap();
|
||||
assert!(store.has("agentdock.claude", "api_key"));
|
||||
store.delete("agentdock.claude", "api_key").unwrap();
|
||||
assert!(!store.has("agentdock.claude", "api_key"));
|
||||
assert!(matches!(store.delete("agentdock.claude", "api_key"), Err(SecretStoreError::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_never_contains_plaintext() {
|
||||
let store = MockSecretStore::new();
|
||||
let secret = "sk-super-secret-value";
|
||||
store.set("agentdock.x", "k", secret).unwrap();
|
||||
// 触发一个错误路径:删除后读取
|
||||
store.delete("agentdock.x", "k").unwrap();
|
||||
let err = store.get("agentdock.x", "k").unwrap_err();
|
||||
let rendered = format!("{err} / {err:?}");
|
||||
assert!(!rendered.contains(secret), "错误渲染不应含密钥明文: {rendered}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 日志脱敏器(架构 §4.3)
|
||||
//!
|
||||
//! 模式表:
|
||||
//! - `sk-` / `xai-` / `ghp_`(及 GitHub 其它前缀 `gho_/ghs_/ghu_/ghr_`)/ JWT 形态 → `***REDACTED***`
|
||||
//! - `key=value` / `key: value` 形式的敏感键(api_key/token/secret/password/authorization/bearer)→ 值脱敏
|
||||
//! - 任何标记 `sensitive: true` 的字段值,调用方用 `redact_value` 强制脱敏
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// 统一的脱敏占位符。
|
||||
pub const REDACTED: &str = "***REDACTED***";
|
||||
|
||||
fn jwt_re() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap())
|
||||
}
|
||||
|
||||
fn sk_xai_re() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"(?i)\b(?:sk|xai)-[A-Za-z0-9_-]+").unwrap())
|
||||
}
|
||||
|
||||
fn github_token_re() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"\bgh[pousr]_[A-Za-z0-9]+").unwrap())
|
||||
}
|
||||
|
||||
fn sensitive_kv_re() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(
|
||||
r#"(?i)\b(api[_-]?key|apikey|token|secret|password|authorization|bearer)\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;]+)"#,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
/// 对任意文本做脱敏:替换前缀密钥、JWT、敏感键值。
|
||||
pub fn redact(input: &str) -> String {
|
||||
let mut out = input.to_string();
|
||||
out = jwt_re().replace_all(&out, REDACTED).to_string();
|
||||
out = sk_xai_re().replace_all(&out, REDACTED).to_string();
|
||||
out = github_token_re().replace_all(&out, REDACTED).to_string();
|
||||
out = sensitive_kv_re()
|
||||
.replace_all(&out, "$1=***REDACTED***")
|
||||
.to_string();
|
||||
out
|
||||
}
|
||||
|
||||
/// 对单个已知敏感值强制脱敏(适配器 `sensitive: true` 字段落日志前调用)。
|
||||
pub fn redact_value(_value: &str) -> String {
|
||||
REDACTED.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn redacts_sk_prefix() {
|
||||
let out = redact("密钥是 sk-abc123def");
|
||||
assert!(out.contains(REDACTED));
|
||||
assert!(!out.contains("sk-abc123def"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_xai_prefix() {
|
||||
let out = redact("xai-verysecret");
|
||||
assert!(out.contains(REDACTED));
|
||||
assert!(!out.contains("xai-verysecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_github_tokens() {
|
||||
for token in ["ghp_abcdefghijklmnop", "gho_1234", "ghs_xyz", "ghu_9", "ghr_ab"] {
|
||||
let out = redact(token);
|
||||
assert!(out.contains(REDACTED), "{token}");
|
||||
assert!(!out.contains(token), "{token}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_jwt() {
|
||||
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||
let out = redact(&format!("Authorization: Bearer {jwt}"));
|
||||
assert!(!out.contains(jwt));
|
||||
assert!(out.contains(REDACTED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_sensitive_key_value() {
|
||||
let out = redact("API_KEY=sk-abc123");
|
||||
assert!(out.contains("API_KEY=***REDACTED***"), "{out}");
|
||||
assert!(!out.contains("sk-abc123"));
|
||||
|
||||
let out2 = redact("token: abcdef123456");
|
||||
assert!(out2.contains("***REDACTED***"), "{out2}");
|
||||
assert!(!out2.contains("abcdef123456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_quoted_value() {
|
||||
let out = redact("password=\"hunter2secret\"");
|
||||
assert!(out.contains("***REDACTED***"), "{out}");
|
||||
assert!(!out.contains("hunter2secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_innocent_text_untouched() {
|
||||
let s = "node --version 返回 24.18.0";
|
||||
assert_eq!(redact(s), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_value_always_masks() {
|
||||
assert_eq!(redact_value("anything"), REDACTED);
|
||||
assert_eq!(redact_value("sk-abc"), REDACTED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! 密钥库抽象接口(架构 §4.1)
|
||||
|
||||
use crate::error::SecretStoreError;
|
||||
|
||||
/// 系统密钥库封装接口。`service` 固定前缀 `agentdock.<cli_id>`,
|
||||
/// `account` 为字段 id(如 `api_key`)。
|
||||
pub trait SecretStore: Send + Sync {
|
||||
/// 写入密钥。
|
||||
fn set(&self, service: &str, account: &str, secret: &str) -> Result<(), SecretStoreError>;
|
||||
/// 读取密钥。
|
||||
fn get(&self, service: &str, account: &str) -> Result<String, SecretStoreError>;
|
||||
/// 是否存在该密钥条目。
|
||||
fn has(&self, service: &str, account: &str) -> bool;
|
||||
/// 删除密钥条目。
|
||||
fn delete(&self, service: &str, account: &str) -> Result<(), SecretStoreError>;
|
||||
}
|
||||
|
||||
/// 构造 service 名:`agentdock.<cli_id>`。
|
||||
pub fn service_name(cli_id: &str) -> String {
|
||||
format!("agentdock.{cli_id}")
|
||||
}
|
||||
Reference in New Issue
Block a user