use blake3::Hasher;
use rand::Rng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tracing::{debug, error, info};
pub type ScanError = SigningError;
const API_BASE: &str = "https://lonkero.bountyy.fi/api/v1";
const REQUEST_TIMEOUT_SECS: u64 = 30;
const TOKEN_VALIDITY_SECS: u64 = 6 * 60 * 60;
static GLOBAL_SCAN_TOKEN: OnceLock<ScanToken> = OnceLock::new();
#[derive(Debug, Clone, Error)]
pub enum SigningError {
#[error("Not authorized. Call authorize_scan() first.")]
NotAuthorized,
#[error("Authorization expired. Re-authorize to continue.")]
AuthorizationExpired,
#[error("BANNED: {0}")]
Banned(String),
#[error("License error: {0}")]
LicenseError(String),
#[error("Server unreachable: {0}")]
ServerUnreachable(String),
#[error("Server error: {0}")]
ServerError(String),
#[error("Invalid response: {0}")]
InvalidResponse(String),
}
#[derive(Debug, Clone, Serialize)]
struct ScanAuthorizeRequest {
targets_count: u32,
hardware_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
license_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
scanner_version: Option<String>,
modules: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeniedModuleInfo {
pub module: String,
pub reason: String,
}
#[derive(Debug, Clone, Deserialize)]
struct ScanAuthorizeResponse {
authorized: bool,
scan_token: Option<String>,
token_expires_at: Option<String>,
max_targets: Option<u32>,
license_type: Option<String>,
licensee: Option<String>,
organization: Option<String>,
authorized_modules: Option<Vec<String>>,
denied_modules: Option<Vec<DeniedModuleInfo>>,
error: Option<String>,
ban_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct SignRequest {
results_hash: String,
scan_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
license_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
hardware_id: Option<String>,
timestamp: u64,
nonce: String,
modules_used: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
scan_metadata: Option<ScanMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
findings_summary: Option<FindingsSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
target_hashes: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
struct SignResponse {
valid: bool,
signature: Option<String>,
signed_at: Option<String>,
license_type: Option<String>,
algorithm: Option<String>,
error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanToken {
pub token: String,
pub expires_at: String,
pub max_targets: u32,
pub license_type: String,
pub authorized_modules: Vec<String>,
}
impl ScanToken {
pub fn is_valid(&self) -> bool {
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&self.expires_at) {
let now = chrono::Utc::now();
return now < expires;
}
false
}
pub fn is_module_authorized(&self, module_id: &str) -> bool {
self.authorized_modules.iter().any(|m| m == module_id)
}
pub fn filter_modules<'a>(&self, requested: &[&'a str]) -> (Vec<&'a str>, Vec<&'a str>) {
let mut approved = Vec::new();
let mut denied = Vec::new();
for module in requested {
if self.is_module_authorized(module) {
approved.push(*module);
} else {
denied.push(*module);
}
}
(approved, denied)
}
pub fn get_denied_modules(&self, requested: &[String]) -> Vec<String> {
requested
.iter()
.filter(|m| !self.is_module_authorized(m))
.cloned()
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportSignature {
pub signature: String,
pub algorithm: String,
pub signed_at: String,
pub license_type: String,
pub results_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub targets_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scanner_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scan_duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct FindingsSummary {
pub total: u32,
pub by_severity: SeverityCounts,
pub by_module: HashMap<String, u32>,
}
impl FindingsSummary {
fn normalize_module_name(name: &str) -> String {
name.trim().to_lowercase()
}
pub fn from_vulnerabilities(vulnerabilities: &[crate::types::Vulnerability]) -> Self {
let mut summary = Self::default();
for vuln in vulnerabilities {
summary.total += 1;
summary.by_severity.increment(&vuln.severity);
let module_name = Self::normalize_module_name(&vuln.category);
*summary.by_module.entry(module_name).or_insert(0) += 1;
}
summary
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SeverityCounts {
pub critical: u32,
pub high: u32,
pub medium: u32,
pub low: u32,
pub info: u32,
}
impl SeverityCounts {
pub fn increment(&mut self, severity: &crate::types::Severity) {
match severity {
crate::types::Severity::Critical => self.critical += 1,
crate::types::Severity::High => self.high += 1,
crate::types::Severity::Medium => self.medium += 1,
crate::types::Severity::Low => self.low += 1,
crate::types::Severity::Info => self.info += 1,
}
}
}
pub fn generate_nonce() -> String {
let mut rng = rand::rng();
let bytes: [u8; 16] = rng.random();
hex::encode(bytes)
}
pub fn hash_results<T: Serialize>(results: &T) -> Result<String, SigningError> {
let json = serde_json::to_string(results).map_err(|e| {
SigningError::InvalidResponse(format!("Failed to serialize results: {}", e))
})?;
let mut hasher = Hasher::new();
hasher.update(json.as_bytes());
Ok(hasher.finalize().to_hex().to_string())
}
#[inline]
pub fn is_authorized() -> bool {
match GLOBAL_SCAN_TOKEN.get() {
Some(token) => token.is_valid(),
None => false,
}
}
pub fn get_scan_token() -> Option<&'static ScanToken> {
GLOBAL_SCAN_TOKEN.get().filter(|t| t.is_valid())
}
#[inline]
pub fn is_scan_authorized() -> bool {
is_authorized()
}
pub fn get_hardware_id() -> String {
#[cfg(target_os = "linux")]
{
if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
let mut hasher = Sha256::new();
hasher.update(id.trim().as_bytes());
hasher.update(b"lonkero-signing-v1");
return hex::encode(hasher.finalize())[..32].to_string();
}
}
#[cfg(target_os = "macos")]
{
if let Ok(output) = std::process::Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
{
let output_str = String::from_utf8_lossy(&output.stdout);
if let Some(uuid_line) = output_str.lines().find(|l| l.contains("IOPlatformUUID")) {
let mut hasher = Sha256::new();
hasher.update(uuid_line.as_bytes());
hasher.update(b"lonkero-signing-v1");
return hex::encode(hasher.finalize())[..32].to_string();
}
}
}
#[cfg(target_os = "windows")]
{
if let Ok(output) = std::process::Command::new("wmic")
.args(["csproduct", "get", "uuid"])
.output()
{
let output_str = String::from_utf8_lossy(&output.stdout);
if let Some(uuid_line) = output_str.lines().nth(1) {
let mut hasher = Sha256::new();
hasher.update(uuid_line.trim().as_bytes());
hasher.update(b"lonkero-signing-v1");
return hex::encode(hasher.finalize())[..32].to_string();
}
}
}
let hostname = hostname::get()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let mut hasher = Sha256::new();
hasher.update(hostname.as_bytes());
hasher.update(b"lonkero-fallback-v1");
hex::encode(hasher.finalize())[..32].to_string()
}
pub async fn authorize_scan(
targets_count: u32,
hardware_id: &str,
license_key: Option<&str>,
scanner_version: Option<&str>,
modules: Vec<String>,
) -> Result<ScanToken, SigningError> {
debug!(
"Authorizing scan for {} targets with {} modules",
targets_count,
modules.len()
);
let request = ScanAuthorizeRequest {
targets_count,
hardware_id: hardware_id.to_string(),
license_key: license_key.map(String::from),
scanner_version: scanner_version.map(String::from),
modules,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.user_agent(format!("Lonkero/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| SigningError::ServerUnreachable(e.to_string()))?;
let response = client
.post(format!("{}/scan/authorize", API_BASE))
.json(&request)
.send()
.await
.map_err(|e| {
error!("Authorization server unreachable: {}", e);
SigningError::ServerUnreachable(e.to_string())
})?;
let status = response.status();
let auth_response: ScanAuthorizeResponse = response.json().await.map_err(|e| {
SigningError::InvalidResponse(format!("Failed to parse authorization response: {}", e))
})?;
if let Some(ban_reason) = auth_response.ban_reason {
error!("SCAN BLOCKED: User is banned - {}", ban_reason);
return Err(SigningError::Banned(ban_reason));
}
if let Some(ref denied) = auth_response.denied_modules {
if denied.len() > 0 {
debug!(
"[Auth] {} modules denied (requires license upgrade)",
denied.len()
);
for d in denied {
debug!("[Auth] Module '{}' denied: {}", d.module, d.reason);
}
}
}
if !auth_response.authorized {
let error_msg = auth_response
.error
.unwrap_or_else(|| "Authorization denied".to_string());
if status.as_u16() == 403 || error_msg.to_lowercase().contains("license") {
return Err(SigningError::LicenseError(error_msg));
}
return Err(SigningError::ServerError(error_msg));
}
let token_str = auth_response.scan_token.ok_or_else(|| {
SigningError::InvalidResponse("Missing scan_token in response".to_string())
})?;
let expires_at = auth_response.token_expires_at.ok_or_else(|| {
SigningError::InvalidResponse("Missing token_expires_at in response".to_string())
})?;
let max_targets = auth_response.max_targets.unwrap_or(100);
let license_type = auth_response
.license_type
.unwrap_or_else(|| "Personal".to_string());
let authorized_modules = auth_response.authorized_modules.unwrap_or_default();
let licensee = auth_response.licensee;
let organization = auth_response.organization;
info!(
"[Auth] Authorized: {} license, max {} targets, {} modules{}",
license_type,
max_targets,
authorized_modules.len(),
licensee.as_ref().map(|l| format!(", licensee: {}", l)).unwrap_or_default()
);
let token = ScanToken {
token: token_str,
expires_at,
max_targets,
license_type: license_type.clone(),
authorized_modules,
};
let _ = GLOBAL_SCAN_TOKEN.set(token.clone());
let _ = LICENSE_HOLDER_INFO.set((licensee, organization));
Ok(token)
}
static LICENSE_HOLDER_INFO: std::sync::OnceLock<(Option<String>, Option<String>)> = std::sync::OnceLock::new();
pub fn get_license_holder() -> Option<String> {
LICENSE_HOLDER_INFO.get().and_then(|(licensee, org)| {
licensee.clone().or_else(|| org.clone())
})
}
pub async fn sign_results(
results_hash: &str,
scan_token: &ScanToken,
modules_used: Vec<String>,
metadata: Option<ScanMetadata>,
findings_summary: Option<FindingsSummary>,
targets: Option<Vec<String>>,
) -> Result<ReportSignature, SigningError> {
if !is_valid_blake3_hash(results_hash) {
return Err(SigningError::InvalidResponse(
"Invalid results_hash: must be 64 lowercase hex characters".to_string(),
));
}
if !scan_token.is_valid() {
return Err(SigningError::AuthorizationExpired);
}
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| SigningError::ServerError("System time error".to_string()))?
.as_millis() as u64;
let nonce = generate_nonce();
debug!(
"[Sign] Signing with {} modules used, findings_summary: {}",
modules_used.len(),
findings_summary.as_ref().map(|f| f.total).unwrap_or(0)
);
let target_hashes = targets.map(|urls| {
urls.iter()
.map(|url| {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
format!("{:x}", hasher.finalize())
})
.collect::<Vec<String>>()
});
let request = SignRequest {
results_hash: results_hash.to_string(),
scan_token: scan_token.token.clone(),
license_key: None,
hardware_id: None,
timestamp,
nonce,
modules_used,
scan_metadata: metadata,
findings_summary,
target_hashes,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.user_agent(format!("Lonkero/{}", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| SigningError::ServerUnreachable(e.to_string()))?;
let response = client
.post(format!("{}/sign", API_BASE))
.json(&request)
.send()
.await
.map_err(|e| {
error!("Signing server unreachable: {}", e);
SigningError::ServerUnreachable(e.to_string())
})?;
let sign_response: SignResponse = response.json().await.map_err(|e| {
SigningError::InvalidResponse(format!("Failed to parse sign response: {}", e))
})?;
if !sign_response.valid {
let error_msg = sign_response
.error
.unwrap_or_else(|| "Signing failed".to_string());
return Err(SigningError::ServerError(error_msg));
}
let signature = sign_response.signature.ok_or_else(|| {
SigningError::InvalidResponse("Missing signature in response".to_string())
})?;
let signed_at = sign_response.signed_at.ok_or_else(|| {
SigningError::InvalidResponse("Missing signed_at in response".to_string())
})?;
let algorithm = sign_response
.algorithm
.unwrap_or_else(|| "HMAC-SHA512".to_string());
let license_type = sign_response
.license_type
.unwrap_or_else(|| scan_token.license_type.clone());
info!("Results signed successfully with {}", algorithm);
Ok(ReportSignature {
signature,
algorithm,
signed_at,
license_type,
results_hash: results_hash.to_string(),
})
}
fn is_valid_blake3_hash(hash: &str) -> bool {
if hash.len() != 64 {
return false;
}
hash.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_nonce() {
let nonce1 = generate_nonce();
let nonce2 = generate_nonce();
assert_ne!(nonce1, nonce2);
assert!(nonce1.len() >= 16);
assert_eq!(nonce1.len(), 32);
assert!(nonce1.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_hash_results() {
#[derive(Serialize)]
struct TestData {
value: String,
}
let data = TestData {
value: "test".to_string(),
};
let hash = hash_results(&data).unwrap();
assert_eq!(hash.len(), 64);
assert!(hash
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
let hash2 = hash_results(&data).unwrap();
assert_eq!(hash, hash2);
}
#[test]
fn test_is_valid_blake3_hash() {
let valid = "a".repeat(64);
assert!(is_valid_blake3_hash(&valid));
assert!(!is_valid_blake3_hash("abc123"));
let too_long = "a".repeat(65);
assert!(!is_valid_blake3_hash(&too_long));
let uppercase = "A".repeat(64);
assert!(!is_valid_blake3_hash(&uppercase));
let invalid_chars = "g".repeat(64);
assert!(!is_valid_blake3_hash(&invalid_chars));
}
#[test]
fn test_scan_token_validity() {
let future = chrono::Utc::now() + chrono::Duration::hours(1);
let valid_token = ScanToken {
token: "test_token".to_string(),
expires_at: future.to_rfc3339(),
max_targets: 100,
license_type: "Personal".to_string(),
authorized_modules: vec!["sqli_scanner".to_string(), "xss_scanner".to_string()],
};
assert!(valid_token.is_valid());
assert!(valid_token.is_module_authorized("sqli_scanner"));
assert!(!valid_token.is_module_authorized("wordpress_scanner"));
let past = chrono::Utc::now() - chrono::Duration::hours(1);
let expired_token = ScanToken {
token: "test_token".to_string(),
expires_at: past.to_rfc3339(),
max_targets: 100,
license_type: "Personal".to_string(),
authorized_modules: vec![],
};
assert!(!expired_token.is_valid());
let invalid_token = ScanToken {
token: "test_token".to_string(),
expires_at: "invalid".to_string(),
max_targets: 100,
license_type: "Personal".to_string(),
authorized_modules: vec![],
};
assert!(!invalid_token.is_valid());
}
#[test]
fn test_signing_error_display() {
let err = SigningError::Banned("Test ban reason".to_string());
assert!(err.to_string().contains("BANNED"));
assert!(err.to_string().contains("Test ban reason"));
let err = SigningError::NotAuthorized;
assert!(err.to_string().contains("authorize_scan"));
let err = SigningError::AuthorizationExpired;
assert!(err.to_string().contains("expired"));
let err = SigningError::ServerUnreachable("connection refused".to_string());
assert!(err.to_string().contains("unreachable"));
}
#[test]
fn test_timestamp_is_milliseconds() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
assert!(now > 1_000_000_000_000); }
#[test]
fn test_no_local_or_offline_tokens() {
let nonce = generate_nonce();
assert!(!nonce.starts_with("local_"));
assert!(!nonce.starts_with("offline_"));
}
#[test]
fn test_severity_counts_new() {
let counts = SeverityCounts::default();
assert_eq!(counts.critical, 0);
assert_eq!(counts.high, 0);
assert_eq!(counts.medium, 0);
assert_eq!(counts.low, 0);
assert_eq!(counts.info, 0);
}
#[test]
fn test_severity_counts_increment() {
let mut counts = SeverityCounts::default();
counts.increment(&crate::types::Severity::Critical);
counts.increment(&crate::types::Severity::Critical);
counts.increment(&crate::types::Severity::High);
counts.increment(&crate::types::Severity::Medium);
counts.increment(&crate::types::Severity::Medium);
counts.increment(&crate::types::Severity::Medium);
counts.increment(&crate::types::Severity::Low);
counts.increment(&crate::types::Severity::Info);
counts.increment(&crate::types::Severity::Info);
assert_eq!(counts.critical, 2);
assert_eq!(counts.high, 1);
assert_eq!(counts.medium, 3);
assert_eq!(counts.low, 1);
assert_eq!(counts.info, 2);
}
#[test]
fn test_findings_summary_new() {
let summary = FindingsSummary::default();
assert_eq!(summary.total, 0);
assert_eq!(summary.by_severity.critical, 0);
assert_eq!(summary.by_severity.high, 0);
assert_eq!(summary.by_severity.medium, 0);
assert_eq!(summary.by_severity.low, 0);
assert_eq!(summary.by_severity.info, 0);
assert!(summary.by_module.is_empty());
}
#[test]
fn test_findings_summary_from_vulnerabilities() {
use crate::types::{Confidence, Severity, Vulnerability};
let vulns = vec![
Vulnerability {
id: "1".to_string(),
vuln_type: "xss".to_string(),
severity: Severity::Critical,
confidence: Confidence::High,
category: "XSS".to_string(),
url: "https://example.com/page1".to_string(),
parameter: Some("q".to_string()),
payload: "<script>alert(1)</script>".to_string(),
description: "XSS vulnerability".to_string(),
evidence: None,
cwe: "CWE-79".to_string(),
cvss: 8.0,
verified: true,
false_positive: false,
remediation: "Sanitize input".to_string(),
discovered_at: "2024-01-01T00:00:00Z".to_string(),
ml_confidence: None,
ml_data: None,
},
Vulnerability {
id: "2".to_string(),
vuln_type: "sqli".to_string(),
severity: Severity::High,
confidence: Confidence::Medium,
category: "SQLi".to_string(),
url: "https://example.com/page2".to_string(),
parameter: Some("id".to_string()),
payload: "1' OR '1'='1".to_string(),
description: "SQL Injection".to_string(),
evidence: None,
cwe: "CWE-89".to_string(),
cvss: 9.0,
verified: false,
false_positive: false,
remediation: "Use parameterized queries".to_string(),
discovered_at: "2024-01-01T00:00:00Z".to_string(),
ml_confidence: None,
ml_data: None,
},
Vulnerability {
id: "3".to_string(),
vuln_type: "xss".to_string(),
severity: Severity::Medium,
confidence: Confidence::Low,
category: "XSS".to_string(),
url: "https://example.com/page3".to_string(),
parameter: None,
payload: "test".to_string(),
description: "Another XSS".to_string(),
evidence: None,
cwe: "CWE-79".to_string(),
cvss: 5.0,
verified: false,
false_positive: false,
remediation: "Sanitize".to_string(),
discovered_at: "2024-01-01T00:00:00Z".to_string(),
ml_confidence: None,
ml_data: None,
},
];
let summary = FindingsSummary::from_vulnerabilities(&vulns);
assert_eq!(summary.total, 3);
assert_eq!(summary.by_severity.critical, 1);
assert_eq!(summary.by_severity.high, 1);
assert_eq!(summary.by_severity.medium, 1);
assert_eq!(summary.by_severity.low, 0);
assert_eq!(summary.by_severity.info, 0);
assert_eq!(summary.by_module.get("xss"), Some(&2));
assert_eq!(summary.by_module.get("sqli"), Some(&1));
let serialized = serde_json::to_string(&summary).unwrap();
assert!(!serialized.contains("example.com"));
assert!(!serialized.contains("page1"));
assert!(!serialized.contains("page2"));
assert!(!serialized.contains("page3"));
}
#[test]
fn test_findings_summary_serialization() {
let mut summary = FindingsSummary::default();
summary.total = 5;
summary.by_severity.critical = 1;
summary.by_severity.high = 2;
summary.by_severity.medium = 1;
summary.by_severity.low = 1;
summary.by_module.insert("xss".to_string(), 3);
summary.by_module.insert("sqli".to_string(), 2);
let json = serde_json::to_string(&summary).unwrap();
assert!(json.contains("\"total\":5"));
assert!(json.contains("\"critical\":1"));
assert!(json.contains("\"high\":2"));
assert!(json.contains("\"by_severity\""));
assert!(json.contains("\"by_module\""));
let deserialized: FindingsSummary = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.total, 5);
assert_eq!(deserialized.by_severity.critical, 1);
assert_eq!(deserialized.by_module.get("xss"), Some(&3));
}
#[test]
fn test_module_name_normalization() {
use crate::types::{Confidence, Severity, Vulnerability};
let vulns = vec![
Vulnerability {
id: "1".to_string(),
vuln_type: "xss".to_string(),
severity: Severity::High,
confidence: Confidence::High,
category: "XSS".to_string(), url: "https://example.com".to_string(),
parameter: None,
payload: "test".to_string(),
description: "test".to_string(),
evidence: None,
cwe: "CWE-79".to_string(),
cvss: 5.0,
verified: false,
false_positive: false,
remediation: "fix".to_string(),
discovered_at: "2024-01-01T00:00:00Z".to_string(),
ml_confidence: None,
ml_data: None,
},
Vulnerability {
id: "2".to_string(),
vuln_type: "xss".to_string(),
severity: Severity::Medium,
confidence: Confidence::Medium,
category: " xss ".to_string(), url: "https://example.com".to_string(),
parameter: None,
payload: "test".to_string(),
description: "test".to_string(),
evidence: None,
cwe: "CWE-79".to_string(),
cvss: 5.0,
verified: false,
false_positive: false,
remediation: "fix".to_string(),
discovered_at: "2024-01-01T00:00:00Z".to_string(),
ml_confidence: None,
ml_data: None,
},
Vulnerability {
id: "3".to_string(),
vuln_type: "xss".to_string(),
severity: Severity::Low,
confidence: Confidence::Low,
category: "Xss".to_string(), url: "https://example.com".to_string(),
parameter: None,
payload: "test".to_string(),
description: "test".to_string(),
evidence: None,
cwe: "CWE-79".to_string(),
cvss: 5.0,
verified: false,
false_positive: false,
remediation: "fix".to_string(),
discovered_at: "2024-01-01T00:00:00Z".to_string(),
ml_confidence: None,
ml_data: None,
},
];
let summary = FindingsSummary::from_vulnerabilities(&vulns);
assert_eq!(summary.by_module.len(), 1);
assert_eq!(summary.by_module.get("xss"), Some(&3));
}
}