AnyBot/src/vault.rs

683 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 保險庫 / 1Password / 安全表單: the bot can sign in to a site in AnyBot's browser without ever
// seeing the password (the original keeps logins in a vault and fills them through a secure
// form; it also connects to 1Password). The login comes from the macOS Keychain (AnyBot's own
// saved logins or any internet password there) or the 1Password CLI (`op`), and is typed into
// the page by AnyBot; the model only hears that it was filled.
use serde_json::Value;
use std::process::Command;
pub const TOOL: &str = "browser_fill_login";
/// Keychain entries AnyBot saves are marked with this label.
const LABEL: &str = "AnyBot 登入";
pub const LIST_TOOL: &str = "vault_list";
pub const CARD_TOOL: &str = "browser_fill_card";
pub const CONTACT_TOOL: &str = "browser_fill_contact";
/// The vault tools the bot gets (with AnyBot's browser).
pub fn specs() -> Vec<Value> {
vec![
serde_json::json!({"name": LIST_TOOL, "description": "List what the person saved in AnyBot's 保險庫 (vault): logins (site, username, notes — never the password), payment cards (nickname, brand, last 4 digits, expiry — never the number or security code) and contact info (name, address, phones, emails). Notes say when to use an item. Use the ids with browser_fill_login / browser_fill_card / browser_fill_contact.",
"parameters": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["all", "login", "card", "contact"]}, "query": {"type": "string", "description": "Words to search for"}}}}),
serde_json::json!({"name": TOOL, "description": "Sign in on the current tab of AnyBot's browser with a login from the person's vault (or the macOS Keychain / 1Password). You never see the password; the person approves each use. Open the login page with browser_open first; after filling, press the page's sign-in button (computer_use or ask the person) and check with browser_read.",
"parameters": {"type": "object", "properties": {"login_id": {"type": "string", "description": "From vault_list (default: the saved login for the current site)"}, "site": {"type": "string", "description": "Domain to use (default: the current tab's)"}, "submit": {"type": "boolean", "description": "Also submit the form"}}}}),
serde_json::json!({"name": CARD_TOOL, "description": "Fill a saved payment card into the checkout form on the current tab of AnyBot's browser (card number, expiry, security code, name). You never see the number or security code; the person approves every use. Card fields inside a payment provider's frame may need the person's help. Never submit a payment without the person saying so.",
"parameters": {"type": "object", "properties": {"card_id": {"type": "string", "description": "From vault_list"}}, "required": ["card_id"]}}),
serde_json::json!({"name": CONTACT_TOOL, "description": "Fill saved contact info (name, address, phone, email) into the form on the current tab of AnyBot's browser, e.g. a shipping or billing address.",
"parameters": {"type": "object", "properties": {"contact_id": {"type": "string", "description": "From vault_list"}}, "required": ["contact_id"]}}),
]
}
/// vault_list: what the bot may know (no passwords, card numbers or security codes).
pub fn list_for_bot(args: &str) -> String {
use crate::vault_store::Kind;
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default();
let kind = v["kind"].as_str().unwrap_or("all");
let q = v["query"].as_str().unwrap_or("").trim().to_lowercase();
let items = match crate::vault_store::list() {
Ok(i) => i,
Err(e) => return format!("讀不到保險庫:{e}"),
};
let rows: Vec<Value> = items
.iter()
.filter(|i| match kind {
"login" => i.kind == Kind::Login,
"card" => i.kind == Kind::Card,
"contact" => i.kind == Kind::Contact,
_ => true,
})
.filter(|i| q.is_empty() || i.haystack().contains(&q))
.map(|i| match i.kind {
Kind::Login => serde_json::json!({"id": i.id, "kind": "login", "title": i.display_title(), "sites": i.sites, "username": i.username, "notes": i.notes}),
Kind::Card => serde_json::json!({"id": i.id, "kind": "card", "title": i.display_title(), "brand": i.brand, "last4": i.last4, "expiry": format!("{:02}/{}", i.exp_month, i.exp_year % 100), "holder": i.holder, "billing_address": i.billing.one_line(), "notes": i.notes}),
Kind::Contact => serde_json::json!({"id": i.id, "kind": "contact", "label": i.title, "name": i.name, "address": {"line1": i.address.line1, "line2": i.address.line2, "city": i.address.city, "region": i.address.region, "postal_code": i.address.postal, "country": i.address.country}, "phones": i.phones, "emails": i.emails, "notes": i.notes}),
})
.collect();
if rows.is_empty() {
return "保險庫裡沒有符合的項目。請使用者到「設定 → 保險庫」新增,或改問使用者。".into();
}
serde_json::to_string_pretty(&rows).unwrap_or_default()
}
/// "https://accounts.example.com/login?x" → "accounts.example.com"
pub fn host_of(url: &str) -> String {
let rest = url.split("://").nth(1).unwrap_or(url);
rest.split(['/', '?', '#']).next().unwrap_or("").split('@').last().unwrap_or("").split(':').next().unwrap_or("").to_lowercase()
}
/// accounts.google.com → [accounts.google.com, google.com]
pub fn candidates(host: &str) -> Vec<String> {
let parts: Vec<&str> = host.split('.').collect();
let mut v = vec![host.to_string()];
for i in 1..parts.len().saturating_sub(1) {
v.push(parts[i..].join("."));
}
v
}
pub struct Login {
pub username: String,
pub password: String,
pub source: &'static str,
}
// ---------- Linux: the desktop keyring (GNOME Keyring / KWallet) through Secret Service ----------
// Entries are stored with attributes application=anybot, server=<host>, user=<name>.
fn secret_tool(args: &[&str], input: Option<&str>) -> Option<std::process::Output> {
use std::io::Write;
let mut c = Command::new("secret-tool");
// The label has Chinese in it: make sure secret-tool reads its arguments as UTF-8 (C.UTF-8
// exists everywhere; the person's own locale might not be installed).
c.env("LC_ALL", "C.UTF-8");
c.args(args).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).stdin(if input.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() });
let mut child = c.spawn().ok()?;
if let (Some(text), Some(mut w)) = (input, child.stdin.take()) {
let _ = w.write_all(text.as_bytes());
}
child.wait_with_output().ok()
}
/// `secret-tool search --all` blocks → (attributes, secret).
pub fn parse_secret_search(out: &str) -> Vec<(std::collections::HashMap<String, String>, String)> {
let mut v = vec![];
for block in out.split("\n[").map(|b| b.trim_start_matches('[')) {
let mut attrs = std::collections::HashMap::new();
let mut secret = String::new();
for l in block.lines() {
if let Some((k, val)) = l.split_once(" = ") {
match k.trim() {
"secret" => secret = val.to_string(),
k if k.starts_with("attribute.") => {
attrs.insert(k.trim_start_matches("attribute.").to_string(), val.to_string());
}
_ => {}
}
}
}
if !attrs.is_empty() {
v.push((attrs, secret));
}
}
v
}
fn linux_find(host: &str) -> Option<Login> {
let o = secret_tool(&["search", "--all", "--unlock", "application", "anybot", "server", host], None)?;
// secret-tool prints the matches on stdout, or (older versions) on stderr.
let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
let (attrs, secret) = parse_secret_search(&text).into_iter().find(|(_, s)| !s.is_empty())?;
Some(Login { username: attrs.get("user").cloned().unwrap_or_default(), password: secret, source: "系統鑰匙圈" })
}
/// One Keychain login by site and account (bringing old AnyBot logins into the vault file).
pub fn keychain_login(site: &str, account: &str) -> Option<Login> {
if !cfg!(target_os = "macos") {
return linux_find(site).filter(|l| l.username == account);
}
let out = Command::new("security").args(["find-internet-password", "-s", site, "-a", account, "-g"]).output().ok()?;
if !out.status.success() {
return None;
}
Some(Login { username: account.to_string(), password: parse_g_password(&String::from_utf8_lossy(&out.stderr))?, source: "鑰匙圈" })
}
fn keychain(host: &str) -> Option<Login> {
if !cfg!(target_os = "macos") {
return linux_find(host);
}
// `-g` prints the attributes on stdout and the password on stderr, either as `"text"` or,
// when it isn't plain ASCII, as `0x<hex> "escaped"` (`-w` gives bare hex then, which
// can't be told apart from a password that looks like hex).
let out = Command::new("security").args(["find-internet-password", "-s", host, "-g"]).output().ok()?;
if !out.status.success() {
return None;
}
let meta = String::from_utf8_lossy(&out.stdout).to_string();
let account = meta.lines().find(|l| l.contains("\"acct\"")).and_then(|l| l.split("=\"").nth(1)).map(|s| s.trim_end_matches('"').to_string()).unwrap_or_default();
let password = parse_g_password(&String::from_utf8_lossy(&out.stderr))?;
Some(Login { username: account, password, source: "鑰匙圈" })
}
/// The password line of `security find-…-password -g`.
pub fn parse_g_password(stderr: &str) -> Option<String> {
let line = stderr.lines().find_map(|l| l.strip_prefix("password: ").or_else(|| (l == "password:").then_some("")))?;
if let Some(hex) = line.strip_prefix("0x") {
let hex: String = hex.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
let bytes: Option<Vec<u8>> = (0..hex.len() / 2).map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()).collect();
return String::from_utf8(bytes?).ok();
}
Some(line.strip_prefix('"').and_then(|l| l.strip_suffix('"')).unwrap_or(line).to_string())
}
fn onepassword(host: &str) -> Option<Login> {
let path = crate::cloudbox::tool_path();
let list = Command::new("op").env("PATH", &path).args(["item", "list", "--categories", "Login", "--format", "json"]).output().ok()?;
if !list.status.success() {
return None;
}
let items: Value = serde_json::from_slice(&list.stdout).ok()?;
let id = items.as_array()?.iter().find(|i| i["urls"].as_array().into_iter().flatten().any(|u| host_of(u["href"].as_str().unwrap_or("")).ends_with(host)))?["id"].as_str()?.to_string();
let got = Command::new("op").env("PATH", &path).args(["item", "get", &id, "--fields", "label=username,label=password", "--reveal", "--format", "json"]).output().ok()?;
let fields: Value = serde_json::from_slice(&got.stdout).ok()?;
let arr = fields.as_array()?;
let get = |l: &str| arr.iter().find(|f| f["label"].as_str() == Some(l)).and_then(|f| f["value"].as_str()).unwrap_or("").to_string();
Some(Login { username: get("username"), password: get("password"), source: "1Password" })
}
pub fn find(host: &str) -> Option<Login> {
for h in candidates(host) {
if let Some(l) = keychain(&h) {
return Some(l);
}
}
for h in candidates(host) {
if let Some(l) = onepassword(&h) {
return Some(l);
}
}
None
}
/// Save a login in the macOS Keychain (the person types it in AnyBot's settings).
/// (Kept for the live tests and older callers.)
#[cfg_attr(not(test), allow(dead_code))]
pub fn save(site: &str, username: &str, password: &str) -> Result<(), String> {
let host = host_of(site);
let host = if host.is_empty() { site.trim().to_lowercase() } else { host };
if host.is_empty() || password.is_empty() {
return Err("請填網站和密碼".into());
}
if !cfg!(target_os = "macos") {
let label = format!("{LABEL}:{host}");
let o = secret_tool(&["store", "--label", &label, "application", "anybot", "server", &host, "user", username], Some(password))
.ok_or("找不到 secret-tool:請安裝 libsecret-tools(系統鑰匙圈的指令)。")?;
return if o.status.success() { Ok(()) } else { Err(format!("存不進系統鑰匙圈:{}", String::from_utf8_lossy(&o.stderr).trim())) };
}
let out = Command::new("security").args(["add-internet-password", "-U", "-s", &host, "-a", username, "-l", LABEL, "-w", password]).output().map_err(|e| e.to_string())?;
if out.status.success() {
index_add(&host, username);
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
/// AnyBot's list of the logins it saved: site and account only (the passwords stay in the
/// keychain). macOS prints a non-ASCII label like 「AnyBot 登入」 as hex bytes, so the keychain
/// can't be searched by label reliably; this list is the source of truth for what to show.
fn index_path() -> std::path::PathBuf {
crate::model::State::path().parent().map(|d| d.join("vault-index.json")).unwrap_or_default()
}
fn read_index() -> Option<Vec<(String, String)>> {
let mut list: Vec<(String, String)> = std::fs::read_to_string(index_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
// Once: bring in logins AnyBot saved before the list existed.
let marker = index_path().with_file_name("vault-index-imported");
if cfg!(target_os = "macos") && !marker.exists() {
for l in legacy_keychain_logins() {
if !list.contains(&l) {
list.push(l);
}
}
write_index(&list);
let _ = std::fs::write(&marker, "1");
}
Some(list)
}
fn write_index(list: &[(String, String)]) {
let _ = std::fs::write(index_path(), serde_json::to_string_pretty(list).unwrap_or_default());
}
/// (Kept for the live tests and older callers.)
#[cfg_attr(not(test), allow(dead_code))]
fn index_add(site: &str, account: &str) {
let mut list = read_index().unwrap_or_default();
if !list.iter().any(|(s, a)| s == site && a == account) {
list.push((site.to_string(), account.to_string()));
write_index(&list);
}
}
/// The keychain has this login (no password is read, so no prompt).
fn keychain_has(site: &str, account: &str) -> bool {
Command::new("security").args(["find-internet-password", "-s", site, "-a", account]).output().map(|o| o.status.success()).unwrap_or(false)
}
/// Logins saved before the index existed: keychain items with AnyBot's label, which
/// `security dump-keychain` prints either as text or as hex bytes.
fn legacy_keychain_logins() -> Vec<(String, String)> {
let Ok(out) = Command::new("security").args(["dump-keychain"]).output() else { return vec![] };
let text = String::from_utf8_lossy(&out.stdout);
let hex: String = LABEL.bytes().map(|b| format!("{b:02X}")).collect();
let mut v = vec![];
for block in text.split("keychain: ") {
if !block.contains(&format!("=\"{LABEL}\"")) && !block.to_uppercase().contains(&format!("0X{hex}")) {
continue;
}
let get = |k: &str| block.lines().find(|l| l.contains(&format!("\"{k}\""))).and_then(|l| l.split("=\"").nth(1)).map(|s| s.trim_end_matches('"').to_string()).unwrap_or_default();
let (s, a) = (get("srvr"), get("acct"));
if !s.is_empty() {
v.push((s, a));
}
}
v
}
/// Sites AnyBot saved (names only).
pub fn saved_sites() -> Vec<(String, String)> {
if !cfg!(target_os = "macos") {
let Some(o) = secret_tool(&["search", "--all", "application", "anybot"], None) else { return vec![] };
let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
return parse_secret_search(&text).into_iter().filter_map(|(a, _)| Some((a.get("server")?.clone(), a.get("user").cloned().unwrap_or_default()))).collect();
}
let list = read_index().unwrap_or_default();
// Drop entries someone deleted in 鑰匙圈存取 meanwhile.
let kept: Vec<_> = list.iter().filter(|(s, a)| keychain_has(s, a)).cloned().collect();
if kept.len() != list.len() {
write_index(&kept);
}
kept
}
/// (Kept for the live tests and older callers.)
#[cfg_attr(not(test), allow(dead_code))]
pub fn remove(site: &str, account: &str) {
if !cfg!(target_os = "macos") {
let _ = secret_tool(&["clear", "application", "anybot", "server", site, "user", account], None);
return;
}
let _ = Command::new("security").args(["delete-internet-password", "-s", site, "-a", account]).output();
if let Some(mut list) = read_index() {
list.retain(|(s, a)| !(s == site && a == account));
write_index(&list);
}
}
/// The JavaScript that types the login into the page (as a JSON-encoded string literal).
pub fn fill_script(username: &str, password: &str, submit: bool) -> String {
let u = serde_json::to_string(username).unwrap_or_default();
let p = serde_json::to_string(password).unwrap_or_default();
format!(
r#"(() => {{
const set = (el, v) => {{ const d = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); d.set.call(el, v); el.dispatchEvent(new Event('input', {{bubbles: true}})); el.dispatchEvent(new Event('change', {{bubbles: true}})); }};
const vis = el => el.offsetParent !== null && !el.disabled;
const pw = [...document.querySelectorAll('input[type=password]')].find(vis);
const users = [...document.querySelectorAll('input[type=email],input[type=text],input[autocomplete=username],input[name*=user i],input[name*=mail i],input[id*=user i],input[id*=mail i]')].filter(vis);
let user = null;
if (pw) {{ for (const el of users) {{ if (el.compareDocumentPosition(pw) & Node.DOCUMENT_POSITION_FOLLOWING) user = el; }} }} else {{ user = users[0] || null; }}
if (user && {u}) set(user, {u});
if (pw) set(pw, {p});
let submitted = false;
if ({submit} && (pw || user)) {{
const field = pw || user;
const f = field.form;
// The form's own button; else (pages without a <form>, like many shops) the nearest visible
// button that says 登入 / Log in / Sign in / 繼續 / 下一步.
let b = f ? (f.querySelector('button[type=submit],input[type=submit]') || f.querySelector('button')) : null;
if (!b) {{
const words = /登入|登录|log ?in|sign ?in|繼續|继续|下一步|next|continue/i;
const cands = [...document.querySelectorAll('button,input[type=submit],[role=button],a')].filter(vis).filter(el => words.test((el.innerText || el.value || el.getAttribute('aria-label') || '').trim()));
const fr = field.getBoundingClientRect();
cands.sort((a, c) => {{ const ra = a.getBoundingClientRect(), rc = c.getBoundingClientRect(); return (Math.abs(ra.top - fr.top) + Math.abs(ra.left - fr.left)) - (Math.abs(rc.top - fr.top) + Math.abs(rc.left - fr.left)); }});
b = cands[0] || null;
}}
if (b && !b.disabled) {{ b.click(); submitted = true; }}
else if (f) {{ f.requestSubmit ? f.requestSubmit() : f.submit(); submitted = true; }}
}}
return JSON.stringify({{user: !!user, password: !!pw, submitted}});
}})()"#
)
}
fn args_of(args: &str) -> Value {
serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default()
}
/// The current tab's host in AnyBot's browser.
fn current_host() -> Result<String, String> {
let (url, _) = crate::botbrowser::eval_current(None, "1")?;
Ok(host_of(&url))
}
/// What browser_fill_login will use, worked out before asking the person.
pub struct LoginPlan {
pub host: String,
/// A vault login (public fields only).
pub item: Option<crate::vault_store::Item>,
/// The vault login is saved for this site (else the person is warned before allowing it).
pub matched: bool,
/// A Keychain / 1Password login (when the vault has none).
other: Option<Login>,
}
impl LoginPlan {
/// The approval question.
pub fn question(&self) -> String {
match &self.item {
Some(i) if self.matched => format!("用保險庫的登入資訊({}{})登入 {}(Bot 看不到密碼)", i.display_title(), if i.username.is_empty() { String::new() } else { format!(" · {}", i.username) }, self.host),
Some(i) => format!(
"也要在 {} 上使用 {} 嗎?(已為 {} 儲存)\n只有在它與已儲存的網站屬於同一服務時才允許。仿冒網站可能會竊取你的密碼。",
self.host,
i.display_title(),
i.sites.join("、")
),
None => format!("用{}的登入資料({})登入 {}(Bot 看不到密碼)", self.other.as_ref().map(|l| l.source).unwrap_or("已儲存"), self.other.as_ref().map(|l| l.username.as_str()).unwrap_or(""), self.host),
}
}
}
pub fn plan_login(args: &str) -> Result<LoginPlan, String> {
let v = args_of(args);
let tab = current_host()?;
let host = v["site"].as_str().filter(|s| !s.trim().is_empty()).map(host_of).filter(|h| !h.is_empty()).unwrap_or(tab);
if let Some(id) = v["login_id"].as_str().filter(|s| !s.is_empty()) {
let item = crate::vault_store::get(id).filter(|i| i.kind == crate::vault_store::Kind::Login).ok_or("保險庫裡沒有這筆登入資訊(先用 vault_list 看 id)")?;
let matched = item.fills_on(&host);
return Ok(LoginPlan { host, item: Some(item), matched, other: None });
}
if let Some(item) = crate::vault_store::logins_for(&host).into_iter().next() {
return Ok(LoginPlan { host, item: Some(item), matched: true, other: None });
}
match find(&host) {
Some(l) => Ok(LoginPlan { host, item: None, matched: true, other: Some(l) }),
None => Err(format!("找不到「{host}」的登入資料。請使用者在「設定 → 保險庫」新增一筆登入資訊(或存在 macOS 鑰匙圈/1Password)。")),
}
}
/// Type the planned login into the page (after the person allowed it).
pub fn fill_login(plan: &LoginPlan, submit: bool) -> Result<String, String> {
let (username, password, source) = match (&plan.item, &plan.other) {
(Some(i), _) => {
let (u, p) = crate::vault_store::login_secret(&i.id).ok_or("保險庫裡沒有這筆登入資訊")?;
if !plan.matched {
crate::vault_store::add_site(&i.id, &plan.host)?;
}
(u, p, "保險庫")
}
(None, Some(l)) => (l.username.clone(), l.password.clone(), l.source),
_ => return Err("沒有可用的登入資料".into()),
};
let (_, r) = crate::botbrowser::eval_current(None, &fill_script(&username, &password, submit))?;
let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default();
if got["password"].as_bool() != Some(true) && got["user"].as_bool() != Some(true) {
return Err("這一頁找不到帳號或密碼欄位。先打開登入頁(有些網站要先按「登入」或先填帳號、按下一步才出現密碼欄)。".into());
}
Ok(format!(
"已在 {} 填入{}{}(來自{source},你看不到密碼)。{}",
plan.host,
if got["user"].as_bool() == Some(true) { format!("帳號 {username}") } else { String::new() },
if got["password"].as_bool() == Some(true) { "和密碼" } else { "(這頁沒有密碼欄,可能要先按下一步)" },
if submit && got["submitted"].as_bool() == Some(true) {
"也已按下登入,請用 browser_read 確認是否登入成功。如果還在登入頁(常見原因:需要驗證碼、簡訊碼或人機驗證),請使用者在 AnyBot 瀏覽器視窗裡完成,之後會記住登入。"
} else if submit {
"但頁面上找不到登入按鈕,沒有送出。請用 computer_use 按登入按鈕,或請使用者按。"
} else {
"接著按頁面上的登入按鈕。"
}
))
}
/// JavaScript that fills a form's fields by their autocomplete hints and common names.
/// `values` maps a field role to its value; the page gets them as one JSON string literal.
pub fn form_fill_script(values: &Value) -> String {
let data = serde_json::to_string(&values.to_string()).unwrap_or_default();
format!(
r#"(() => {{
const V = JSON.parse({data});
const vis = el => el.offsetParent !== null && !el.disabled && !el.readOnly;
const set = (el, v) => {{
if (el.tagName === 'SELECT') {{
const want = String(v).toLowerCase();
const opt = [...el.options].find(o => o.value.toLowerCase() === want || o.text.trim().toLowerCase() === want || (want.length > 1 && o.text.trim().toLowerCase().startsWith(want)));
if (!opt) return false;
el.value = opt.value;
}} else {{
const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, String(v));
}}
el.dispatchEvent(new Event('input', {{bubbles: true}}));
el.dispatchEvent(new Event('change', {{bubbles: true}}));
return true;
}};
const rules = {{
'cc-number': [/card.?num|cc.?num|cardnumber|ccnumber|卡號/i],
'cc-exp': [/^(cc-?)?exp(iry|iration)?(-?date)?$|mm.?\/?.?yy|有效期/i],
'cc-exp-month': [/exp.*month|cc-?month|card.?month|月/i],
'cc-exp-year': [/exp.*year|cc-?year|card.?year|年/i],
'cc-csc': [/cvc|cvv|csc|security.?code|card.?code|安全碼|驗證碼/i],
'cc-name': [/name.?on.?card|card.?holder|holder.?name|cc-?name|持卡人/i],
'name': [/^(full.?)?name$|姓名|收件人/i],
'address-line1': [/address.?(line)?.?1|street|^address$|地址/i],
'address-line2': [/address.?(line)?.?2|apt|suite|unit/i],
'address-level2': [/city|town|城市|縣市/i],
'address-level1': [/state|province|region|county|州|省/i],
'postal-code': [/zip|postal|postcode|郵遞區號|郵遞/i],
'country': [/country|國家/i],
'tel': [/phone|mobile|tel|手機|電話/i],
'email': [/e-?mail|電子郵件/i],
}};
const fields = [...document.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=password]),select,textarea')].filter(vis);
const used = new Set();
const filled = [];
for (const role of Object.keys(V)) {{
if (V[role] === '' || V[role] == null) continue;
let el = fields.find(f => !used.has(f) && (f.autocomplete || '').toLowerCase().split(/\s+/).includes(role));
if (!el) {{
const pats = rules[role] || [];
el = fields.find(f => {{ const ac = (f.autocomplete || '').toLowerCase(); return !used.has(f) && (ac === '' || ac === 'on' || ac === 'off' || ac.startsWith('cc-') === role.startsWith('cc-')) && pats.some(p => p.test([f.type === 'text' ? '' : f.type, f.name, f.id, f.placeholder, f.getAttribute('aria-label') || '', (f.labels && f.labels[0] ? f.labels[0].innerText : '')].join(' '))); }});
}}
if (el && set(el, V[role])) {{ used.add(el); filled.push(role); }}
}}
const frames = document.querySelectorAll('iframe').length;
return JSON.stringify({{filled, frames}});
}})()"#
)
}
/// Names for the roles, as the bot is told what was filled.
fn role_name(r: &str) -> &'static str {
match r {
"cc-number" => "卡號",
"cc-exp" | "cc-exp-month" | "cc-exp-year" => "有效期限",
"cc-csc" => "安全碼",
"cc-name" => "持卡人姓名",
"name" => "姓名",
"address-line1" | "address-line2" => "地址",
"address-level2" => "城市",
"address-level1" => "州/省/地區",
"postal-code" => "郵遞區號",
"country" => "國家/地區",
"tel" => "電話",
"email" => "電子郵件",
_ => "欄位",
}
}
fn filled_summary(r: &Value) -> (Vec<&'static str>, u64) {
let mut names: Vec<&'static str> = r["filled"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).map(role_name).collect();
names.dedup();
(names, r["frames"].as_u64().unwrap_or(0))
}
/// For the approval question: (current host, card label) without secrets.
pub fn plan_card(args: &str) -> Result<(String, crate::vault_store::Item), String> {
let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string();
let card = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Card).ok_or("保險庫裡沒有這張卡片(先用 vault_list 看 id)")?;
Ok((current_host()?, card))
}
pub fn fill_card(args: &str) -> Result<String, String> {
let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string();
let c = crate::vault_store::card_secret(&id).ok_or("保險庫裡沒有這張卡片")?;
let values = serde_json::json!({
"cc-number": c.number,
"cc-exp": format!("{:02}/{:02}", c.exp_month, c.exp_year % 100),
"cc-exp-month": format!("{:02}", c.exp_month),
"cc-exp-year": c.exp_year.to_string(),
"cc-csc": c.csc,
"cc-name": c.holder,
});
let (_, r) = crate::botbrowser::eval_current(None, &form_fill_script(&values))?;
let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default();
let (names, frames) = filled_summary(&got);
if names.is_empty() {
return Err(if frames > 0 {
"這一頁的付款欄位在付款服務的框架裡,AnyBot 填不進去。請使用者自己填卡號,或改用網站提供的其他付款方式。".into()
} else {
"這一頁找不到付款欄位。先打開結帳頁的付款步驟。".into()
});
}
Ok(format!("已填入 {} •••• {} 的{}(你看不到卡號和安全碼)。送出付款前一定要先問使用者。", c.display_title(), c.last4, names.join("、")))
}
pub fn plan_contact(args: &str) -> Result<(String, crate::vault_store::Item), String> {
let id = args_of(args)["contact_id"].as_str().unwrap_or("").to_string();
let c = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Contact).ok_or("保險庫裡沒有這筆聯絡資訊(先用 vault_list 看 id)")?;
Ok((current_host()?, c))
}
pub fn fill_contact(args: &str) -> Result<String, String> {
let (_, c) = plan_contact(args)?;
let values = serde_json::json!({
"name": c.name, "address-line1": c.address.line1, "address-line2": c.address.line2, "address-level2": c.address.city,
"address-level1": c.address.region, "postal-code": c.address.postal, "country": c.address.country,
"tel": c.phones.first().cloned().unwrap_or_default(), "email": c.emails.first().cloned().unwrap_or_default(),
});
let (_, r) = crate::botbrowser::eval_current(None, &form_fill_script(&values))?;
let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default();
let (names, _) = filled_summary(&got);
if names.is_empty() {
return Err("這一頁找不到地址或聯絡欄位。".into());
}
Ok(format!("已填入「{}」的{}。請用 browser_read 確認。", c.display_title(), names.join("、")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hosts_and_candidates() {
assert_eq!(host_of("https://accounts.Google.com/signin?x=1"), "accounts.google.com");
assert_eq!(host_of("https://me:pw@example.com:8443/a"), "example.com");
assert_eq!(candidates("a.b.example.com"), vec!["a.b.example.com", "b.example.com", "example.com"]);
let found = parse_secret_search("[/org/freedesktop/secrets/collection/login/7]\nlabel = AnyBot 登入:x.com\nsecret = hunter2\ncreated = 2026-11-04\nattribute.application = anybot\nattribute.server = x.com\nattribute.user = me\n[/org/freedesktop/secrets/collection/login/8]\nsecret = pw2\nattribute.server = y.com\n");
assert_eq!(found.len(), 2);
assert_eq!(found[0].1, "hunter2");
assert_eq!(found[0].0["user"], "me");
assert_eq!(found[1].0["server"], "y.com");
}
#[test]
fn keychain_password_lines() {
assert_eq!(parse_g_password("password: \"hunter2\"\n").unwrap(), "hunter2");
assert_eq!(parse_g_password("password: \"a\"b\"\n").unwrap(), "a\"b");
assert_eq!(parse_g_password("password: 0x706122737320E4BDA0E5A5BD \"pa\\\"ss \\344\\275\\240\\345\\245\\275\"\n").unwrap(), "pa\"ss 你好");
assert_eq!(parse_g_password("password: \n").unwrap(), "");
assert!(parse_g_password("nothing").is_none());
}
#[test]
fn the_script_keeps_the_password_in_a_string() {
let js = fill_script("me@x.com", "p\"w'</script>", true);
assert!(js.contains(r#""p\"w'</script>""#));
assert!(js.contains("input[type=password]") && js.contains("if (true && (pw || user))"));
}
/// Needs a keyring: on Linux run under `dbus-run-session` with gnome-keyring unlocked.
#[test]
#[ignore]
fn keyring_live() {
save("https://login.example.org/x", "me@example.org", "s3cret pass").unwrap();
let l = find("login.example.org").expect("found");
assert_eq!((l.username.as_str(), l.password.as_str()), ("me@example.org", "s3cret pass"));
assert!(saved_sites().iter().any(|(s, a)| s == "login.example.org" && a == "me@example.org"));
// Logins from before the list existed (label printed as hex by `security`) show up too.
if cfg!(target_os = "macos") {
assert!(saved_sites().iter().any(|(s, _)| s == "legacy.example.org"), "legacy entry: {:?}", saved_sites());
}
remove("login.example.org", "me@example.org");
assert!(find("login.example.org").is_none());
}
/// The whole bot-side flow on this computer: local pages in AnyBot's browser (a throwaway
/// profile), items in a scratch vault, and the fill tools. Run with ANYBOT_DATA_DIR set to a
/// scratch folder; opens a browser window for a moment.
#[test]
#[ignore]
fn fill_live() {
use crate::vault_store as store;
use std::io::{Read, Write};
let dir = crate::model::State::path().parent().unwrap().to_path_buf();
std::env::set_var("ANYBOT_VAULT_DIR", &dir);
std::env::set_var("ANYBOT_VAULT_KEY_FILE", dir.join("vault-test.key"));
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for mut c in listener.incoming().flatten() {
let mut buf = [0u8; 2048];
let n = c.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let page = if req.starts_with("GET /checkout") {
"<html><body><form><input autocomplete=name id=n><input name=street id=a1><input placeholder=City id=c><input name=zip id=z><input type=tel id=t><input id=cc name=cardnumber><input id=ex placeholder='MM / YY'><input id=cv name=cvc><input id=hn placeholder='Name on card'></form></body></html>"
} else {
"<html><body><form><input type=email name=email id=e><input type=password id=p><button type=submit>Sign in</button></form></body></html>"
};
let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}", page.len());
}
});
crate::botbrowser::add("vault-test").unwrap();
let run = || -> Result<(), String> {
let login = store::put_login(store::LoginInput { id: None, sites: vec!["127.0.0.1".into()], username: "tester@example.org".into(), password: "pa\"ss 你好".into(), notes: String::new() })?;
let card = store::put_card(store::CardInput { id: None, number: "4111111111111111".into(), expiry: "08/39".into(), csc: "123".into(), holder: "WANG XIAOMING".into(), nickname: "測試".into(), billing: store::Address::default(), notes: String::new() })?;
let contact = store::put_contact(store::ContactInput { id: None, label: "住家".into(), name: "王小明".into(), address: store::Address { line1: "信義路五段 7 號".into(), city: "台北市".into(), postal: "110".into(), ..Default::default() }, phones: vec!["0912345678".into()], emails: vec![], notes: String::new() })?;
// What the bot sees has no secrets.
let seen = list_for_bot("{}");
assert!(seen.contains("tester@example.org") && seen.contains("1111") && !seen.contains("4111111111111111") && !seen.contains("pa\\\"ss") && !seen.contains("123\""), "{seen}");
crate::botbrowser::open(&format!("http://127.0.0.1:{port}/login"), Some("vault-test"))?;
let plan = plan_login("{}")?;
assert!(plan.matched && plan.item.as_ref().map(|i| i.id == login).unwrap_or(false));
let said = fill_login(&plan, false)?;
assert!(!said.contains("pa\"ss"), "{said}");
let (_, got) = crate::botbrowser::eval_current(None, "JSON.stringify([e.value, p.value])")?;
assert_eq!(got.as_str().unwrap(), r#"["tester@example.org","pa\"ss 你好"]"#);
crate::botbrowser::open(&format!("http://127.0.0.1:{port}/checkout"), Some("vault-test"))?;
let said = fill_card(&format!("{{\"card_id\":\"{card}\"}}"))?;
assert!(!said.contains("4111111111111111") && !said.contains("123"), "{said}");
fill_contact(&format!("{{\"contact_id\":\"{contact}\"}}"))?;
let (_, got) = crate::botbrowser::eval_current(None, "JSON.stringify([n.value,a1.value,c.value,z.value,t.value,cc.value,ex.value,cv.value,hn.value])")?;
assert_eq!(got.as_str().unwrap(), r#"["王小明","信義路五段 7 號","台北市","110","0912345678","4111111111111111","08/39","123","WANG XIAOMING"]"#);
Ok(())
};
let r = run();
let _ = crate::botbrowser::remove("vault-test");
let _ = std::fs::remove_file(dir.join("vault-test.key"));
let _ = std::fs::remove_file(store::file_path());
r.unwrap();
}
}