Skip to main content

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}