bpi-rs 0.2.1

Bilibili API client library for Rust
Documentation
use serde::{Deserialize, Serialize};

use crate::probe::endpoint_contract::ApiRisk;

pub const SANITIZED_MID: u64 = 1_000_001;
pub const SANITIZED_USER: &str = "sanitized-user";
pub const REDACTED: &str = "<redacted>";
pub const SANITIZED_AVATAR_URL: &str = "https://example.invalid/avatar.png";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SanitizeFinding {
    pub path: String,
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SanitizeAction {
    ReplaceString(String),
    ReplaceNumber(u64),
    Drop,
    Keep,
}

pub fn sanitize_value(value: &mut serde_json::Value, risk: Option<ApiRisk>) {
    sanitize_at_path(value, "", risk);
}

pub fn audit_value(value: &serde_json::Value, risk: Option<ApiRisk>) -> Vec<SanitizeFinding> {
    let mut findings = Vec::new();
    audit_at_path(value, "", risk, &mut findings);
    findings
}

fn sanitize_at_path(value: &mut serde_json::Value, path: &str, risk: Option<ApiRisk>) {
    match value {
        serde_json::Value::Object(map) => {
            for (key, child) in map.iter_mut() {
                let child_path = child_path(path, key);
                let normalized = normalize_key(key);
                if should_sanitize_account_fields(risk)
                    && let Some(action) = path_sanitize_action(&child_path)
                {
                    apply_action(child, action);
                    continue;
                }
                if always_redact_key(&normalized) {
                    *child = serde_json::Value::String(REDACTED.to_string());
                    continue;
                }
                if account_id_key(&normalized) && should_sanitize_account_fields(risk) {
                    *child = serde_json::Value::Number(SANITIZED_MID.into());
                    continue;
                }
                if account_name_key(&normalized) && should_sanitize_account_fields(risk) {
                    *child = serde_json::Value::String(SANITIZED_USER.to_string());
                    continue;
                }
                if avatar_key(&normalized) && should_sanitize_account_fields(risk) {
                    *child = serde_json::Value::String(SANITIZED_AVATAR_URL.to_string());
                    continue;
                }

                sanitize_at_path(child, &child_path, risk);
            }
        }
        serde_json::Value::Array(items) => {
            let item_path = format!("{path}[]");
            for item in items {
                sanitize_at_path(item, &item_path, risk);
            }
        }
        _ => {}
    }
}

fn audit_at_path(
    value: &serde_json::Value,
    path: &str,
    risk: Option<ApiRisk>,
    findings: &mut Vec<SanitizeFinding>,
) {
    match value {
        serde_json::Value::Object(map) => {
            for (key, child) in map {
                let child_path = child_path(path, key);
                let normalized = normalize_key(key);
                if always_redact_key(&normalized) && !is_redacted(child) {
                    findings.push(SanitizeFinding {
                        path: child_path.clone(),
                        reason: "敏感认证字段未脱敏".to_string(),
                    });
                } else if should_sanitize_account_fields(risk)
                    && let Some(action) = path_sanitize_action(&child_path)
                    && !is_sanitized_for_action(child, &action)
                {
                    findings.push(SanitizeFinding {
                        path: child_path.clone(),
                        reason: "路径覆盖字段未脱敏".to_string(),
                    });
                } else if should_sanitize_account_fields(risk)
                    && (account_id_key(&normalized)
                        || account_name_key(&normalized)
                        || avatar_key(&normalized))
                    && !is_sanitized_account_value(child)
                {
                    findings.push(SanitizeFinding {
                        path: child_path.clone(),
                        reason: "账号可识别字段未脱敏".to_string(),
                    });
                }

                audit_at_path(child, &child_path, risk, findings);
            }
        }
        serde_json::Value::Array(items) => {
            let item_path = format!("{path}[]");
            for item in items {
                audit_at_path(item, &item_path, risk, findings);
            }
        }
        _ => audit_scalar_value(value, path, findings),
    }
}

fn audit_scalar_value(value: &serde_json::Value, path: &str, findings: &mut Vec<SanitizeFinding>) {
    let Some(value) = value.as_str() else {
        return;
    };
    let lower = value.to_ascii_lowercase();
    if lower.contains("sessdata=")
        || lower.contains("bili_jct=")
        || lower.contains("dedeuserid=")
        || lower.contains("buvid3=")
        || looks_like_email(value)
    {
        findings.push(SanitizeFinding {
            path: path.to_string(),
            reason: "字符串值疑似包含账号凭据或联系方式".to_string(),
        });
    }
}

fn child_path(path: &str, key: &str) -> String {
    if path.is_empty() {
        format!("$.{key}")
    } else {
        format!("{path}.{key}")
    }
}

fn normalize_key(key: &str) -> String {
    key.chars()
        .filter(|ch| *ch != '_' && *ch != '-')
        .flat_map(char::to_lowercase)
        .collect()
}

fn always_redact_key(key: &str) -> bool {
    matches!(
        key,
        "sessdata"
            | "bilijct"
            | "csrf"
            | "cookie"
            | "authorization"
            | "setcookie"
            | "buvid3"
            | "qrcodekey"
    )
}

fn path_sanitize_action(path: &str) -> Option<SanitizeAction> {
    match path {
        "$.data.mid"
        | "$.data.owner.mid"
        | "$.data.list[].owner.mid"
        | "$.data.cooperators[].mid" => Some(SanitizeAction::ReplaceNumber(SANITIZED_MID)),
        "$.data.name" | "$.data.owner.name" | "$.data.list[].owner.name" => {
            Some(SanitizeAction::ReplaceString(SANITIZED_USER.to_string()))
        }
        "$.data.cooperators[].nick_name" => {
            Some(SanitizeAction::ReplaceString(SANITIZED_USER.to_string()))
        }
        "$.data.list[].owner.face" => Some(SanitizeAction::ReplaceString(
            SANITIZED_AVATAR_URL.to_string(),
        )),
        _ => None,
    }
}

fn apply_action(value: &mut serde_json::Value, action: SanitizeAction) {
    match action {
        SanitizeAction::ReplaceString(replacement) => {
            *value = serde_json::Value::String(replacement);
        }
        SanitizeAction::ReplaceNumber(replacement) => {
            *value = serde_json::Value::Number(replacement.into());
        }
        SanitizeAction::Drop => {
            *value = serde_json::Value::Null;
        }
        SanitizeAction::Keep => {}
    }
}

fn is_sanitized_for_action(value: &serde_json::Value, action: &SanitizeAction) -> bool {
    match action {
        SanitizeAction::ReplaceString(replacement) => value.as_str() == Some(replacement.as_str()),
        SanitizeAction::ReplaceNumber(replacement) => value.as_u64() == Some(*replacement),
        SanitizeAction::Drop => value.is_null(),
        SanitizeAction::Keep => true,
    }
}

fn account_id_key(key: &str) -> bool {
    matches!(
        key,
        "mid" | "uid" | "ownermid" | "authoruid" | "memberid" | "targetid"
    )
}

fn account_name_key(key: &str) -> bool {
    matches!(
        key,
        "uname" | "name" | "nickname" | "nick" | "authorname" | "ownername"
    )
}

fn avatar_key(key: &str) -> bool {
    matches!(key, "face" | "avatar" | "authorface" | "ownerface")
}

fn should_sanitize_account_fields(risk: Option<ApiRisk>) -> bool {
    !matches!(risk, Some(ApiRisk::PublicRead))
}

fn is_redacted(value: &serde_json::Value) -> bool {
    value.as_str() == Some(REDACTED)
        || value
            .as_str()
            .is_some_and(|value| value.starts_with("sanitized-"))
}

fn is_sanitized_account_value(value: &serde_json::Value) -> bool {
    value.as_u64() == Some(SANITIZED_MID)
        || value.as_str() == Some(SANITIZED_USER)
        || value.as_str() == Some(SANITIZED_AVATAR_URL)
        || value.as_str() == Some(REDACTED)
}

fn looks_like_email(value: &str) -> bool {
    if value.contains('/') || value.contains('\\') || value.contains(' ') {
        return false;
    }
    let Some((local, domain)) = value.split_once('@') else {
        return false;
    };
    !local.is_empty() && domain.contains('.') && !domain.ends_with('.')
}

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

    #[test]
    fn sanitize_redacts_credentials_and_private_account_fields() {
        let mut value = serde_json::json!({
            "data": {
                "mid": 42,
                "name": "real-user",
                "face": "https://example.com/face.jpg",
                "csrf": "secret",
                "title": "public title"
            }
        });

        sanitize_value(&mut value, Some(ApiRisk::PrivateRead));

        assert_eq!(value["data"]["mid"], SANITIZED_MID);
        assert_eq!(value["data"]["name"], SANITIZED_USER);
        assert_eq!(value["data"]["face"], SANITIZED_AVATAR_URL);
        assert_eq!(value["data"]["csrf"], REDACTED);
        assert_eq!(value["data"]["title"], "public title");
    }

    #[test]
    fn public_read_keeps_account_like_public_fields() {
        let mut value = serde_json::json!({
            "data": {
                "owner": {
                    "mid": 42,
                    "name": "public-up"
                }
            }
        });

        sanitize_value(&mut value, Some(ApiRisk::PublicRead));

        assert_eq!(value["data"]["owner"]["mid"], 42);
        assert_eq!(value["data"]["owner"]["name"], "public-up");
    }

    #[test]
    fn audit_reports_unsanitized_private_fields() {
        let value = serde_json::json!({
            "data": {
                "mid": 42,
                "name": "real-user",
                "cookie": "SESSDATA=secret"
            }
        });

        let findings = audit_value(&value, Some(ApiRisk::PrivateRead));

        assert!(findings.iter().any(|finding| finding.path == "$.data.mid"));
        assert!(
            findings
                .iter()
                .any(|finding| finding.path == "$.data.cookie")
        );
    }

    #[test]
    fn sanitize_applies_private_path_overrides() {
        let mut value = serde_json::json!({
            "data": {
                "list": [
                    {
                        "owner": {
                            "mid": 42,
                            "name": "real-user",
                            "face": "https://example.com/face.jpg"
                        }
                    }
                ],
                "cooperators": [
                    {
                        "mid": 43,
                        "nick_name": "real-cooperator"
                    }
                ]
            }
        });

        sanitize_value(&mut value, Some(ApiRisk::PrivateRead));

        assert_eq!(value["data"]["list"][0]["owner"]["mid"], SANITIZED_MID);
        assert_eq!(value["data"]["list"][0]["owner"]["name"], SANITIZED_USER);
        assert_eq!(
            value["data"]["list"][0]["owner"]["face"],
            SANITIZED_AVATAR_URL
        );
        assert_eq!(value["data"]["cooperators"][0]["mid"], SANITIZED_MID);
        assert_eq!(value["data"]["cooperators"][0]["nick_name"], SANITIZED_USER);
    }

    #[test]
    fn audit_reports_unsanitized_path_overrides() {
        let value = serde_json::json!({
            "data": {
                "cooperators": [
                    {
                        "mid": 43,
                        "nick_name": "real-cooperator"
                    }
                ]
            }
        });

        let findings = audit_value(&value, Some(ApiRisk::PrivateRead));

        assert!(
            findings
                .iter()
                .any(|finding| finding.path == "$.data.cooperators[].mid")
        );
        assert!(
            findings
                .iter()
                .any(|finding| finding.path == "$.data.cooperators[].nick_name")
        );
    }
}