WP-20/21: contract and failing tests for vulnerability management
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -7,6 +7,7 @@ pub mod scheduler;
|
||||
pub mod settings_service;
|
||||
pub mod upgrade_service;
|
||||
pub mod user_service;
|
||||
pub mod vuln_service;
|
||||
|
||||
pub use auth_service::AuthService;
|
||||
pub use cluster_service::ClusterService;
|
||||
@ -15,6 +16,7 @@ pub use jobs::{JobHandler, JobLog, JobRunner};
|
||||
pub use settings_service::SettingsService;
|
||||
pub use upgrade_service::{PackageUpgradeJob, UpgradeParams};
|
||||
pub use user_service::UserService;
|
||||
pub use vuln_service::{VulnerabilityScanJob, VulnerabilityService};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_fakes;
|
||||
|
||||
@ -6,6 +6,8 @@ use domain::settings::{SmtpSettings, KEY_SMTP, SECRET_KEYS};
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::scheduler::validate_cron;
|
||||
use crate::vuln_service::{DEFAULT_NOTIFY_MIN_SEVERITY, KEY_NOTIFY_MIN_SEVERITY};
|
||||
use domain::vuln::Severity;
|
||||
|
||||
pub struct SettingsService {
|
||||
repo: Arc<dyn SettingsRepository>,
|
||||
@ -104,6 +106,20 @@ impl SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsService {
|
||||
pub async fn notify_min_severity(&self) -> Result<Severity, DomainError> {
|
||||
Ok(self
|
||||
.get(KEY_NOTIFY_MIN_SEVERITY)
|
||||
.await?
|
||||
.map(|s| Severity::parse(&s))
|
||||
.unwrap_or(DEFAULT_NOTIFY_MIN_SEVERITY))
|
||||
}
|
||||
|
||||
pub async fn set_notify_min_severity(&self, severity: Severity) -> Result<(), DomainError> {
|
||||
self.set(KEY_NOTIFY_MIN_SEVERITY, severity.as_str()).await
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_key(kind: JobKind) -> String {
|
||||
format!("schedule.{}", kind.as_str())
|
||||
}
|
||||
|
||||
@ -494,3 +494,150 @@ impl ClusterGateway for MemCluster {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
use domain::ports::{FindingRepository, VulnerabilityScanner};
|
||||
use domain::vuln::{
|
||||
Finding, FindingFilter, FindingStatus, RawFinding, Severity, SeverityCounts, TargetKind,
|
||||
};
|
||||
|
||||
pub fn raw(
|
||||
cve: &str,
|
||||
pkg: &str,
|
||||
installed: &str,
|
||||
sev: Severity,
|
||||
fixed: Option<&str>,
|
||||
) -> RawFinding {
|
||||
RawFinding {
|
||||
cve_id: cve.into(),
|
||||
severity: sev,
|
||||
package: pkg.into(),
|
||||
installed_version: installed.into(),
|
||||
fixed_version: fixed.map(String::from),
|
||||
title: format!("{cve} in {pkg}"),
|
||||
url: format!("https://nvd.nist.gov/vuln/detail/{cve}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub type ScanResults = Arc<Mutex<HashMap<String, Result<Vec<RawFinding>, String>>>>;
|
||||
|
||||
/// Scanner returning configurable results per target ("os" or image ref).
|
||||
#[derive(Default)]
|
||||
pub struct FakeScanner {
|
||||
pub results: ScanResults,
|
||||
}
|
||||
|
||||
impl FakeScanner {
|
||||
pub fn with(self, target: &str, r: Result<Vec<RawFinding>, &str>) -> Self {
|
||||
self.results
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(target.into(), r.map_err(String::from));
|
||||
self
|
||||
}
|
||||
fn get(&self, target: &str) -> Result<Vec<RawFinding>, DomainError> {
|
||||
match self.results.lock().unwrap().get(target) {
|
||||
Some(Ok(v)) => Ok(v.clone()),
|
||||
Some(Err(e)) => Err(DomainError::Unavailable(e.clone())),
|
||||
None => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VulnerabilityScanner for FakeScanner {
|
||||
async fn version(&self) -> Result<String, DomainError> {
|
||||
Ok("fake 0.1".into())
|
||||
}
|
||||
async fn scan_os(
|
||||
&self,
|
||||
out: &dyn domain::ports::LineSink,
|
||||
) -> Result<Vec<RawFinding>, DomainError> {
|
||||
out.line("scanning os");
|
||||
self.get("os")
|
||||
}
|
||||
async fn scan_image(
|
||||
&self,
|
||||
image: &str,
|
||||
out: &dyn domain::ports::LineSink,
|
||||
) -> Result<Vec<RawFinding>, DomainError> {
|
||||
out.line(&format!("scanning {image}"));
|
||||
self.get(image)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemFindings(pub Mutex<Vec<Finding>>);
|
||||
|
||||
#[async_trait]
|
||||
impl FindingRepository for MemFindings {
|
||||
async fn active_by_target(&self, target: &str) -> Result<Vec<Finding>, DomainError> {
|
||||
Ok(self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|f| f.target == target && f.status != FindingStatus::Fixed)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
async fn insert(&self, finding: &Finding) -> Result<(), DomainError> {
|
||||
self.0.lock().unwrap().push(finding.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn touch(
|
||||
&self,
|
||||
ids: &[Uuid],
|
||||
last_seen: chrono::DateTime<Utc>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.filter(|f| ids.contains(&f.id))
|
||||
.for_each(|f| f.last_seen = last_seen);
|
||||
Ok(())
|
||||
}
|
||||
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
let f = v
|
||||
.iter_mut()
|
||||
.find(|f| f.id == id)
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
f.status = status;
|
||||
Ok(())
|
||||
}
|
||||
async fn get(&self, id: Uuid) -> Result<Option<Finding>, DomainError> {
|
||||
Ok(self.0.lock().unwrap().iter().find(|f| f.id == id).cloned())
|
||||
}
|
||||
async fn list(&self, filter: &FindingFilter) -> Result<Vec<Finding>, DomainError> {
|
||||
let mut v: Vec<Finding> = self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|f| filter.include_fixed || f.status != FindingStatus::Fixed)
|
||||
.filter(|f| filter.min_severity.is_none_or(|m| f.raw.severity >= m))
|
||||
.filter(|f| filter.target.as_ref().is_none_or(|t| &f.target == t))
|
||||
.filter(|f| filter.status.is_none_or(|s| f.status == s))
|
||||
.cloned()
|
||||
.collect();
|
||||
v.sort_by(|a, b| {
|
||||
b.raw
|
||||
.severity
|
||||
.cmp(&a.raw.severity)
|
||||
.then(a.raw.cve_id.cmp(&b.raw.cve_id))
|
||||
});
|
||||
Ok(v)
|
||||
}
|
||||
async fn counts(&self, kind: Option<TargetKind>) -> Result<SeverityCounts, DomainError> {
|
||||
let mut c = SeverityCounts::default();
|
||||
for f in
|
||||
self.0.lock().unwrap().iter().filter(|f| {
|
||||
f.status != FindingStatus::Fixed && kind.is_none_or(|k| f.target_kind == k)
|
||||
})
|
||||
{
|
||||
c.add(f.raw.severity);
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,3 +6,4 @@ mod scheduler_tests;
|
||||
mod settings_tests;
|
||||
mod upgrade_tests;
|
||||
mod user_service_tests;
|
||||
mod vuln_tests;
|
||||
|
||||
278
backend/crates/application/src/tests/vuln_tests.rs
Normal file
278
backend/crates/application/src/tests/vuln_tests.rs
Normal file
@ -0,0 +1,278 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::vuln::{FindingFilter, FindingStatus, Severity, TargetKind};
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::jobs::JobLog;
|
||||
use crate::test_fakes::{
|
||||
raw, smtp, FakeCipher, FakeScanner, MemCluster, MemFindings, MemMailer, MemSettings,
|
||||
ScanResults,
|
||||
};
|
||||
use crate::{SettingsService, VulnerabilityService};
|
||||
|
||||
#[derive(Default)]
|
||||
struct VecLog(Mutex<Vec<String>>);
|
||||
#[async_trait]
|
||||
impl JobLog for VecLog {
|
||||
async fn line(&self, text: &str) {
|
||||
self.0.lock().unwrap().push(text.into());
|
||||
}
|
||||
}
|
||||
|
||||
struct F {
|
||||
findings: Arc<MemFindings>,
|
||||
mailer: Arc<MemMailer>,
|
||||
settings: Arc<SettingsService>,
|
||||
results: ScanResults,
|
||||
}
|
||||
|
||||
fn fixture(scanner: FakeScanner) -> (F, VulnerabilityService) {
|
||||
let findings = Arc::new(MemFindings::default());
|
||||
let mailer = Arc::new(MemMailer::default());
|
||||
let results = scanner.results.clone();
|
||||
let settings = Arc::new(SettingsService::new(
|
||||
Arc::new(MemSettings::default()),
|
||||
Arc::new(FakeCipher),
|
||||
mailer.clone(),
|
||||
));
|
||||
let svc = VulnerabilityService::new(
|
||||
Arc::new(scanner),
|
||||
findings.clone(),
|
||||
Arc::new(MemCluster::default()),
|
||||
settings.clone(),
|
||||
);
|
||||
(
|
||||
F {
|
||||
findings,
|
||||
mailer,
|
||||
settings,
|
||||
results,
|
||||
},
|
||||
svc,
|
||||
)
|
||||
}
|
||||
|
||||
const GITEA: &str = "gitea/gitea:1.22.3";
|
||||
const PG: &str = "bitnami/postgresql:16.4.0";
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_scan_inserts_findings_for_os_and_all_cluster_images() {
|
||||
let scanner = FakeScanner::default()
|
||||
.with(
|
||||
"os",
|
||||
Ok(vec![raw(
|
||||
"CVE-1",
|
||||
"openssl",
|
||||
"3.0.1",
|
||||
Severity::High,
|
||||
Some("3.0.2"),
|
||||
)]),
|
||||
)
|
||||
.with(
|
||||
GITEA,
|
||||
Ok(vec![
|
||||
raw("CVE-2", "git", "2.39", Severity::Critical, None),
|
||||
raw("CVE-3", "curl", "7.88", Severity::Low, None),
|
||||
]),
|
||||
);
|
||||
let (f, svc) = fixture(scanner);
|
||||
let log = VecLog::default();
|
||||
let report = svc.scan(&log).await.unwrap();
|
||||
assert_eq!(report.targets, vec!["os", PG, GITEA]);
|
||||
assert_eq!(report.new_findings.len(), 3);
|
||||
assert_eq!(report.total_open, 3);
|
||||
assert_eq!(report.fixed, 0);
|
||||
let all = f.findings.0.lock().unwrap();
|
||||
let os = all.iter().find(|x| x.target == "os").unwrap();
|
||||
assert_eq!(os.target_kind, TargetKind::Os);
|
||||
assert_eq!(os.status, FindingStatus::Open);
|
||||
assert_eq!(os.raw.fixed_version.as_deref(), Some("3.0.2"));
|
||||
assert!(all
|
||||
.iter()
|
||||
.any(|x| x.target == GITEA && x.target_kind == TargetKind::Image));
|
||||
let lines = log.0.lock().unwrap().join("\n");
|
||||
assert!(lines.contains("scanning os"), "{lines}");
|
||||
assert!(lines.contains(GITEA), "{lines}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rescan_keeps_existing_marks_fixed_and_reports_only_new() {
|
||||
let scanner = FakeScanner::default().with(
|
||||
"os",
|
||||
Ok(vec![
|
||||
raw("CVE-1", "openssl", "3.0.1", Severity::High, None),
|
||||
raw("CVE-9", "bash", "5.2", Severity::Low, None),
|
||||
]),
|
||||
);
|
||||
let (f, svc) = fixture(scanner);
|
||||
let first = svc.scan(&VecLog::default()).await.unwrap();
|
||||
assert_eq!(first.new_findings.len(), 2);
|
||||
let first_seen = f.findings.0.lock().unwrap()[0].first_seen;
|
||||
|
||||
// CVE-9 disappears (fixed), CVE-1 stays, CVE-2 is new
|
||||
*f.results.lock().unwrap() = std::collections::HashMap::from([(
|
||||
"os".to_string(),
|
||||
Ok(vec![
|
||||
raw("CVE-1", "openssl", "3.0.1", Severity::High, None),
|
||||
raw("CVE-2", "openssl", "3.0.1", Severity::Critical, None),
|
||||
]),
|
||||
)]);
|
||||
let second = svc.scan(&VecLog::default()).await.unwrap();
|
||||
assert_eq!(
|
||||
second
|
||||
.new_findings
|
||||
.iter()
|
||||
.map(|f| f.raw.cve_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["CVE-2"]
|
||||
);
|
||||
assert_eq!(second.fixed, 1);
|
||||
assert_eq!(second.total_open, 2);
|
||||
let all = f.findings.0.lock().unwrap();
|
||||
let cve1 = all.iter().find(|x| x.raw.cve_id == "CVE-1").unwrap();
|
||||
assert_eq!(cve1.first_seen, first_seen, "first_seen is preserved");
|
||||
assert!(cve1.last_seen > first_seen);
|
||||
assert_eq!(
|
||||
all.iter().find(|x| x.raw.cve_id == "CVE-9").unwrap().status,
|
||||
FindingStatus::Fixed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_target_is_skipped_and_its_findings_are_kept() {
|
||||
let scanner = FakeScanner::default()
|
||||
.with("os", Ok(vec![raw("CVE-1", "a", "1", Severity::High, None)]))
|
||||
.with(GITEA, Err("pull failed"));
|
||||
let (f, svc) = fixture(scanner);
|
||||
svc.scan(&VecLog::default()).await.unwrap();
|
||||
*f.results.lock().unwrap() =
|
||||
std::collections::HashMap::from([("os".to_string(), Err("trivy crashed".to_string()))]);
|
||||
let log = VecLog::default();
|
||||
let report = svc.scan(&log).await.unwrap();
|
||||
assert_eq!(report.failed_targets, vec!["os"]);
|
||||
assert_eq!(
|
||||
f.findings
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|x| x.status == FindingStatus::Open)
|
||||
.count(),
|
||||
1,
|
||||
"not marked fixed on failure"
|
||||
);
|
||||
assert!(log
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|l| l.contains("trivy crashed")));
|
||||
// scanner not installed at all -> hard error
|
||||
*f.results.lock().unwrap() = std::collections::HashMap::new();
|
||||
svc.scan(&VecLog::default()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_findings_at_or_above_threshold_trigger_one_mail() {
|
||||
let scanner = FakeScanner::default().with(
|
||||
"os",
|
||||
Ok(vec![
|
||||
raw("CVE-1", "a", "1", Severity::Critical, Some("2")),
|
||||
raw("CVE-2", "b", "1", Severity::Medium, None),
|
||||
]),
|
||||
);
|
||||
let (f, svc) = fixture(scanner);
|
||||
// no smtp configured -> no mail, no error
|
||||
svc.scan(&VecLog::default()).await.unwrap();
|
||||
assert!(f.mailer.0.lock().unwrap().is_empty());
|
||||
|
||||
f.settings.set_smtp(smtp()).await.unwrap();
|
||||
*f.results.lock().unwrap() = std::collections::HashMap::from([(
|
||||
"os".to_string(),
|
||||
Ok(vec![
|
||||
raw("CVE-3", "c", "1", Severity::High, None),
|
||||
raw("CVE-4", "d", "1", Severity::Low, None),
|
||||
]),
|
||||
)]);
|
||||
svc.scan(&VecLog::default()).await.unwrap();
|
||||
let sent = f.mailer.0.lock().unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert!(sent[0].1.contains("1 new"), "{}", sent[0].1);
|
||||
assert!(sent[0].2.contains("CVE-3"));
|
||||
assert!(!sent[0].2.contains("CVE-4"), "below threshold");
|
||||
drop(sent);
|
||||
|
||||
f.settings
|
||||
.set_notify_min_severity(Severity::Low)
|
||||
.await
|
||||
.unwrap();
|
||||
*f.results.lock().unwrap() = std::collections::HashMap::from([(
|
||||
"os".to_string(),
|
||||
Ok(vec![raw("CVE-5", "e", "1", Severity::Low, None)]),
|
||||
)]);
|
||||
svc.scan(&VecLog::default()).await.unwrap();
|
||||
assert_eq!(f.mailer.0.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_summary_and_status_changes() {
|
||||
let scanner = FakeScanner::default()
|
||||
.with(
|
||||
"os",
|
||||
Ok(vec![
|
||||
raw("CVE-1", "a", "1", Severity::Critical, None),
|
||||
raw("CVE-2", "b", "1", Severity::Low, None),
|
||||
]),
|
||||
)
|
||||
.with(
|
||||
GITEA,
|
||||
Ok(vec![raw("CVE-3", "c", "1", Severity::High, None)]),
|
||||
);
|
||||
let (_f, svc) = fixture(scanner);
|
||||
svc.scan(&VecLog::default()).await.unwrap();
|
||||
|
||||
let high = svc
|
||||
.list(FindingFilter {
|
||||
min_severity: Some(Severity::High),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
high.iter()
|
||||
.map(|f| f.raw.cve_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["CVE-1", "CVE-3"]
|
||||
);
|
||||
let s = svc.summary().await.unwrap();
|
||||
assert_eq!(s.total.total(), 3);
|
||||
assert_eq!(s.os.critical, 1);
|
||||
assert_eq!(s.images.high, 1);
|
||||
assert!(s.last_scan.is_some());
|
||||
|
||||
let id = high[0].id;
|
||||
let f = svc
|
||||
.set_status(id, FindingStatus::Acknowledged)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(f.status, FindingStatus::Acknowledged);
|
||||
assert!(matches!(
|
||||
svc.set_status(id, FindingStatus::Fixed).await.unwrap_err(),
|
||||
DomainError::Validation(_)
|
||||
));
|
||||
assert_eq!(
|
||||
svc.set_status(uuid::Uuid::new_v4(), FindingStatus::Open)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
DomainError::NotFound
|
||||
);
|
||||
let ack = svc
|
||||
.list(FindingFilter {
|
||||
status: Some(FindingStatus::Acknowledged),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ack.len(), 1);
|
||||
}
|
||||
84
backend/crates/application/src/vuln_service.rs
Normal file
84
backend/crates/application/src/vuln_service.rs
Normal file
@ -0,0 +1,84 @@
|
||||
//! Vulnerability scanning: diffs scanner results against stored findings,
|
||||
//! notifies about new ones by mail.
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{ClusterGateway, FindingRepository, VulnerabilityScanner};
|
||||
use domain::vuln::{
|
||||
Finding, FindingFilter, FindingStatus, ScanReport, Severity, SeverityCounts, TargetKind,
|
||||
};
|
||||
use domain::DomainError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::jobs::{JobHandler, JobLog};
|
||||
use crate::SettingsService;
|
||||
|
||||
pub struct VulnerabilityService {
|
||||
pub(crate) scanner: Arc<dyn VulnerabilityScanner>,
|
||||
pub(crate) findings: Arc<dyn FindingRepository>,
|
||||
pub(crate) cluster: Arc<dyn ClusterGateway>,
|
||||
pub(crate) settings: Arc<SettingsService>,
|
||||
}
|
||||
|
||||
impl VulnerabilityService {
|
||||
pub fn new(
|
||||
scanner: Arc<dyn VulnerabilityScanner>,
|
||||
findings: Arc<dyn FindingRepository>,
|
||||
cluster: Arc<dyn ClusterGateway>,
|
||||
settings: Arc<SettingsService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
scanner,
|
||||
findings,
|
||||
cluster,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan the OS and all cluster images, persist the diff, notify about new findings.
|
||||
pub async fn scan(&self, _log: &dyn JobLog) -> Result<ScanReport, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn list(&self, _filter: FindingFilter) -> Result<Vec<Finding>, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn summary(&self) -> Result<Summary, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Only open <-> acknowledged transitions are allowed by users.
|
||||
pub async fn set_status(
|
||||
&self,
|
||||
_id: Uuid,
|
||||
_status: FindingStatus,
|
||||
) -> Result<Finding, DomainError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn scanner_version(&self) -> Result<String, DomainError> {
|
||||
self.scanner.version().await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Summary {
|
||||
pub total: SeverityCounts,
|
||||
pub os: SeverityCounts,
|
||||
pub images: SeverityCounts,
|
||||
pub last_scan: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
pub struct VulnerabilityScanJob(pub Arc<VulnerabilityService>);
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for VulnerabilityScanJob {
|
||||
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of the notification threshold setting.
|
||||
pub const KEY_NOTIFY_MIN_SEVERITY: &str = "vuln.notify_min_severity";
|
||||
pub const DEFAULT_NOTIFY_MIN_SEVERITY: Severity = Severity::High;
|
||||
Reference in New Issue
Block a user