vigil_redaction/label.rs
1//! ISS-005:Stage 2 T0 标签化枚举(ADR 0013 + `docs/design/vigil-redaction-selection.md`)。
2//!
3//! 8 个业务标签,聚合两层来源:
4//! - **v0.3 硬指纹规则**(`HARD_RULES` 12 项):aws / github / anthropic / openai / jwt /
5//! pem / stripe / google / gitlab / slack / env_assignment / database_url / email /
6//! internal_ipv4
7//! - **Privacy Filter 33-class id2label**(Stage 2 模型,`private_*` 前缀)
8//!
9//! 标签体系是"业务视角"的归并 —— caller 在 UI / 审计 / 风险累加时看到的是 8 类;
10//! 原始 `Finding.kind` 字面量保留在 `Finding` 上以便调试与规则名反查。
11//!
12//! **不变量**:
13//! - `PrivacyLabel::from_kind` 是**封闭映射**(ISS-005 明确列出所有支持 kind);未识别
14//! kind 返 `None`,caller 可选择 fail-closed。feedback_extend_enum_sync_tests:
15//! 新 variant 必须同时扩 `from_kind` + `as_str` + 测试。
16//! - `as_str` 返回的字面量是**外部契约**(落 JSON / 审计 / UI 文案),禁改串。
17
18/// Stage 2 T0 业务标签枚举(ADR 0013)。
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum PrivacyLabel {
21 /// 服务 API 密钥 / 凭证类:aws / github / anthropic / openai / jwt / pem /
22 /// stripe / google / gitlab / slack / env_assignment / database_url。泄漏即越权,
23 /// caller 应走 fail-closed。
24 Secret,
25 /// 账户号码类(Privacy Filter `private_account_number` 等):银行卡 / 社保号等。
26 AccountNumber,
27 /// 邮箱:Hard `email` + Model `private_email`。
28 Email,
29 /// 电话号码:Model `private_phone`。
30 Phone,
31 /// 人名:Model `private_person`。
32 Person,
33 /// 地址:Model `private_address`。
34 Address,
35 /// 日期(可能是 PII 生日 / 关键事件日):Model `private_date`。
36 Date,
37 /// URL / IP 类:Hard `internal_ipv4` + Model `private_url`。
38 /// 注:内网 IP 归入此类(可能是拓扑信息);公网 URL 也可能含凭证(如 Slack webhook),
39 /// 但 Slack webhook 的完整结构由 `Secret` 承担,`Url` 仅承担通用 URL/IP 场景。
40 Url,
41}
42
43impl PrivacyLabel {
44 /// 返回稳定的外部字面量(UI / 审计 / JSON 序列化契约)。
45 ///
46 /// **纪律**:feedback_ssot_drift_guard —— 任何对这里字面量的修改都应同步
47 /// `privacy_label_as_str_stable` 精确集合测试。
48 pub fn as_str(&self) -> &'static str {
49 match self {
50 Self::Secret => "secret",
51 Self::AccountNumber => "account_number",
52 Self::Email => "email",
53 Self::Phone => "phone",
54 Self::Person => "person",
55 Self::Address => "address",
56 Self::Date => "date",
57 Self::Url => "url",
58 }
59 }
60
61 /// 全量 variant 数组(守门测试消费;同时作为"新增 variant 必测"的对账单)。
62 pub const ALL: [PrivacyLabel; 8] = [
63 Self::Secret,
64 Self::AccountNumber,
65 Self::Email,
66 Self::Phone,
67 Self::Person,
68 Self::Address,
69 Self::Date,
70 Self::Url,
71 ];
72
73 /// 从 `Finding.kind` 字面量反查标签。
74 ///
75 /// 覆盖两层来源:
76 /// - Hard(`HARD_RULES.name`):aws / github / anthropic / openai / jwt / pem /
77 /// env_assignment / slack / stripe / google / gitlab / database_url / email /
78 /// internal_ipv4
79 /// - Model(Privacy Filter):`private_*` 前缀 8 类 + 裸 `secret` / `account_number`
80 ///
81 /// 返回 `None` 表示 kind 不在封闭集合中 —— caller 可选择:
82 /// - 继续保留 Finding(本 Stage scaffold 的默认行为,兼容未来新 kind)
83 /// - 或视为 fail-closed 拒入(安全关键路径)
84 pub fn from_kind(kind: &str) -> Option<Self> {
85 match kind {
86 // ─── Hard rules(`HARD_RULES.name`)→ Secret 大类 ───
87 // 这些 kind 在 HARD_RULES 中都有对应 Regex 命中;新增 HARD_RULES 时
88 // 请同步这里 + 单测(feedback_extend_enum_sync_tests)。
89 "aws_access_key_id" | "github_token" | "anthropic_api_key" | "openai_api_key"
90 | "jwt" | "pem_private_key" | "env_assignment" | "slack_webhook"
91 | "stripe_secret_key" | "google_api_key" | "gitlab_pat" | "database_url" | "secret" => {
92 Some(Self::Secret)
93 }
94
95 // ─── Email:Hard `email` + Model `private_email` ───
96 "email" | "private_email" => Some(Self::Email),
97
98 // ─── URL/IP:Hard `internal_ipv4` + `generic_url`(R1a 加)+ Model `private_url` / `url` ───
99 "internal_ipv4" | "generic_url" | "private_url" | "url" => Some(Self::Url),
100
101 // ─── Model 专属标签(`private_*` + 裸名兼容)───
102 "private_phone" | "phone" => Some(Self::Phone),
103 "private_person" | "person" => Some(Self::Person),
104 "private_address" | "address" => Some(Self::Address),
105 "private_date" | "date" => Some(Self::Date),
106 "private_account_number" | "account_number" => Some(Self::AccountNumber),
107
108 _ => None,
109 }
110 }
111}
112
113impl std::fmt::Display for PrivacyLabel {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 f.write_str(self.as_str())
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 /// feedback_ssot_drift_guard:精确集合双向 diff(sorted vec assert_eq!)。
124 /// 任何 variant 字面量漂移或新增/删除都会让本测试失败。
125 #[test]
126 fn privacy_label_as_str_stable() {
127 let got: Vec<&'static str> = PrivacyLabel::ALL.iter().map(|l| l.as_str()).collect();
128 let mut got_sorted = got.clone();
129 got_sorted.sort_unstable();
130
131 let mut expected = vec![
132 "secret",
133 "account_number",
134 "email",
135 "phone",
136 "person",
137 "address",
138 "date",
139 "url",
140 ];
141 expected.sort_unstable();
142
143 assert_eq!(got_sorted, expected, "PrivacyLabel 字面量集合漂移");
144 assert_eq!(got.len(), 8, "必须恰好 8 个 variant");
145
146 // 顺手守:Display == as_str
147 for l in PrivacyLabel::ALL {
148 assert_eq!(format!("{l}"), l.as_str());
149 }
150 }
151
152 /// 每个 variant 至少一个 kind 字面量可命中 from_kind(覆盖 8 个桶)。
153 #[test]
154 fn privacy_label_from_kind_all_variants() {
155 // 每个 variant 挑一个代表性 kind
156 let samples: &[(&str, PrivacyLabel)] = &[
157 ("github_token", PrivacyLabel::Secret),
158 ("private_account_number", PrivacyLabel::AccountNumber),
159 ("email", PrivacyLabel::Email),
160 ("private_phone", PrivacyLabel::Phone),
161 ("private_person", PrivacyLabel::Person),
162 ("private_address", PrivacyLabel::Address),
163 ("private_date", PrivacyLabel::Date),
164 ("internal_ipv4", PrivacyLabel::Url),
165 ];
166 // 确认样本本身覆盖所有 8 个 variant(防测试对照表遗漏)
167 let mut seen: Vec<PrivacyLabel> = samples.iter().map(|(_, l)| *l).collect();
168 seen.sort();
169 seen.dedup();
170 assert_eq!(seen.len(), 8, "样本未覆盖所有 variant");
171
172 for (kind, expected) in samples {
173 assert_eq!(
174 PrivacyLabel::from_kind(kind),
175 Some(*expected),
176 "kind {kind:?} 期望映射到 {expected:?}"
177 );
178 }
179 }
180
181 /// 未识别 kind 返 None(fail-closed 信号)。
182 #[test]
183 fn privacy_label_from_kind_unknown_returns_none() {
184 assert_eq!(PrivacyLabel::from_kind("not_a_kind"), None);
185 assert_eq!(PrivacyLabel::from_kind(""), None);
186 assert_eq!(PrivacyLabel::from_kind("PRIVATE_PERSON"), None); // 大小写敏感
187 }
188
189 /// 双向兼容:`private_*` 前缀 + 裸名都能命中(Stage 2 模型 vs. 调试 / 手搓样本)。
190 #[test]
191 fn privacy_label_from_kind_accepts_both_model_and_bare() {
192 assert_eq!(
193 PrivacyLabel::from_kind("private_email"),
194 PrivacyLabel::from_kind("email")
195 );
196 assert_eq!(
197 PrivacyLabel::from_kind("private_phone"),
198 PrivacyLabel::from_kind("phone")
199 );
200 }
201}