use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
pub fn now_utc() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| format!("{:?}", std::time::SystemTime::now()))
}
pub fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
fn stable_id(prefix: &str, key: &str) -> String {
sha256_hex(&format!("{}:{}", prefix, key))
}
pub const ASSET_ACTIVE: &str = "active";
pub const ASSET_REMOVED: &str = "removed";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Asset {
pub stable_id: String,
pub asset_type: String,
pub name: String,
pub ip: Option<String>,
pub fqdn: Option<String>,
pub reverse_dns: Option<String>,
pub first_seen: String,
pub last_seen: String,
pub status: String,
#[serde(default)]
pub metadata: Value,
}
impl Asset {
pub fn new(name: &str, asset_type: &str) -> Self {
let mut asset = Self {
stable_id: stable_id("asset", name),
asset_type: asset_type.to_string(),
name: name.to_string(),
..Default::default()
};
asset.metadata = Value::Object(Default::default());
let now = now_utc();
asset.first_seen = now.clone();
asset.last_seen = now;
asset.status = ASSET_ACTIVE.to_string();
asset
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Service {
pub stable_id: String,
pub asset_id: String,
pub port: u16,
pub protocol: String,
pub service_name: String,
pub product: Option<String>,
pub version: Option<String>,
pub version_str: Option<String>,
pub banner: Option<String>,
#[serde(default)]
pub technologies: Vec<String>,
#[serde(default)]
pub cpes: Vec<String>,
pub first_seen: String,
pub last_seen: String,
pub status: String,
#[serde(default)]
pub metadata: Value,
}
impl Service {
pub fn new(asset_id: &str, port: u16, protocol: &str, service_name: &str) -> Self {
let key = format!("{}:{}:{}:{}", asset_id, port, protocol, service_name);
let mut service = Self {
stable_id: stable_id("service", &key),
asset_id: asset_id.to_string(),
port,
protocol: protocol.to_string(),
service_name: service_name.to_string(),
..Default::default()
};
service.metadata = Value::Object(Default::default());
let now = now_utc();
service.first_seen = now.clone();
service.last_seen = now;
service.status = ASSET_ACTIVE.to_string();
service
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Finding {
pub stable_id: String,
pub asset_id: String,
pub service_id: Option<String>,
pub title: String,
pub severity: String,
pub tool: String,
pub target_url: Option<String>,
pub detail: Option<String>,
pub detail_path: Option<String>,
pub reference: Option<String>,
#[serde(default)]
pub cves: Vec<String>,
#[serde(default)]
pub endpoints: Vec<String>,
#[serde(default)]
pub technologies: Vec<String>,
pub first_seen: String,
pub last_seen: String,
pub status: String,
pub exploitability: Option<String>,
#[serde(default)]
pub metadata: Value,
}
impl Finding {
pub fn new(asset_id: &str, tool: &str, title: &str) -> Self {
let key = format!("{}:{}:{}", asset_id, tool, stable_title_identity(title));
let mut finding = Self {
stable_id: stable_id("finding", &key),
asset_id: asset_id.to_string(),
tool: tool.to_string(),
title: title.to_string(),
..Default::default()
};
finding.metadata = Value::Object(Default::default());
let now = now_utc();
finding.first_seen = now.clone();
finding.last_seen = now;
finding.status = ASSET_ACTIVE.to_string();
finding
}
}
fn stable_title_identity(title: &str) -> String {
let collapsed = title.split_whitespace().collect::<Vec<_>>().join(" ");
let parts: Vec<&str> = collapsed
.split('|')
.map(str::trim)
.filter(|part| !part.is_empty())
.collect();
let joined = parts.join("|");
joined
.trim_end_matches(|c: char| ".,:;\\/|".contains(c))
.trim()
.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Observation {
pub stable_id: String,
pub run_id: String,
pub subject_type: String,
pub subject_id: String,
pub kind: String,
#[serde(default)]
pub before: Value,
#[serde(default)]
pub after: Value,
pub detail: String,
pub observed_at: String,
}
impl Observation {
pub fn new(
run_id: &str,
subject_type: &str,
subject_id: &str,
kind: &str,
before: Value,
after: Value,
detail: &str,
) -> Self {
let before_s = before.to_string();
let after_s = after.to_string();
let key = format!(
"{}:{}:{}:{}:{}:{}",
run_id, subject_type, subject_id, kind, before_s, after_s
);
Self {
stable_id: stable_id("observation", &key),
run_id: run_id.to_string(),
subject_type: subject_type.to_string(),
subject_id: subject_id.to_string(),
kind: kind.to_string(),
before,
after,
detail: detail.to_string(),
observed_at: now_utc(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScanRun {
pub stable_id: String,
pub target: String,
pub folder: String,
pub started_at: Option<String>,
pub finished_at: Option<String>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub artifacts: Vec<Value>,
pub content_hash: String,
pub imported_at: String,
#[serde(default)]
pub stats: Value,
}
impl ScanRun {
pub fn new(
run_id: &str,
target: &str,
folder: &str,
content_hash: &str,
started_at: Option<String>,
) -> Self {
Self {
stable_id: run_id.to_string(),
target: target.to_string(),
folder: folder.to_string(),
started_at,
finished_at: None,
content_hash: content_hash.to_string(),
imported_at: now_utc(),
stats: Value::Object(Default::default()),
..Default::default()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExploitExecution {
pub execution_id: String,
pub run_id: String,
pub asset_id: String,
pub vulnerability_id: String,
pub module_type: String,
pub module: String,
pub host: String,
pub payload: Option<String>,
pub status: String,
pub start_time: String,
pub finish_time: Option<String>,
pub job_id: Option<String>,
pub session_id: Option<String>,
pub error: Option<String>,
#[serde(default)]
pub loot: Vec<String>,
#[serde(default)]
pub options: BTreeMap<String, String>,
pub selected: bool,
pub created_at: String,
}
impl ExploitExecution {
pub fn new(run_id: &str, asset_id: &str, vulnerability_id: &str, module: &str) -> Self {
let key = format!("{}:{}:{}:{}", run_id, asset_id, vulnerability_id, module);
let now = now_utc();
Self {
execution_id: sha256_hex(&format!("exploit_exec:{}:{}", key, now)),
run_id: run_id.to_string(),
asset_id: asset_id.to_string(),
vulnerability_id: vulnerability_id.to_string(),
module: module.to_string(),
module_type: String::new(),
host: String::new(),
payload: None,
status: String::new(),
start_time: now.clone(),
finish_time: None,
job_id: None,
session_id: None,
error: None,
loot: Vec::new(),
options: BTreeMap::new(),
selected: false,
created_at: now,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finding_identity_is_stable_across_framing() {
let a = Finding::new("asset", "nmap", "Apache Version /");
let b = Finding::new("asset", "nmap", "Apache Version | ");
assert_eq!(a.stable_id, b.stable_id);
let c = Finding::new("asset", "nmap", "Apache Version");
assert_eq!(a.stable_id, c.stable_id);
}
#[test]
fn finding_identity_keeps_distinct_titles_separate() {
let a = Finding::new("asset", "nmap", "Apache 2.4");
let b = Finding::new("asset", "nmap", "Apache 2.2");
assert_ne!(a.stable_id, b.stable_id);
}
#[test]
fn finding_identity_is_scoped_by_tool_and_asset() {
let a = Finding::new("asset", "nmap", "Apache");
let b = Finding::new("asset", "nikto", "Apache");
let c = Finding::new("asset2", "nmap", "Apache");
assert_ne!(a.stable_id, b.stable_id);
assert_ne!(a.stable_id, c.stable_id);
}
}