clipmem 0.2.6

macOS clipboard memory backed by SQLite and searchable from OpenClaw
Documentation
use crate::model::ClipboardSnapshot;

const CONTEXT_CUES: &[&str] = &[
    "api key",
    "api-key",
    "apikey",
    "access token",
    "access_token",
    "auth token",
    "authorization",
    "bearer",
    "client secret",
    "client_secret",
    "secret key",
    "secret_key",
    "token",
    "x-api-key",
];

pub(crate) fn should_skip_snapshot_for_api_key_filter(snapshot: &ClipboardSnapshot) -> bool {
    let search_text = snapshot.search_text();
    if search_text.trim().is_empty() {
        return false;
    }

    text_looks_like_api_key(search_text)
}

fn text_looks_like_api_key(text: &str) -> bool {
    has_strong_api_key_signature(text) || has_contextual_token_match(text)
}

fn has_strong_api_key_signature(text: &str) -> bool {
    token_candidates(text).any(matches_strong_api_key_signature)
}

fn has_contextual_token_match(text: &str) -> bool {
    let lines = text.lines().map(str::trim).collect::<Vec<_>>();
    for (index, line) in lines.iter().enumerate() {
        if line.is_empty() {
            continue;
        }

        let cue_nearby = [
            index.saturating_sub(1),
            index,
            (index + 1).min(lines.len() - 1),
        ]
        .into_iter()
        .any(|candidate| contains_context_cue(lines[candidate]));
        if !cue_nearby {
            continue;
        }

        if token_candidates(line).any(is_contextual_secret_candidate) {
            return true;
        }
    }

    false
}

fn contains_context_cue(text: &str) -> bool {
    let lower = text.to_ascii_lowercase();
    CONTEXT_CUES.iter().any(|cue| lower.contains(cue))
}

fn matches_strong_api_key_signature(token: &str) -> bool {
    matches_openai_key(token)
        || matches_anthropic_key(token)
        || matches_github_key(token)
        || matches_google_api_key(token)
        || matches_aws_access_key_id(token)
        || matches_slack_token(token)
        || matches_stripe_key(token)
}

fn matches_openai_key(token: &str) -> bool {
    token.len() >= 36
        && (token.starts_with("sk-proj-")
            || token.starts_with("sk-live-")
            || token.starts_with("sk-svcacct-")
            || (token.starts_with("sk-") && !token.starts_with("sk_test_")))
        && token_chars_are_safe(token)
        && class_count(token) >= 2
}

fn matches_anthropic_key(token: &str) -> bool {
    token.len() >= 24
        && token.starts_with("sk-ant-")
        && token_chars_are_safe(token)
        && class_count(token) >= 2
}

fn matches_github_key(token: &str) -> bool {
    const SHORT_PREFIXES: &[&str] = &["ghp_", "gho_", "ghu_", "ghs_", "ghr_"];

    (SHORT_PREFIXES
        .iter()
        .any(|prefix| token.starts_with(prefix) && token.len() >= 24)
        || (token.starts_with("github_pat_") && token.len() >= 40))
        && token_chars_are_safe(token)
}

fn matches_google_api_key(token: &str) -> bool {
    token.len() >= 35
        && token.len() <= 45
        && token.starts_with("AIza")
        && token_chars_are_safe(token)
}

fn matches_aws_access_key_id(token: &str) -> bool {
    token.len() == 20
        && (token.starts_with("AKIA") || token.starts_with("ASIA"))
        && token
            .chars()
            .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
}

fn matches_slack_token(token: &str) -> bool {
    token.len() >= 24
        && token.starts_with("xox")
        && token.contains('-')
        && token_chars_are_safe(token)
}

fn matches_stripe_key(token: &str) -> bool {
    token.len() >= 24
        && (token.starts_with("sk_live_")
            || token.starts_with("sk_test_")
            || token.starts_with("rk_live_")
            || token.starts_with("rk_test_"))
        && token_chars_are_safe(token)
}

fn is_contextual_secret_candidate(token: &str) -> bool {
    let len = token.len();
    if !(20..=200).contains(&len) {
        return false;
    }
    if !token_chars_are_safe(token) {
        return false;
    }
    if token.starts_with("http") {
        return false;
    }
    if class_count(token) < 3 {
        return false;
    }

    let lower = token.to_ascii_lowercase();
    !matches!(
        lower.as_str(),
        "authorization" | "bearer" | "token" | "secret" | "password" | "apikey"
    )
}

fn token_candidates(text: &str) -> impl Iterator<Item = &str> {
    text.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
        .filter(|token| !token.is_empty())
}

fn token_chars_are_safe(token: &str) -> bool {
    token
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}

fn class_count(token: &str) -> usize {
    let mut seen_lower = false;
    let mut seen_upper = false;
    let mut seen_digit = false;
    let mut seen_symbol = false;

    for ch in token.chars() {
        if ch.is_ascii_lowercase() {
            seen_lower = true;
        } else if ch.is_ascii_uppercase() {
            seen_upper = true;
        } else if ch.is_ascii_digit() {
            seen_digit = true;
        } else if ch == '_' || ch == '-' {
            seen_symbol = true;
        }
    }

    [seen_lower, seen_upper, seen_digit, seen_symbol]
        .into_iter()
        .filter(|seen| *seen)
        .count()
}

#[cfg(test)]
mod tests {
    use super::should_skip_snapshot_for_api_key_filter;
    use crate::model::{build_item, build_representation, build_snapshot, CaptureContext};

    fn text_snapshot(text: &str) -> crate::model::ClipboardSnapshot {
        build_snapshot(
            CaptureContext::new(1)
                .with_frontmost_app_name("Terminal")
                .with_frontmost_app_bundle_id("com.apple.Terminal"),
            vec![build_item(
                0,
                vec![build_representation(
                    "public.utf8-plain-text".to_string(),
                    Some(text.to_string()),
                    text.as_bytes().to_vec(),
                )],
            )],
        )
    }

    #[test]
    fn detects_openai_style_keys() {
        let snapshot = text_snapshot("sk-proj-abcDEF1234567890abcdefghijklmnopqrstuv");
        assert!(should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn detects_anthropic_style_keys() {
        let snapshot = text_snapshot("sk-ant-api03-AbCdEf1234567890abcdefghijklmnop");
        assert!(should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn detects_github_pat_tokens() {
        let snapshot = text_snapshot("github_pat_11AA22bb33CC44dd55EE66ff77GG88hh99II00jj");
        assert!(should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn detects_google_api_keys() {
        let snapshot = text_snapshot("AIzaSyD3XAMPLE1234567890abcDEFghiJKLmn");
        assert!(should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn detects_aws_access_key_ids() {
        let snapshot = text_snapshot("AKIAIOSFODNN7EXAMPLE");
        assert!(should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn detects_slack_tokens() {
        let token = [
            "xoxb",
            "123456789012",
            "123456789012",
            "abcdefghijklmnopqrstuvwx",
        ]
        .join("-");
        let snapshot = text_snapshot(&token);
        assert!(should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn detects_stripe_keys() {
        let token = ["sk", "live", "51PFakeAbCdEfGhIjKlMnOpQrStUvWxYz123456789"].join("_");
        let snapshot = text_snapshot(&token);
        assert!(should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn detects_contextual_generic_tokens() {
        let snapshot = text_snapshot("Authorization: Bearer 8JfA-2mQpV_4tLz9XnR6cH0wKdS7yBu3");
        assert!(should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn ignores_random_base64_without_context() {
        let snapshot = text_snapshot("QWxhZGRpbjpvcGVuIHNlc2FtZQ");
        assert!(!should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn ignores_git_shas_and_uuids() {
        let snapshot = text_snapshot(
            "commit 0123456789abcdef0123456789abcdef01234567\nrequest 123e4567-e89b-12d3-a456-426614174000",
        );
        assert!(!should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn ignores_normal_shell_commands() {
        let snapshot = text_snapshot("git status && cargo test -q");
        assert!(!should_skip_snapshot_for_api_key_filter(&snapshot));
    }

    #[test]
    fn ignores_empty_search_text() {
        let snapshot = build_snapshot(CaptureContext::new(1), Vec::new());
        assert!(!should_skip_snapshot_for_api_key_filter(&snapshot));
    }
}