388 lines
11 KiB
Rust
388 lines
11 KiB
Rust
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use clap::Parser;
|
|
use regex::Regex;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
const DEFAULT_BASE_DIR: &str = "/opt/activitywatch/dlp-content-analysis";
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(about = "Analyze text or screenshot with DLP dictionaries/regex packs.")]
|
|
struct Cli {
|
|
#[arg(long)]
|
|
text: Option<String>,
|
|
|
|
#[arg(long)]
|
|
image: Option<PathBuf>,
|
|
|
|
#[arg(long)]
|
|
dictionary_pack: Option<String>,
|
|
|
|
#[arg(long)]
|
|
regex_pack: Option<String>,
|
|
|
|
#[arg(long, default_value = DEFAULT_BASE_DIR)]
|
|
base_dir: PathBuf,
|
|
|
|
#[arg(
|
|
long,
|
|
default_value = "/opt/activitywatch/dlp-content-analysis/.venv/bin/python"
|
|
)]
|
|
legacy_python: PathBuf,
|
|
|
|
#[arg(
|
|
long,
|
|
default_value = "/opt/activitywatch/dlp-content-analysis/content_analyzer.py"
|
|
)]
|
|
legacy_analyzer: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, PartialEq, Eq)]
|
|
struct MatchResult {
|
|
name: String,
|
|
description: String,
|
|
value: String,
|
|
start: usize,
|
|
end: usize,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
severity: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct Analysis {
|
|
text: String,
|
|
dictionary_pack: Option<String>,
|
|
regex_pack: Option<String>,
|
|
dictionary_matches: Vec<MatchResult>,
|
|
regex_matches: Vec<MatchResult>,
|
|
source: String,
|
|
}
|
|
|
|
fn digits_only(value: &str) -> String {
|
|
value.chars().filter(|ch| ch.is_ascii_digit()).collect()
|
|
}
|
|
|
|
fn validate_inn(value: &str) -> bool {
|
|
let digits = digits_only(value);
|
|
let nums = digits
|
|
.bytes()
|
|
.map(|byte| (byte - b'0') as u32)
|
|
.collect::<Vec<_>>();
|
|
match nums.len() {
|
|
10 => {
|
|
let coef = [2, 4, 10, 3, 5, 9, 4, 6, 8];
|
|
let chk = nums
|
|
.iter()
|
|
.take(9)
|
|
.zip(coef)
|
|
.map(|(digit, coef)| digit * coef)
|
|
.sum::<u32>()
|
|
% 11
|
|
% 10;
|
|
chk == nums[9]
|
|
}
|
|
12 => {
|
|
let c11 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8];
|
|
let c12 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8];
|
|
let chk11 = nums
|
|
.iter()
|
|
.take(10)
|
|
.zip(c11)
|
|
.map(|(digit, coef)| digit * coef)
|
|
.sum::<u32>()
|
|
% 11
|
|
% 10;
|
|
let chk12 = nums
|
|
.iter()
|
|
.take(11)
|
|
.zip(c12)
|
|
.map(|(digit, coef)| digit * coef)
|
|
.sum::<u32>()
|
|
% 11
|
|
% 10;
|
|
chk11 == nums[10] && chk12 == nums[11]
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn validate_snils(value: &str) -> bool {
|
|
let digits = digits_only(value);
|
|
if digits.len() != 11 {
|
|
return false;
|
|
}
|
|
let nums = digits
|
|
.bytes()
|
|
.map(|byte| (byte - b'0') as u32)
|
|
.collect::<Vec<_>>();
|
|
let checksum = nums[9] * 10 + nums[10];
|
|
let sum = nums
|
|
.iter()
|
|
.take(9)
|
|
.enumerate()
|
|
.map(|(idx, digit)| digit * (9 - idx as u32))
|
|
.sum::<u32>();
|
|
let expected = match sum {
|
|
0..=99 => sum,
|
|
100 | 101 => 0,
|
|
_ => {
|
|
let value = sum % 101;
|
|
if value == 100 { 0 } else { value }
|
|
}
|
|
};
|
|
checksum == expected
|
|
}
|
|
|
|
fn validate_passport(value: &str) -> bool {
|
|
let digits = digits_only(value);
|
|
digits.len() == 10
|
|
&& digits != "0000000000"
|
|
&& digits.chars().any(|ch| ch != digits.as_bytes()[0] as char)
|
|
}
|
|
|
|
fn validate_checksum(kind: &str, value: &str) -> bool {
|
|
match kind {
|
|
"inn" => validate_inn(value),
|
|
"snils" => validate_snils(value),
|
|
"passport" => validate_passport(value),
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
fn char_offset(text: &str, byte_offset: usize) -> usize {
|
|
text[..byte_offset].chars().count()
|
|
}
|
|
|
|
fn load_json(path: &Path) -> Result<Value> {
|
|
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
|
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
|
|
}
|
|
|
|
fn resolve_pack(base_dir: &Path, kind: &str, name: Option<&str>) -> Option<PathBuf> {
|
|
let name = name?;
|
|
let path = base_dir.join(kind).join(format!("{name}.json"));
|
|
path.exists().then_some(path)
|
|
}
|
|
|
|
fn match_dictionary(text: &str, path: Option<&Path>) -> Result<Vec<MatchResult>> {
|
|
let Some(path) = path else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let rules = load_json(path)?;
|
|
let Some(rules) = rules.as_object() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let mut results = Vec::new();
|
|
for (name, rule) in rules {
|
|
let Some(regex) = rule.get("regex").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
let regex =
|
|
Regex::new(regex).with_context(|| format!("compile dictionary regex {name}"))?;
|
|
let checksum = rule
|
|
.get("checksum")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("none");
|
|
let description = rule
|
|
.get("description")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(name)
|
|
.to_string();
|
|
for capture in regex.find_iter(text) {
|
|
let value = capture.as_str();
|
|
if validate_checksum(checksum, value) {
|
|
results.push(MatchResult {
|
|
name: name.to_string(),
|
|
description: description.clone(),
|
|
value: value.to_string(),
|
|
start: char_offset(text, capture.start()),
|
|
end: char_offset(text, capture.end()),
|
|
severity: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
results.sort_by_key(|result| (result.start, result.end, result.name.clone()));
|
|
Ok(results)
|
|
}
|
|
|
|
fn regex_entries(pack: &Value) -> Vec<(String, String, String, String)> {
|
|
let mut entries = Vec::new();
|
|
if let Some(rules) = pack.get("rules").and_then(Value::as_array) {
|
|
for rule in rules.iter().filter_map(Value::as_object) {
|
|
let Some(regex) = rule.get("regex").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
let id = rule
|
|
.get("id")
|
|
.or_else(|| rule.get("name"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("regex-rule");
|
|
let description = rule
|
|
.get("description")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(id);
|
|
let severity = rule
|
|
.get("severity")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("medium");
|
|
entries.push((
|
|
id.to_string(),
|
|
description.to_string(),
|
|
regex.to_string(),
|
|
severity.to_string(),
|
|
));
|
|
}
|
|
} else if let Some(patterns) = pack.get("patterns").and_then(Value::as_object) {
|
|
for (id, rule) in patterns {
|
|
let Some(rule) = rule.as_object() else {
|
|
continue;
|
|
};
|
|
let Some(regex) = rule.get("regex").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
let description = rule
|
|
.get("description")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(id);
|
|
let severity = rule
|
|
.get("severity")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("medium");
|
|
entries.push((
|
|
id.to_string(),
|
|
description.to_string(),
|
|
regex.to_string(),
|
|
severity.to_string(),
|
|
));
|
|
}
|
|
}
|
|
entries
|
|
}
|
|
|
|
fn match_regex_pack(text: &str, path: Option<&Path>) -> Result<Vec<MatchResult>> {
|
|
let Some(path) = path else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let pack = load_json(path)?;
|
|
let mut results = Vec::new();
|
|
for (name, description, regex, severity) in regex_entries(&pack) {
|
|
let regex =
|
|
Regex::new(®ex).with_context(|| format!("compile regex pack rule {name}"))?;
|
|
for capture in regex.find_iter(text) {
|
|
results.push(MatchResult {
|
|
name: name.clone(),
|
|
description: description.clone(),
|
|
value: capture.as_str().to_string(),
|
|
start: char_offset(text, capture.start()),
|
|
end: char_offset(text, capture.end()),
|
|
severity: Some(severity.clone()),
|
|
});
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
|
|
fn analyze_text(cli: &Cli) -> Result<Analysis> {
|
|
let text = cli.text.clone().unwrap_or_default();
|
|
let dictionary_path = resolve_pack(
|
|
&cli.base_dir,
|
|
"dictionaries",
|
|
cli.dictionary_pack.as_deref(),
|
|
);
|
|
let regex_pack_path = resolve_pack(&cli.base_dir, "regex-packs", cli.regex_pack.as_deref());
|
|
Ok(Analysis {
|
|
dictionary_matches: match_dictionary(&text, dictionary_path.as_deref())?,
|
|
regex_matches: match_regex_pack(&text, regex_pack_path.as_deref())?,
|
|
text,
|
|
dictionary_pack: cli.dictionary_pack.clone(),
|
|
regex_pack: cli.regex_pack.clone(),
|
|
source: "text".to_string(),
|
|
})
|
|
}
|
|
|
|
fn run_legacy_image(cli: &Cli, image: &Path) -> Result<Value> {
|
|
if !cli.legacy_python.exists() || !cli.legacy_analyzer.exists() {
|
|
bail!(
|
|
"image OCR requires legacy analyzer: {} {}",
|
|
cli.legacy_python.display(),
|
|
cli.legacy_analyzer.display()
|
|
);
|
|
}
|
|
let mut command = Command::new(&cli.legacy_python);
|
|
command.arg(&cli.legacy_analyzer).arg("--image").arg(image);
|
|
if let Some(pack) = &cli.dictionary_pack {
|
|
command.arg("--dictionary-pack").arg(pack);
|
|
}
|
|
if let Some(pack) = &cli.regex_pack {
|
|
command.arg("--regex-pack").arg(pack);
|
|
}
|
|
let output = command.output().context("run legacy image analyzer")?;
|
|
if !output.status.success() {
|
|
bail!(
|
|
"legacy image analyzer failed rc={:?}: {}{}",
|
|
output.status.code(),
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
serde_json::from_slice(&output.stdout).context("parse legacy image analyzer JSON")
|
|
}
|
|
|
|
fn run(cli: &Cli) -> Result<Value> {
|
|
if let Some(image) = &cli.image {
|
|
return run_legacy_image(cli, image);
|
|
}
|
|
Ok(serde_json::to_value(analyze_text(cli)?)?)
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
let value = run(&cli)?;
|
|
println!("{}", serde_json::to_string_pretty(&value)?);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn validates_inn_like_python() {
|
|
assert!(validate_inn("7707083893"));
|
|
assert!(validate_inn("500100732259"));
|
|
assert!(!validate_inn("7707083894"));
|
|
}
|
|
|
|
#[test]
|
|
fn validates_snils_like_python() {
|
|
assert!(validate_snils("112-233-445 95"));
|
|
assert!(!validate_snils("112-233-445 96"));
|
|
}
|
|
|
|
#[test]
|
|
fn validates_passport_like_python() {
|
|
assert!(validate_passport("1234 567890"));
|
|
assert!(!validate_passport("0000 000000"));
|
|
assert!(!validate_passport("1111 111111"));
|
|
}
|
|
|
|
#[test]
|
|
fn regex_pack_patterns_shape_is_supported() {
|
|
let pack = serde_json::json!({"patterns": {"secret": {"regex": "token", "description": "Token", "severity": "high"}}});
|
|
assert_eq!(
|
|
regex_entries(&pack),
|
|
vec![(
|
|
"secret".to_string(),
|
|
"Token".to_string(),
|
|
"token".to_string(),
|
|
"high".to_string()
|
|
)]
|
|
);
|
|
}
|
|
}
|