vigil-redaction 0.1.8

Privacy filter for Vigil — hard fingerprint rules (13 kinds) + optional ONNX-backed PII scanner with multilang ensemble
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
//! vigil-redaction
//!
//! 职责(ADR 0002 §D1):纯函数,输入任意 `serde_json::Value`,输出脱敏后的 `Value`
//! 与一个可供 FTS5 检索的摘要字符串。**无 IO、无全局状态**。
//!
//! I01 实装最小规则集(**仅以下指纹在本迭代内承诺覆盖**):
//! - **服务 API key 指纹**:AWS access key id、GitHub token 家族(`ghp_/gho_/ghu_/ghs_/ghr_`)、
//!   Anthropic(`sk-ant-*`)、OpenAI(`sk-*` 其它)。**顺序敏感:anthropic 必须先于 openai。**
//! - **JWT** 三段式 base64url
//! - **PEM 私钥块**(任何 `-----BEGIN ... PRIVATE KEY-----` 开头)
//! - **JSON object-key 启发**:当 key 名含 `secret|token|password|api_key|auth` 时,
//!   整个字符串值被替换为 `[REDACTED len=N by_key=...]`
//! - **自由文本 `.env` 风格键值对**:`[A-Z_]+(KEY|TOKEN|SECRET|PASSWORD|AUTH|...)=value` 和
//!   `... : value` 两种形态,即使 value 不匹配任何服务指纹也整段脱敏(规则名 `env_assignment`)
//! - **email 列表**
//! - **内部 IPv4**(10/8、172.16/12、192.168/16、127/8)
//!
//! **不在 I01 范围**:Slack / Stripe / GCP service account key / SSH host key /
//! OAuth client_secret / 通用 40-hex GitHub classic OAuth token / Google API key 等
//! 由 I02 与 I09(浏览器扩展)扩展。

#![deny(missing_docs)]
#![forbid(unsafe_code)]
// 本 crate 的 unwrap/expect 仅出现在两类位置:
//   1) 静态 Regex 编译(字面常量,失败即开发期 bug,启动即崩更易发现)
//   2) #[cfg(test)] 测试代码(AGENTS.md 明确允许)
// 运行时数据路径上不含任何 unwrap/expect。
#![allow(clippy::unwrap_used, clippy::expect_used)]

use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::Value;

/// 当前迭代号。
pub const ITERATION: &str = "I01";

// ADR 0013:T0 模型 × 硬指纹 merge 层(ISS-013)。纯函数,不依赖任何模型 runtime;
// 由 ISS-005 scaffold 后续从 `scan_text` 调用。
pub mod merge;

pub use merge::{merge_findings, Finding, FindingSource};

// ISS-005: Stage 2 T0 label + scan_text unified entry.
// `label` defines 8 business label enum; `scan` wraps v0.3 hard-fp path as Stage 1
// scaffold. Real model inference is deferred to ISS-008.
pub mod label;
pub mod scan;

pub use label::PrivacyLabel;
pub use scan::{scan_text, RedactionResult, RiskSignals, ScanError};

// ISS-008 Phase 1:Privacy Filter 推理引擎抽象。
// - 默认 feature:导出 trait + NoopEngine + MockEngine + EngineError(0 ort 痕迹)
// - `--features ort`:额外导出 OrtEngine(ORT 1.24 q4f16 真推理)
pub mod engine;

#[cfg(feature = "ort")]
pub use engine::OrtEngine;
pub use engine::{EngineError, MockEngine, NoopEngine, RedactionEngine};

// v0.7-α3 Phase 3 Design(ADR 0017)— ModelDescriptor trait + canonical mapping
// scaffold。**crate-public**(自 R1 起;支持 examples / firewall S4 集成),
// 但 **不在 SDK Phase 1 暴露**(ADR 0015 边界保留;v0.8 才稳定 SDK)。
pub mod model_descriptor;

// v0.7-α3 Phase 3 S3(E6a)— EnsembleEngine 多模型 union + IoU dedup。
// 当前 **crate-public**(EnsembleEngine type)以便 firewall S4 集成时引用,
// 但 **不在 SDK Phase 1 暴露**(ADR 0015 边界保留;v0.8 才稳定 SDK)。
pub mod ensemble;
pub use ensemble::EnsembleEngine;
// v0.8 Sprint 3 P2.0 — per-finding cross-engine attribution(配套 EnsembleEngine::infer_with_attribution)
pub use ensemble::EngineAttribution;

// v0.5 P2 ADR 0012:模型 first-run-download 子模块。
// 整模块 #[cfg(feature = "ort")] gate(详见 bootstrap/mod.rs 顶部)。
// 默认 cargo build/tree -e normal --no-default-features 0 reqwest/dirs/sha2 痕迹。
#[cfg(feature = "ort")]
pub mod bootstrap;
#[cfg(feature = "ort")]
pub use bootstrap::{ensure_model_available, BootstrapError, ModelPaths};

// `scan_text_with_engine`:`scan_text` 的引擎注入版,行为保留 EmptyInput +
// fail-closed 不变量;详见 `scan::scan_text_with_engine` rustdoc。
pub use scan::scan_text_with_engine;
// v0.9 Sprint 1 P1.2 — lang-aware 版(spike;OrtEngine 走 lang-conditional threshold)
pub use scan::scan_text_with_engine_with_lang;

// v0.10 Sprint 2 — typed LanguageHint wrapper(Decision A-prime;SDK 友好)
pub mod lang_hint;
pub use lang_hint::{
    detect_lang_heuristic, scan_text_with_engine_with_hint, LangHintSource, LanguageHint,
    LANG_HINT_TRUSTED_CONFIDENCE,
};

// v0.7-α2 Phase 2D(ADR 0016 Fail-Closed Bottom Line):budget-aware scan +
// 模型路径超时/错误退化到 Hard-only;详见 `scan::scan_text_with_engine_budgeted` rustdoc。
pub use scan::{scan_text_with_engine_budgeted, BudgetedScanOutcome, EngineStatus};

/// 对一个 `Value` 做结构递归脱敏,返回(脱敏后的 Value, FTS 摘要)。
///
/// FTS 摘要规则:把**命中规则的名称 + 全部字符串字面量拼接**形成一行,
/// 供 SQLite FTS5 做 LIKE/MATCH。**绝不**包含原始 secret 的任何字节。
pub fn redact(value: &Value) -> (Value, String) {
    let mut findings: Vec<String> = Vec::new();
    let redacted = redact_value(value, &mut findings);

    // 把命中类型去重拼入 FTS 摘要;额外把**已脱敏**的字符串字面量也接进去,
    // 便于按 event_type / session 关键字检索。
    findings.sort();
    findings.dedup();
    let string_corpus = collect_strings(&redacted);
    let mut summary = String::new();
    for f in &findings {
        summary.push_str("finding:");
        summary.push_str(f);
        summary.push(' ');
    }
    summary.push_str(&string_corpus);
    (redacted, summary.trim().to_string())
}

/// 对单行文本做 hard-pattern 脱敏(ADR 0007 §D7):runner capture loop 每读一行
/// 就应调用本函数,把已知 secret 指纹替换为 `[REDACTED <rule>]` 占位符再写入缓冲。
///
/// 与 [`redact`] 不同:不接 `Value`,不做 JSON 递归,也不生成 FTS 摘要。
/// 仅承担"最早处脱敏"边界,防止 raw bytes 穿越 trace / panic / audit。
///
/// # 使用
///
/// ```
/// use vigil_redaction::scrub_text;
/// let line = "got token ghp_1234567890abcdef1234567890abcdef12345678";
/// let clean = scrub_text(line);
/// assert!(!clean.contains("ghp_1234567890abcdef1234567890abcdef12345678"));
/// assert!(clean.contains("[REDACTED"));
/// ```
pub fn scrub_text(text: &str) -> String {
    // 复用 redact_string 的规则执行(PEM + ALL_RULES)但丢弃 findings。
    let mut sink: Vec<String> = Vec::new();
    redact_string(text, &mut sink)
}

/// 扫描文本,返回**所有**命中的硬指纹规则名(去重,保留 HARD_RULES 声明顺序)。
///
/// I09 `vigil-browser` classifier 需要完整的 finding 列表(不是只返首个命中),
/// 用此 API 替代多次调用 `detect_hard_secret`。
///
/// 与 `scrub_text` 的关系:`scan_hard_findings` 在**未**脱敏原文上扫 HARD_RULES;
/// `scrub_text` 的输出不应再被 scan(占位符会被误识别)。
pub fn scan_hard_findings(text: &str) -> Vec<&'static str> {
    // 与 detect_hard_secret 同源:先剥占位符,再扫 HARD_RULES
    let stripped = KNOWN_REDACTED_MARKER.replace_all(text, "");
    let mut out: Vec<&'static str> = Vec::new();
    for r in HARD_RULES.iter() {
        if r.pattern.is_match(&stripped) && !out.contains(&r.name) {
            out.push(r.name);
        }
    }
    out
}

/// 快速判定文本是否含明显 secret 指纹。供 `vigil-audit::append_event`
/// 做 fail-closed 自检(ADR 0002 §D1 "防越权门")。
///
/// 返回 `Some(rule_name)` 即应拒绝写入;`None` 即未命中强指纹。
///
/// 实现细节:**只剥除 redact 本函数自身产出的窄形占位符**,再扫描。
/// 我们承认以下两种形态是"本模块产物":
///   1. `[REDACTED <rule_name>]`  其中 rule_name 是 `[a-z_]+`(与 `Rule::name` 约束一致)
///   2. `[REDACTED len=<n> by_key=<safe>]` 为 JSON key-hint 脱敏的专用形态
///
/// 攻击者构造的 `[REDACTED ghp_xxx]` / `[REDACTED sk-ant-yyy]` /
/// `[REDACTED DATABASE_PASSWORD=hunter2]` 等不满足上述形态,将**保留在扫描文本里**,
/// 被硬指纹规则识别并拒绝写入。
pub fn detect_hard_secret(text: &str) -> Option<&'static str> {
    let stripped = KNOWN_REDACTED_MARKER.replace_all(text, "");
    for r in HARD_RULES.iter() {
        if r.pattern.is_match(&stripped) {
            return Some(r.name);
        }
    }
    None
}

// ---------------- 内部 ----------------

/// `by_key=<k>` 占位符里 k 允许的字符集(与 KNOWN_REDACTED_MARKER 严格对齐)。
/// 任何超出此集合的 key 字符会在 redact 时被替换为 `_`,保证 marker 识别 100% 覆盖。
const BY_KEY_SAFE_CHAR_CLASS: &str = r"[A-Za-z0-9_\-]";

fn normalize_key_for_placeholder(k: &str) -> String {
    // 非 ASCII 字母数字/下划线/连字符 → `_`。防止 marker 字符集与 redact 输出漂移
    // (ADR 0003 §F1)。
    k.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

fn redact_value(v: &Value, findings: &mut Vec<String>) -> Value {
    match v {
        Value::String(s) => Value::String(redact_string(s, findings)),
        Value::Array(arr) => Value::Array(arr.iter().map(|x| redact_value(x, findings)).collect()),
        Value::Object(obj) => {
            let mut new_obj = serde_json::Map::new();
            for (k, val) in obj {
                // 键名本身启发:SECRET/TOKEN/PASSWORD/KEY/API 等键对应的字符串值一律脱敏
                // (即使值本体未匹配指纹)。数字 / 布尔 / null 值不受影响。
                let sensitive_key = KEY_HINT.is_match(k);
                let redacted = if sensitive_key {
                    match val {
                        Value::String(s) if !s.is_empty() => {
                            findings.push("env_like_key".to_string());
                            let safe_k = normalize_key_for_placeholder(k);
                            Value::String(format!("[REDACTED len={} by_key={}]", s.len(), safe_k))
                        }
                        other => redact_value(other, findings),
                    }
                } else {
                    redact_value(val, findings)
                };
                new_obj.insert(k.clone(), redacted);
            }
            Value::Object(new_obj)
        }
        // 数字 / 布尔 / null 原样返回
        _ => v.clone(),
    }
}

fn redact_string(s: &str, findings: &mut Vec<String>) -> String {
    // PEM 块单独处理:整块替换
    if PEM_RE.is_match(s) {
        findings.push("pem_private_key".to_string());
        return "[REDACTED pem_private_key]".to_string();
    }

    let mut out = s.to_string();
    for rule in ALL_RULES.iter() {
        if rule.pattern.is_match(&out) {
            findings.push(rule.name.to_string());
            out = rule
                .pattern
                .replace_all(&out, |_: &regex::Captures<'_>| {
                    format!("[REDACTED {}]", rule.name)
                })
                .into_owned();
        }
    }
    out
}

fn collect_strings(v: &Value) -> String {
    let mut buf = String::new();
    fn walk(v: &Value, buf: &mut String) {
        match v {
            Value::String(s) => {
                buf.push_str(s);
                buf.push(' ');
            }
            Value::Array(a) => a.iter().for_each(|x| walk(x, buf)),
            Value::Object(o) => o.values().for_each(|x| walk(x, buf)),
            _ => {}
        }
    }
    walk(v, &mut buf);
    buf.trim().to_string()
}

// ISS-005: scan::collect_hard_findings needs spans from HARD_RULES.find_iter().
// Promote Rule + HARD_RULES to pub(crate) so scan.rs can iterate without duplication.
pub(crate) struct Rule {
    pub(crate) name: &'static str,
    pub(crate) pattern: Regex,
}

// NOTE: 规则**顺序敏感** —— redact_string 会逐条 replace_all,命中即替换为占位符,
// 后续规则不会再匹配同一片段。因此 anthropic 必须**先于** openai,
// 否则 `sk-[A-Za-z0-9_\-]{20,}` 会先吞掉 `sk-ant-...`。
//
// 规则集演进见 ADR 0002 §D1 与 I01.md。规则清单是**本迭代已声明覆盖**的 secret
// 指纹集合;未列入的指纹(Slack / Stripe / GCP SA key / SSH host key / OAuth client_secret
// 等)**不在 I01 承诺范围内**,由后续迭代补齐。
pub(crate) static ALL_RULES: Lazy<Vec<Rule>> = Lazy::new(|| {
    vec![
        Rule {
            name: "aws_access_key_id",
            // 前缀 AKIA / ASIA + 16 位大写字母数字
            pattern: Regex::new(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b").expect("regex"),
        },
        Rule {
            name: "github_token",
            // Personal Access Token / Fine-grained PAT / App token
            pattern: Regex::new(r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b").expect("regex"),
        },
        // ---- 顺序强约束:anthropic 必须先于 openai ----
        Rule {
            name: "anthropic_api_key",
            pattern: Regex::new(r"\bsk-ant-[A-Za-z0-9_\-]{20,}\b").expect("regex"),
        },
        Rule {
            name: "openai_api_key",
            // 故意宽松匹配 `sk-...`;anthropic 规则已在前面先替换,不会被本规则再吞。
            pattern: Regex::new(r"\bsk-[A-Za-z0-9_\-]{20,}\b").expect("regex"),
        },
        // ---- 通用 .env 风格键值对:`SOMETHING_KEY/TOKEN/...=value` ----
        //
        // 覆盖"自由文本"里的键值对(区别于 JSON object-key 启发)。例如:
        //   "OPENAI_API_KEY=sk-xxxx"
        //   "DATABASE_PASSWORD=hunter2"
        //   "SOME_SECRET: 'abc'"
        // key 部分允许大小写混合 + `_`,值部分吞到空白/逗号/引号止。
        Rule {
            name: "env_assignment",
            pattern: Regex::new(
                r#"(?i)\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|APIKEY|API_KEY|AUTH)\b\s*[=:]\s*["']?[^\s"',;}\]]+"#,
            )
            .expect("regex"),
        },
        Rule {
            name: "jwt",
            // 三段式 base64url,每段至少 4 字符;头至少带 ey
            pattern: Regex::new(
                r"\bey[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b",
            )
            .expect("regex"),
        },
        Rule {
            name: "email",
            // 保守:只识别常见域名;隐私场景也需脱敏
            pattern: Regex::new(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
                .expect("regex"),
        },
        Rule {
            name: "internal_ipv4",
            // 10.0.0.0/8 / 172.16.0.0/12 / 192.168.0.0/16 / 127.0.0.0/8
            pattern: Regex::new(
                r"\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|127\.\d{1,3}\.\d{1,3}\.\d{1,3})\b",
            )
            .expect("regex"),
        },
        // I09c:Slack incoming webhook URL(hard secret,泄漏即任意人可发消息到该频道)
        // 格式:`https://hooks.slack.com/services/T<TEAM>/B<BOT>/<SIGN>`,三段各自独立 id
        Rule {
            name: "slack_webhook",
            pattern: Regex::new(
                r"\bhttps://hooks\.slack\.com/services/T[A-Z0-9]{8,12}/B[A-Z0-9]{8,12}/[A-Za-z0-9]{20,}\b",
            )
            .expect("regex"),
        },
        // I09c:Stripe secret API key(live/test 两前缀,`sk_` 下划线区别于 anthropic `sk-`)
        // 格式:`sk_live_...` 或 `sk_test_...`(24+ chars,实际常见 ~100 chars)
        Rule {
            name: "stripe_secret_key",
            pattern: Regex::new(r"\bsk_(live|test)_[A-Za-z0-9]{24,}\b").expect("regex"),
        },
        // I09c 第二批:Google API key —— 官方固定 format `AIza` + 35 chars,共 39 chars,
        // 广泛用于 Maps / YouTube / Gemini 等 API,泄漏即"任意调用者可消耗配额 / 读数据"
        Rule {
            name: "google_api_key",
            pattern: Regex::new(r"\bAIza[A-Za-z0-9_\-]{35}\b").expect("regex"),
        },
        // I09c 第二批:GitLab personal access token —— `glpat-` 前缀 + 20+ chars
        // 泄漏 = 企业 GitLab 仓库读写权限,与 github_token 同级危险
        Rule {
            name: "gitlab_pat",
            pattern: Regex::new(r"\bglpat-[A-Za-z0-9_\-]{20,}\b").expect("regex"),
        },
        // I09c 第三批:database URL 含凭证 —— 结构化硬指纹(不依赖上下文)
        //
        // 必须含 user:password@ 部分才算暴露。无凭证的 `postgres://host/db` 不匹配
        // (那不是敏感)。scheme 白名单覆盖主流 DB/broker。scheme 顺序 longest-first:
        // postgresql > postgres / mongodb+srv > mongodb / rediss > redis / amqps > amqp
        // (regex alternation 顺序敏感,避免前缀被短 scheme 先吃)。
        //
        // password 允许任意非 `@`/非空白字符(含 URL-encoded `%XX` / 特殊符号),
        // host 收紧到 `[A-Za-z0-9.\-]` 防粘连下一 token。
        Rule {
            name: "database_url",
            pattern: Regex::new(
                r"\b(postgresql|postgres|mysql|mongodb\+srv|mongodb|rediss|redis|amqps|amqp)://[^:/\s@]+:[^@/\s]+@[A-Za-z0-9.\-]+(:\d+)?(/[^\s]*)?",
            )
            .expect("regex"),
        },
        // v0.7-α3 R1a(E6a):generic HTTP/HTTPS URL — Phase 3 spike-3 R1 暴露的
        // production gap(原仅 internal_ipv4 → Url canonical,公网 URL 漏检)。
        // 路由到 PrivacyLabel::Url(label.rs::from_kind 加 "generic_url" 分支)。
        //
        // 顺序敏感:本规则放在 slack_webhook / database_url 之后,因这些更专的
        // URL 规则有独立 canonical(secret 类),先匹配避免被 generic_url 吃。
        // 字符集排除空白 + 引号 + `<>` 防 HTML 解析边界粘连。
        Rule {
            name: "generic_url",
            pattern: Regex::new(r#"\bhttps?://[^\s<>"']+"#).expect("regex"),
        },
    ]
});

// 硬指纹规则:用于 audit 入口的 fail-closed 自检。比 ALL_RULES 更严格,只挑**绝不**允许
// 出现在已脱敏 payload 里的那些。email / internal_ipv4 不纳入(可能是合法上下文)。
//
// 与 ALL_RULES 的语义对齐:anthropic / openai / aws / github / pem / jwt / env_assignment
// 都必须在这里有对应条目。顺序同样敏感(anthropic 先于 openai)。
pub(crate) static HARD_RULES: Lazy<Vec<Rule>> = Lazy::new(|| {
    vec![
        Rule {
            name: "aws_access_key_id",
            pattern: Regex::new(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b").expect("regex"),
        },
        Rule {
            name: "github_token",
            pattern: Regex::new(r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b").expect("regex"),
        },
        Rule {
            name: "anthropic_api_key",
            pattern: Regex::new(r"\bsk-ant-[A-Za-z0-9_\-]{20,}\b").expect("regex"),
        },
        Rule {
            name: "openai_api_key",
            pattern: Regex::new(r"\bsk-[A-Za-z0-9_\-]{20,}\b").expect("regex"),
        },
        Rule {
            name: "pem_private_key",
            pattern: Regex::new(r"-----BEGIN [A-Z ]*PRIVATE KEY-----").expect("regex"),
        },
        Rule {
            name: "jwt",
            pattern: Regex::new(
                r"\bey[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b",
            )
            .expect("regex"),
        },
        Rule {
            name: "env_assignment",
            pattern: Regex::new(
                r#"(?i)\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|APIKEY|API_KEY|AUTH)\b\s*[=:]\s*["']?[^\s"',;}\]]+"#,
            )
            .expect("regex"),
        },
        // I09c:hard-rule 镜像 ALL_RULES 新增的 slack_webhook / stripe_secret_key
        Rule {
            name: "slack_webhook",
            pattern: Regex::new(
                r"\bhttps://hooks\.slack\.com/services/T[A-Z0-9]{8,12}/B[A-Z0-9]{8,12}/[A-Za-z0-9]{20,}\b",
            )
            .expect("regex"),
        },
        Rule {
            name: "stripe_secret_key",
            pattern: Regex::new(r"\bsk_(live|test)_[A-Za-z0-9]{24,}\b").expect("regex"),
        },
        // I09c 第二批:HARD_RULES 镜像 google_api_key / gitlab_pat
        Rule {
            name: "google_api_key",
            pattern: Regex::new(r"\bAIza[A-Za-z0-9_\-]{35}\b").expect("regex"),
        },
        Rule {
            name: "gitlab_pat",
            pattern: Regex::new(r"\bglpat-[A-Za-z0-9_\-]{20,}\b").expect("regex"),
        },
        // I09c 第三批:HARD_RULES 镜像 database_url
        Rule {
            name: "database_url",
            pattern: Regex::new(
                r"\b(postgresql|postgres|mysql|mongodb\+srv|mongodb|rediss|redis|amqps|amqp)://[^:/\s@]+:[^@/\s]+@[A-Za-z0-9.\-]+(:\d+)?(/[^\s]*)?",
            )
            .expect("regex"),
        },
        // 注:generic_url **不**加入 HARD_RULES(secret 类子集)。它在 ALL_RULES 是
        // url canonical 的兜底,通过 scan::collect_url_hard_findings 在
        // scan_text_with_engine 路径补充,**不**破坏 vigil-browser rule_sync 12 项
        // secret 守门数字(ISS-021 RULE_PROFILE_VERSION v5 兼容)。
    ]
});

static PEM_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"-----BEGIN [A-Z ]*PRIVATE KEY-----").expect("regex"));

// **窄形**占位符识别:只匹配 redact 本模块自身产出的形态。
//
// 1) `[REDACTED <rule_name>]` —— rule_name 由本模块声明,形如 `[a-z_]+`
//    (与 `static ALL_RULES` 的 `name` 字段一致的命名规则)。
// 2) `[REDACTED len=<n> by_key=<safe>]` —— key-hint 专用形态;safe 字符集不含
//    能组成合法 env_assignment 的尾部(= 值 / 引号等)。
//
// 攻击者构造 `[REDACTED ghp_realtoken]` 等**超出上述形态**的字符串不会被本正则
// 剥除,从而保留给 HARD_RULES 扫描并被拦下(详见 detect_hard_secret 注释)。
static KNOWN_REDACTED_MARKER: Lazy<Regex> = Lazy::new(|| {
    // by_key 字符集必须与 BY_KEY_SAFE_CHAR_CLASS / normalize_key_for_placeholder 一致。
    let pattern = format!(
        r"\[REDACTED (?:len=\d+ by_key={c}+|[a-z_]+)\]",
        c = BY_KEY_SAFE_CHAR_CLASS
    );
    Regex::new(&pattern).expect("regex")
});

static KEY_HINT: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(?i)(secret|token|password|api[_\-]?key|auth)").expect("regex"));

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn crate_iteration_is_i01() {
        assert_eq!(ITERATION, "I01");
    }

    #[test]
    fn redacts_github_token_in_string() {
        let v = json!({"note": "my token is ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"});
        let (out, summary) = redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(!s.contains("ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"));
        assert!(s.contains("[REDACTED github_token]"));
        assert!(summary.contains("finding:github_token"));
    }

    #[test]
    fn redacts_aws_key() {
        let v = json!({"aws": "AKIAIOSFODNN7EXAMPLE"});
        let (out, _) = redact(&v);
        assert!(!serde_json::to_string(&out)
            .unwrap()
            .contains("AKIAIOSFODNN7EXAMPLE"));
    }

    #[test]
    fn redacts_pem_block() {
        let v = json!({
            "ssh": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKC...\n-----END RSA PRIVATE KEY-----"
        });
        let (out, summary) = redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(!s.contains("BEGIN RSA PRIVATE KEY"));
        assert!(s.contains("[REDACTED pem_private_key]"));
        assert!(summary.contains("pem_private_key"));
    }

    #[test]
    fn redacts_sensitive_key_by_name() {
        // 即使值本身不匹配任何硬指纹,只要 key 名含 secret/token/password/api_key,就脱敏
        let v = json!({"database_password": "hunter2", "ok": "hello"});
        let (out, _) = redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(!s.contains("hunter2"));
        assert!(s.contains("[REDACTED"));
        assert!(s.contains("hello")); // 普通字段保持
    }

    #[test]
    fn redacts_email_and_internal_ip() {
        let v = json!({"msg": "contact alice@example.com on 192.168.1.5"});
        let (out, _) = redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(!s.contains("alice@example.com"));
        assert!(!s.contains("192.168.1.5"));
    }

    #[test]
    fn leaves_non_sensitive_untouched() {
        let v = json!({"n": 42, "flag": true, "list": [1,2,3], "msg": "hello world"});
        let (out, summary) = redact(&v);
        assert_eq!(out, v);
        // 未命中任何规则时 summary 只含字符串语料
        assert!(!summary.contains("finding:"));
    }

    #[test]
    fn detect_hard_secret_catches_github_token() {
        let text = r#"{"x": "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"}"#;
        assert_eq!(detect_hard_secret(text), Some("github_token"));
    }

    #[test]
    fn detect_hard_secret_catches_pem() {
        assert_eq!(
            detect_hard_secret("...-----BEGIN RSA PRIVATE KEY-----..."),
            Some("pem_private_key")
        );
    }

    #[test]
    fn detect_hard_secret_allows_clean_text() {
        assert_eq!(detect_hard_secret(r#"{"msg":"hello world"}"#), None);
    }

    /// FTS 摘要不得含原始 secret。
    #[test]
    fn fts_summary_never_contains_raw_secret() {
        const MAGIC: &str = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        let v = json!({"note": format!("token = {}", MAGIC)});
        let (_out, summary) = redact(&v);
        assert!(
            !summary.contains(MAGIC),
            "summary 泄漏了 secret: {}",
            summary
        );
        assert!(summary.contains("finding:github_token"));
    }

    /// Anthropic key 必须被识别为 `anthropic_api_key`,**不能**被 openai 规则吞掉。
    /// Codex I01 review 的 MUST-FIX 回归测试。
    #[test]
    fn anthropic_key_not_misclassified_as_openai() {
        let v = json!({"note": "value=sk-ant-api03_ABCDEFGHIJKLMNOPQRSTUVWX"});
        let (out, summary) = redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(!s.contains("sk-ant-api03"));
        assert!(
            summary.contains("anthropic_api_key"),
            "summary 应含 anthropic,实际:{}",
            summary
        );
    }

    /// `detect_hard_secret` 对 anthropic 也必须命中,且优先级高于 openai。
    #[test]
    fn detect_hard_secret_catches_anthropic_before_openai() {
        let text = r#"{"x": "sk-ant-api03_ABCDEFGHIJKLMNOPQRSTUVWX"}"#;
        assert_eq!(detect_hard_secret(text), Some("anthropic_api_key"));
    }

    /// 自由文本 `KEY=value` 也必须被脱敏(文档承诺与实现对齐)。
    #[test]
    fn env_style_assignment_is_redacted() {
        let v = json!({
            "log": "OPENAI_API_KEY=some-unregulated-value-xyz123abc\nDATABASE_PASSWORD: hunter2\nOK=yes"
        });
        let (out, _) = redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(
            !s.contains("some-unregulated-value-xyz123abc"),
            "OPENAI_API_KEY=... 未脱敏:{}",
            s
        );
        assert!(!s.contains("hunter2"), "DATABASE_PASSWORD 未脱敏:{}", s);
        assert!(s.contains("OK=yes"), "OK=yes 不应被误脱敏:{}", s);
    }

    /// `detect_hard_secret` 对 env_assignment 模式也应命中。
    #[test]
    fn detect_hard_secret_catches_env_assignment() {
        assert_eq!(
            detect_hard_secret("DATABASE_PASSWORD=hunter2"),
            Some("env_assignment")
        );
    }

    /// F1 回归(ADR 0003 §F1):包含点 / 斜杠 / 中文的 JSON key,
    /// 经 normalize 后必须仍在 KNOWN_REDACTED_MARKER 可识别的字符集内。
    #[test]
    fn f1_special_chars_in_key_normalize_to_marker_safe_class() {
        // 每个 case 的 key 都含非 [A-Za-z0-9_-] 的字符
        let cases = vec![
            json!({"app.config.secret": "sensitive-value-12345"}),
            json!({"path/to/token": "secret-data-abc123"}),
            json!({"中文密钥": "chinese-secret-content"}),
            json!({"key with space": "spaced-secret-value"}),
            json!({"k@weird#chars!": "another-secret-string"}),
        ];
        for v in cases {
            let (out, _) = redact(&v);
            let s = serde_json::to_string(&out).unwrap();
            // 找到 placeholder 子串
            if s.contains("[REDACTED") {
                // marker 必须能剥除它(否则 detect_hard_secret 不一致)
                assert_eq!(
                    detect_hard_secret(&s),
                    None,
                    "placeholder 形态漂出 marker 集合;输出={}",
                    s
                );
            }
        }
    }

    /// Codex I01 第二轮 review 发现:`[REDACTED ...]` 剥除必须**只剥窄形**,
    /// 否则攻击者可用伪装占位符绕过硬检。本测试就是这个攻击面的回归:
    ///   - 模拟恶意 caller 把原文 secret 裹进假 placeholder 里。
    ///   - detect_hard_secret 必须仍然识别出底层的 github_token / env_assignment。
    #[test]
    fn detect_hard_secret_not_bypassed_by_fake_placeholder() {
        // 攻击 1:假 placeholder 包 github token
        let fake1 = "[REDACTED ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ]";
        assert!(
            detect_hard_secret(fake1).is_some(),
            "伪装 placeholder 里的 token 必须被拦下"
        );

        // 攻击 2:假 placeholder 包 .env 赋值
        let fake2 = "[REDACTED DATABASE_PASSWORD=hunter2]";
        assert!(
            detect_hard_secret(fake2).is_some(),
            "伪装 placeholder 里的 env 赋值必须被拦下"
        );

        // 攻击 3:假 placeholder 包 anthropic key
        let fake3 = "[REDACTED sk-ant-api03_abcdefghijklmnopqrstuvwx]";
        assert!(
            detect_hard_secret(fake3).is_some(),
            "伪装 placeholder 里的 anthropic key 必须被拦下"
        );

        // 对照:redact 真实产出的窄形占位不被误判为 secret
        assert!(detect_hard_secret("[REDACTED github_token]").is_none());
        assert!(detect_hard_secret("[REDACTED pem_private_key]").is_none());
        assert!(detect_hard_secret("[REDACTED env_assignment]").is_none());
        assert!(detect_hard_secret("[REDACTED len=40 by_key=auth]").is_none());
    }
}