diff --git a/README.md b/README.md index ff1bc57..5650656 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,18 @@ VulnFeed 是一个用于收集和推送高价值漏洞和补丁公告信息的 当抓取安全补丁公告的站点数据: -| 名称 | 地址 | 推送策略 | -| --------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -| 用友安全中心 | | 近三条数据 | -| 泛微ECOLOGY安全补丁包 | | "EC9.0全量补丁", "EC8.0全量补丁", "EC10.0安全补丁" | -| Smartbi安全补丁包 | | 近三条数据 | -| 帆软安全漏洞声明 | | 近三条数据 | -| 致远安全补丁 | | V5的近三条数据 | -| Vmware安全公告 | | 近十条数据 | -| Oracle安全公告 | | 近三条数据 | -| Firefox安全公告 | | 近三条数据 | -| Apple安全公告 | | 近十条数,不包含没有链接的据 | +| 名称 | 地址 | 推送策略 | +| --------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| 用友安全中心 | | 近三条数据 | +| 泛微ECOLOGY安全补丁包 | | "EC9.0全量补丁", "EC8.0全量补丁", "EC10.0安全补丁" | +| Smartbi安全补丁包 | | 近三条数据 | +| 帆软安全漏洞声明 | | 近三条数据 | +| 致远安全补丁 | | V5的近三条数据 | +| Vmware安全公告 | | 近十条数据 | +| Oracle安全公告 | | 近三条数据 | +| Firefox安全公告 | | 近三条数据 | +| Apple安全公告 | | 近十条数,不包含没有链接的据 | +| Grafana安全公告 | | 第一页数据中以"Grafana security release" 或 "Grafana security update" 开头的公告 | ![app](./images/app.jpg) diff --git a/images/plugin.jpg b/images/plugin.jpg index 4cb66ef..45613ac 100644 Binary files a/images/plugin.jpg and b/images/plugin.jpg differ diff --git a/src/output/plugins/sec_notice/grafana.rs b/src/output/plugins/sec_notice/grafana.rs new file mode 100644 index 0000000..93367c8 --- /dev/null +++ b/src/output/plugins/sec_notice/grafana.rs @@ -0,0 +1,232 @@ +use async_trait::async_trait; +use error_stack::ResultExt; +use mea::mpsc::UnboundedSender; +use scraper::{Html, Selector}; + +use crate::{ + AppResult, + domain::models::security_notice::{CreateSecurityNotice, RiskLevel}, + errors::Error, + output::plugins::sec_notice::{SecNoticePlugin, register_sec_notice}, + utils::http_client::HttpClient, +}; + +const GRAFANA_SECURITY_URL: &str = "https://grafana.com/tags/security/"; + +#[derive(Debug, Clone)] +pub struct GrafanaNoticePlugin { + name: String, + display_name: String, + link: String, + http_client: HttpClient, + sender: UnboundedSender, +} + +#[async_trait] +impl SecNoticePlugin for GrafanaNoticePlugin { + fn get_name(&self) -> String { + self.name.to_string() + } + + fn get_display_name(&self) -> String { + self.display_name.to_string() + } + + fn get_link(&self) -> String { + self.link.to_string() + } + + async fn update(&self, _page_limit: i32) -> AppResult<()> { + let notices = self.parse_security_notices().await?; + + // 只取前10条数据 + for notice in notices.into_iter().take(10) { + let create_security_notice = CreateSecurityNotice { + key: notice.id, + title: notice.title, + product_name: "Grafana".to_string(), + risk_level: RiskLevel::Critical.to_string(), // Grafana安全公告通常为高风险 + source: self.link.clone(), + source_name: self.get_name(), + is_zero_day: false, + publish_time: notice.published_date, + detail_link: notice.detail_link, + pushed: false, + }; + + self.sender + .send(create_security_notice) + .change_context_lazy(|| { + Error::Message("Failed to send security notice to queue".to_string()) + })?; + } + Ok(()) + } +} + +impl GrafanaNoticePlugin { + pub fn try_new( + sender: UnboundedSender, + ) -> AppResult { + let http_client = HttpClient::try_new()?; + let grafana = GrafanaNoticePlugin { + name: "GrafanaPlugin".to_string(), + display_name: "Grafana安全公告".to_string(), + link: GRAFANA_SECURITY_URL.to_string(), + http_client, + sender, + }; + register_sec_notice(grafana.name.clone(), Box::new(grafana.clone())); + Ok(grafana) + } + + async fn get_document(&self, url: &str) -> AppResult { + let content = self.http_client.get_html_content(url).await?; + let document = Html::parse_document(&content); + Ok(document) + } + + /// 解析安全公告列表 + pub async fn parse_security_notices(&self) -> AppResult> { + let document = self.get_document(&self.link).await?; + + // 选择 class="all-posts" 下的所有 article 元素 + let articles_selector = Selector::parse(".all-posts article").map_err(|e| { + Error::Message(format!("Failed to parse CSS selector for articles: {}", e)) + })?; + + let articles: Vec<_> = document.select(&articles_selector).collect(); + + let mut notices = Vec::new(); + + for article in articles.iter() { + if notices.len() >= 10 { + break; + } + + // 提取标题 + let title_selector = Selector::parse("h3").map_err(|e| { + Error::Message(format!("Failed to parse CSS selector for title: {}", e)) + })?; + + if let Some(title_element) = article.select(&title_selector).next() { + let title = title_element.text().collect::().trim().to_string(); + + // 只处理以 "Grafana security release" 或 "Grafana security update" 开头的公告 + if title.starts_with("Grafana security release") + || title.starts_with("Grafana security update") + { + // 提取详情链接 + let link_selector = Selector::parse("a").map_err(|e| { + Error::Message(format!("Failed to parse CSS selector for link: {}", e)) + })?; + + let detail_link = + if let Some(link_element) = article.select(&link_selector).next() { + link_element + .value() + .attr("href") + .map(|href| { + if href.starts_with("http") { + href.to_string() + } else { + format!("https://grafana.com{}", href) + } + }) + .unwrap_or_default() + } else { + "".to_string() + }; + + // 提取发布日期 + let date_selector = + Selector::parse("p.blog-list-item__byline").map_err(|e| { + Error::Message(format!("Failed to parse CSS selector for date: {}", e)) + })?; + + let published_date = + if let Some(date_element) = article.select(&date_selector).next() { + // 从 byline 中提取日期,格式类似 "Naima Alexander · 18 Sep 2025 · 5 min read" + let text = date_element.text().collect::(); + let parts: Vec<&str> = text.split("·").collect(); + if parts.len() >= 2 { + parts[1].trim().to_string() + } else { + text.trim().to_string() + } + } else { + "".to_string() + }; + + // 从链接中提取ID + let id = detail_link + .split('/') + .filter(|s| !s.is_empty()) + .next_back() + .unwrap_or(&detail_link) + .to_string(); + + notices.push(GrafanaSecurityNotice { + id, + title, + detail_link, + published_date, + }); + } + } + } + + Ok(notices) + } +} + +#[derive(Debug, Clone)] +pub struct GrafanaSecurityNotice { + pub id: String, + pub title: String, + pub detail_link: String, + pub published_date: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use mea::mpsc::unbounded; + + #[tokio::test] + async fn test_parse_security_notices() { + let (sender, _receiver) = unbounded::(); + let plugin = GrafanaNoticePlugin::try_new(sender).unwrap(); + + // 测试解析安全公告 + let result = plugin.parse_security_notices().await; + if let Err(e) = &result { + println!("Error parsing security notices: {:?}", e); + } + let notices = result.unwrap(); + + for (i, notice) in notices.iter().enumerate() { + println!( + "Notice {}: title={}, id={}, link={}", + i, notice.title, notice.id, notice.detail_link + ); + } + + // 验证是否获取到了公告 + // 验证是否获取到了公告 + assert!(!notices.is_empty()); + + // 验证前10个公告 + assert!(notices.len() <= 10); + + // 验证每个公告的标题都以指定前缀开头 + for notice in ¬ices { + assert!( + notice.title.starts_with("Grafana security release") + || notice.title.starts_with("Grafana security update") + ); + assert!(!notice.id.is_empty()); + assert!(!notice.detail_link.is_empty()); + } + } +} diff --git a/src/output/plugins/sec_notice/mod.rs b/src/output/plugins/sec_notice/mod.rs index 14a5fac..9d0e4ec 100644 --- a/src/output/plugins/sec_notice/mod.rs +++ b/src/output/plugins/sec_notice/mod.rs @@ -1,6 +1,7 @@ pub mod apple; pub mod fanruan; pub mod firefox; +pub mod grafana; pub mod oracle; pub mod seeyon; pub mod smartbi; @@ -20,8 +21,9 @@ use crate::{ domain::models::security_notice::CreateSecurityNotice, output::plugins::sec_notice::{ apple::AppleNoticePlugin, fanruan::FanRuanNoticePlugin, firefox::FirefoxNoticePlugin, - oracle::OracleNoticePlugin, seeyon::SeeyonNoticePlugin, smartbi::SmartbiNoticePlugin, - vmware::VmwareNoticePlugin, weaver::WeaverNoticePlugin, yongyou::YongYouNoticePlugin, + grafana::GrafanaNoticePlugin, oracle::OracleNoticePlugin, seeyon::SeeyonNoticePlugin, + smartbi::SmartbiNoticePlugin, vmware::VmwareNoticePlugin, weaver::WeaverNoticePlugin, + yongyou::YongYouNoticePlugin, }, }; @@ -39,6 +41,7 @@ pub fn init(sender: UnboundedSender) -> AppResult<()> { OracleNoticePlugin::try_new(sender.clone())?; FirefoxNoticePlugin::try_new(sender.clone())?; AppleNoticePlugin::try_new(sender.clone())?; + GrafanaNoticePlugin::try_new(sender.clone())?; Ok(()) }