- 新增磁盘加密、打印审计和剪贴板管控插件支持 - 优化水印插件显示效果,支持中文及更多Unicode字符 - 改进硬件资产收集逻辑,更准确获取磁盘和显卡信息 - 增强API错误处理,添加详细日志记录 - 完善前端界面,新增插件管理页面 - 修复多个UI问题,优化页面过渡效果 - 添加环境变量覆盖配置功能 - 实现插件状态管理API - 更新文档和变更日志 - 添加安装程序脚本支持
213 lines
7.1 KiB
Rust
213 lines
7.1 KiB
Rust
use std::time::Duration;
|
|
use tokio::sync::watch;
|
|
use tracing::{info, warn};
|
|
use csm_protocol::{Frame, MessageType, ClipboardRule, ClipboardViolationPayload};
|
|
|
|
/// Clipboard control configuration pushed from server
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ClipboardControlConfig {
|
|
pub enabled: bool,
|
|
pub rules: Vec<ClipboardRule>,
|
|
}
|
|
|
|
/// Start the clipboard control plugin.
|
|
/// Periodically checks clipboard content against rules and reports violations.
|
|
pub async fn start(
|
|
mut config_rx: watch::Receiver<ClipboardControlConfig>,
|
|
data_tx: tokio::sync::mpsc::Sender<Frame>,
|
|
device_uid: String,
|
|
) {
|
|
info!("Clipboard control plugin started");
|
|
let mut config = ClipboardControlConfig::default();
|
|
let mut check_interval = tokio::time::interval(Duration::from_secs(2));
|
|
check_interval.tick().await;
|
|
|
|
loop {
|
|
tokio::select! {
|
|
result = config_rx.changed() => {
|
|
if result.is_err() {
|
|
break;
|
|
}
|
|
let new_config = config_rx.borrow_and_update().clone();
|
|
info!(
|
|
"Clipboard control config updated: enabled={}, rules={}",
|
|
new_config.enabled,
|
|
new_config.rules.len()
|
|
);
|
|
config = new_config;
|
|
}
|
|
_ = check_interval.tick() => {
|
|
if !config.enabled || config.rules.is_empty() {
|
|
continue;
|
|
}
|
|
let uid = device_uid.clone();
|
|
let rules = config.rules.clone();
|
|
let result = tokio::task::spawn_blocking(move || check_clipboard(&uid, &rules)).await;
|
|
match result {
|
|
Ok(Some(payload)) => {
|
|
if let Ok(frame) = Frame::new_json(MessageType::ClipboardViolation, &payload) {
|
|
if data_tx.send(frame).await.is_err() {
|
|
warn!("Failed to send clipboard violation: channel closed");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Ok(None) => {}
|
|
Err(e) => warn!("Clipboard check task failed: {}", e),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check clipboard content against rules. Returns a violation payload if a rule matched.
|
|
fn check_clipboard(device_uid: &str, rules: &[ClipboardRule]) -> Option<ClipboardViolationPayload> {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let clipboard_text = get_clipboard_text();
|
|
let foreground_process = get_foreground_process();
|
|
|
|
for rule in rules {
|
|
if rule.rule_type != "block" {
|
|
continue;
|
|
}
|
|
// Check direction — only interested in "out" or "both"
|
|
if !matches!(rule.direction.as_str(), "out" | "both") {
|
|
continue;
|
|
}
|
|
|
|
// Check source process filter
|
|
if let Some(ref src_pattern) = rule.source_process {
|
|
if let Some(ref fg_proc) = foreground_process {
|
|
if !pattern_match(src_pattern, fg_proc) {
|
|
continue;
|
|
}
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Check content pattern
|
|
if let Some(ref content_pattern) = rule.content_pattern {
|
|
if let Some(ref text) = clipboard_text {
|
|
if !content_matches(content_pattern, text) {
|
|
continue;
|
|
}
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Rule matched — generate violation (never send raw content)
|
|
let preview = clipboard_text.as_ref().map(|t| format!("[{} chars]", t.len()));
|
|
|
|
// Clear clipboard to enforce block
|
|
let _ = std::process::Command::new("powershell")
|
|
.args(["-NoProfile", "-NonInteractive", "-Command", "Set-Clipboard -Value ''"])
|
|
.output();
|
|
|
|
info!("Clipboard blocked: rule_id={}", rule.id);
|
|
return Some(ClipboardViolationPayload {
|
|
device_uid: device_uid.to_string(),
|
|
source_process: foreground_process,
|
|
target_process: None,
|
|
content_preview: preview,
|
|
action_taken: "blocked".to_string(),
|
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
|
});
|
|
}
|
|
None
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = (device_uid, rules);
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn get_clipboard_text() -> Option<String> {
|
|
let output = std::process::Command::new("powershell")
|
|
.args(["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"])
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if text.is_empty() { None } else { Some(text) }
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn get_foreground_process() -> Option<String> {
|
|
let output = std::process::Command::new("powershell")
|
|
.args([
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
r#"Add-Type @"
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
public class WinAPI {
|
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
|
}
|
|
"@
|
|
$hwnd = [WinAPI]::GetForegroundWindow()
|
|
$pid = 0
|
|
[WinAPI]::GetWindowThreadProcessId($hwnd, [ref]$pid) | Out-Null
|
|
if ($pid -gt 0) { (Get-Process -Id $pid -ErrorAction SilentlyContinue).ProcessName } else { "" }"#,
|
|
])
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if name.is_empty() {
|
|
None
|
|
} else {
|
|
Some(name)
|
|
}
|
|
}
|
|
|
|
/// Simple case-insensitive wildcard pattern matching. Supports `*` as wildcard.
|
|
fn pattern_match(pattern: &str, text: &str) -> bool {
|
|
let p = pattern.to_lowercase();
|
|
let t = text.to_lowercase();
|
|
if !p.contains('*') {
|
|
return t.contains(&p);
|
|
}
|
|
let parts: Vec<&str> = p.split('*').collect();
|
|
if parts.is_empty() {
|
|
return true;
|
|
}
|
|
let mut pos = 0usize;
|
|
let mut matched_any = false;
|
|
for (i, part) in parts.iter().enumerate() {
|
|
if part.is_empty() {
|
|
continue;
|
|
}
|
|
matched_any = true;
|
|
if i == 0 && !parts[0].is_empty() {
|
|
if !t.starts_with(part) {
|
|
return false;
|
|
}
|
|
pos = part.len();
|
|
} else {
|
|
match t[pos..].find(part) {
|
|
Some(idx) => pos += idx + part.len(),
|
|
None => return false,
|
|
}
|
|
}
|
|
}
|
|
if matched_any && !parts.last().map_or(true, |p| p.is_empty()) {
|
|
return t.ends_with(parts.last().unwrap());
|
|
}
|
|
true
|
|
}
|
|
|
|
fn content_matches(pattern: &str, text: &str) -> bool {
|
|
text.to_lowercase().contains(&pattern.to_lowercase())
|
|
}
|