Feat support yonyou security notice (#9)

This commit is contained in:
fan-tastic-z
2025-09-22 16:02:39 +08:00
committed by GitHub
parent f9cc03bfd8
commit b098e2922b
32 changed files with 1128 additions and 71 deletions

View File

@@ -6,6 +6,7 @@ import VulnerabilityDetailPage from './routes/vuln/detail'
import SyncDataTaskPage from './routes/sync/task'
import PluginListPage from './routes/plugins/list'
import DingBotConfigPage from './routes/dingbot/config'
import SecNoticeListPage from './routes/secnotice/list'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
@@ -74,6 +75,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/secnotice"
element={
<ProtectedRoute>
<Layout>
<SecNoticeListPage />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</div>
)

View File

@@ -58,6 +58,16 @@ const Navigation = () => {
>
漏洞列表
</Link>
<Link
to="/secnotice"
className={`${
isActive('/secnotice')
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
安全公告
</Link>
<Link
to="/plugins"
className={`${

View File

@@ -91,4 +91,17 @@ export const createOrUpdateDingBotConfig = (data) => {
return api.post('/ding_bot_config', data)
}
// 获取安全公告列表
export const getSecNotices = (params) => {
return api.get('/sec_notice', {
params: {
page_no: params.pageNo,
page_size: params.pageSize,
title: params.title,
pushed: params.pushed,
source_name: params.source_name,
},
})
}
export default api

View File

@@ -0,0 +1,290 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { getSecNotices, getPlugins } from '../../lib/api'
const SecNoticeListPage = () => {
const [secNotices, setSecNotices] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [pageNo, setPageNo] = useState(1)
const [pageSize] = useState(10)
const [totalCount, setTotalCount] = useState(0)
const [plugins, setPlugins] = useState([])
// 新的筛选条件状态
const [filters, setFilters] = useState({
title: '',
pushed: '',
source_name: '' // 存储插件的name字段
})
useEffect(() => {
fetchPlugins()
}, [])
useEffect(() => {
fetchSecNotices()
}, [pageNo, filters])
const fetchPlugins = async () => {
try {
const response = await getPlugins()
setPlugins(response.data.data)
} catch (err) {
console.error('获取插件列表失败:', err)
}
}
const fetchSecNotices = async () => {
setLoading(true)
setError('')
try {
const params = {
pageNo,
pageSize,
...filters
}
// 清理空值参数
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === undefined) {
delete params[key]
}
})
const response = await getSecNotices(params)
const { data, total_count } = response.data.data
setSecNotices(data)
setTotalCount(total_count)
} catch (err) {
setError('获取安全公告列表失败')
console.error('Fetch sec notices error:', err)
} finally {
setLoading(false)
}
}
const handlePageChange = (newPageNo) => {
setPageNo(newPageNo)
}
const handleFilterChange = (e) => {
const { name, value } = e.target
setFilters(prev => ({
...prev,
[name]: value
}))
// 重置页码到第一页
setPageNo(1)
}
const handleSourceFilterChange = (e) => {
const selectedName = e.target.value
setFilters(prev => ({
...prev,
source_name: selectedName
}))
// 重置页码到第一页
setPageNo(1)
}
const handleResetFilters = () => {
setFilters({
title: '',
pushed: '',
source_name: ''
})
// 重置页码到第一页
setPageNo(1)
}
const totalPages = Math.ceil(totalCount / pageSize)
return (
<div className="mx-auto max-w-7xl">
<div className="mb-8 text-center">
<h1 className="mb-2 text-3xl font-extrabold text-gray-900">
安全公告列表
</h1>
<p className="text-gray-600">
当前共有 {totalCount} 个安全公告
</p>
</div>
{/* 筛选控件 */}
<div className="mb-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
公告标题
</label>
<input
type="text"
name="title"
id="title"
value={filters.title}
onChange={handleFilterChange}
placeholder="搜索公告标题..."
className="block w-full px-3 py-2 placeholder-gray-400 border border-gray-300 rounded-md shadow-sm appearance-none focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div>
<label htmlFor="pushed" className="block text-sm font-medium text-gray-700">
推送状态
</label>
<select
name="pushed"
id="pushed"
value={filters.pushed}
onChange={handleFilterChange}
className="block w-full px-3 py-2 placeholder-gray-400 border border-gray-300 rounded-md shadow-sm appearance-none focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">全部</option>
<option value="true">已推送</option>
<option value="false">未推送</option>
</select>
</div>
<div>
<label htmlFor="source" className="block text-sm font-medium text-gray-700">
来源
</label>
<select
name="source"
id="source"
value={filters.source}
onChange={handleSourceFilterChange}
className="block w-full px-3 py-2 placeholder-gray-400 border border-gray-300 rounded-md shadow-sm appearance-none focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="">全部</option>
{plugins.map((plugin) => (
<option key={plugin.name} value={plugin.name}>
{plugin.display_name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end mt-4">
<button
onClick={handleResetFilters}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
重置筛选
</button>
</div>
</div>
{error && (
<div className="p-4 mb-6 border border-red-200 rounded-md bg-red-50">
<div className="text-sm text-red-800">
{error}
</div>
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="w-12 h-12 border-b-2 border-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<>
<div className="overflow-hidden bg-white shadow sm:rounded-md">
<ul className="divide-y divide-gray-200">
{secNotices.map((notice) => (
<li key={notice.id}>
<div className="block hover:bg-gray-50">
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-indigo-600 truncate">{notice.title}</p>
<div className="flex flex-shrink-0 ml-2">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
notice.risk_level === 'Critical' ? 'bg-red-100 text-red-800' :
notice.risk_level === 'High' ? 'bg-orange-100 text-orange-800' :
notice.risk_level === 'Medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{notice.risk_level}
</span>
</div>
</div>
<div className="mt-2 sm:flex sm:justify-between">
<div className="sm:flex">
<p className="flex items-center text-sm text-gray-500">
产品: {notice.product_name || 'N/A'}
</p>
<p className="flex items-center mt-1 text-sm text-gray-500 sm:mt-0 sm:ml-6">
来源: {notice.source}
</p>
</div>
<div className="flex items-center mt-2 text-sm text-gray-500 sm:mt-0">
<svg className="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" />
</svg>
<p>
发布时间: <time dateTime={notice.publish_time}>{new Date(notice.publish_time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</time>
</p>
<span className={`ml-4 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
notice.pushed ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{notice.pushed ? '已推送' : '未推送'}
</span>
</div>
</div>
<div className="mt-2">
<p className="text-sm text-gray-500 line-clamp-2">
{notice.description}
</p>
</div>
</div>
</div>
</li>
))}
</ul>
</div>
{/* 分页组件 */}
<div className="flex items-center justify-between mt-6">
<div className="text-sm text-gray-700">
显示第 {(pageNo - 1) * pageSize + 1} {Math.min(pageNo * pageSize, totalCount)} 条记录
总共 {totalCount} 条记录
</div>
<div className="flex space-x-2">
<button
onClick={() => handlePageChange(pageNo - 1)}
disabled={pageNo === 1}
className={`px-4 py-2 text-sm font-medium rounded-md ${
pageNo === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
上一页
</button>
<span className="px-4 py-2 text-sm text-gray-700">
{pageNo} {totalPages}
</span>
<button
onClick={() => handlePageChange(pageNo + 1)}
disabled={pageNo === totalPages}
className={`px-4 py-2 text-sm font-medium rounded-md ${
pageNo === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
下一页
</button>
</div>
</div>
</>
)}
</div>
)
}
export default SecNoticeListPage

View File

@@ -0,0 +1,16 @@
-- Add migration script here
CREATE TABLE IF NOT EXISTS security_notice (
id BIGSERIAL PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
product_name TEXT NOT NULL DEFAULT '',
risk_level TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
source_name TEXT NOT NULL DEFAULT '',
is_zero_day BOOLEAN NOT NULL DEFAULT FALSE,
publish_time TEXT NOT NULL DEFAULT '',
detail_link TEXT NOT NULL DEFAULT '',
pushed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -6,6 +6,7 @@ use crate::{
domain::{
models::{
admin_user::{AdminUserPassword, AdminUsername, CreateAdminUserRequest},
security_notice::CreateSecurityNotice,
vuln_information::CreateVulnInformation,
},
ports::VulnService,
@@ -15,8 +16,9 @@ use crate::{
input::http::http_server::{self, make_acceptor_and_advertise_addr},
output::{
db::{admin_user::AdminUserDao, pg::Pg},
plugins,
plugins::{sec_notice, vuln},
scheduler::Scheduler,
sec_notcie_worker::SecNoticeWorker,
worker::Worker,
},
utils::{
@@ -84,11 +86,17 @@ async fn run_server(server_rt: &Runtime, config: Config) -> AppResult<()> {
.change_context_lazy(make_error)?;
let (sender, receiver) = mea::mpsc::unbounded::<CreateVulnInformation>();
plugins::init(sender).change_context_lazy(make_error)?;
vuln::init(sender).change_context_lazy(make_error)?;
let mut worker = Worker::new(receiver, db.clone());
server_rt.spawn(async move { worker.run().await });
let (sec_sender, sec_receiver) = mea::mpsc::unbounded::<CreateSecurityNotice>();
sec_notice::init(sec_sender).change_context_lazy(make_error)?;
let mut sec_worker = SecNoticeWorker::new(sec_receiver, db.clone());
server_rt.spawn(async move { sec_worker.run().await });
let sched = Scheduler::try_new(db.clone())
.await
.change_context_lazy(make_error)?;

View File

@@ -3,5 +3,6 @@ pub mod auth;
pub mod ding_bot;
pub mod extension_data;
pub mod page_utils;
pub mod security_notice;
pub mod sync_data_task;
pub mod vuln_information;

View File

@@ -0,0 +1,106 @@
use std::fmt;
use chrono::{DateTime, Utc};
use modql::field::Fields;
use serde::{Deserialize, Serialize};
use crate::domain::models::page_utils::PageFilter;
#[derive(
Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, sqlx::FromRow, Deserialize,
)]
pub struct SecuritNotice {
pub id: i64,
pub key: String,
pub title: String,
pub product_name: String,
pub risk_level: String,
pub source: String,
pub source_name: String,
pub is_zero_day: bool,
pub publish_time: String,
pub detail_link: String,
pub pushed: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Fields, Serialize)]
pub struct CreateSecurityNotice {
pub key: String,
pub title: String,
pub product_name: String,
pub risk_level: String,
pub source: String,
pub source_name: String,
pub is_zero_day: bool,
pub publish_time: String,
pub detail_link: String,
pub pushed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl fmt::Display for RiskLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ListSecNoticeRequest {
pub page_filter: PageFilter,
pub search_params: SearchParams,
}
impl ListSecNoticeRequest {
pub fn new(page_filter: PageFilter, search_params: SearchParams) -> Self {
ListSecNoticeRequest {
page_filter,
search_params,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct SearchParams {
pub title: Option<String>,
pub pushed: Option<bool>,
pub source_name: Option<String>,
}
impl SearchParams {
pub fn new() -> Self {
SearchParams::default()
}
pub fn with_title(mut self, title: Option<String>) -> Self {
self.title = title;
self
}
pub fn with_pushed(mut self, pushed: Option<bool>) -> Self {
self.pushed = pushed;
self
}
pub fn with_source_name(mut self, source_name: Option<String>) -> Self {
self.source_name = source_name;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ListSecNoticeResponseData {
pub total: i64,
pub data: Vec<SecuritNotice>,
}
impl ListSecNoticeResponseData {
pub fn new(total: i64, data: Vec<SecuritNotice>) -> Self {
ListSecNoticeResponseData { total, data }
}
}

View File

@@ -4,6 +4,7 @@ use crate::{
admin_user::AdminUser,
auth::LoginRequest,
ding_bot::{CreateDingBotRequest, DingBotConfig},
security_notice::{ListSecNoticeRequest, ListSecNoticeResponseData},
sync_data_task::{CreateSyncDataTaskRequest, SyncDataTask},
vuln_information::{
GetVulnInformationRequest, ListVulnInformationRequest, ListVulnInformationResponseData,
@@ -37,6 +38,11 @@ pub trait VulnService: Clone + Send + Sync + 'static {
) -> impl Future<Output = AppResult<i64>> + Send;
fn get_ding_bot_config(&self) -> impl Future<Output = AppResult<Option<DingBotConfig>>> + Send;
fn list_sec_notice(
&self,
req: ListSecNoticeRequest,
) -> impl Future<Output = AppResult<ListSecNoticeResponseData>> + Send;
}
pub trait VulnRepository: Clone + Send + Sync + 'static {
@@ -64,4 +70,9 @@ pub trait VulnRepository: Clone + Send + Sync + 'static {
) -> impl Future<Output = AppResult<i64>> + Send;
fn get_ding_bot_config(&self) -> impl Future<Output = AppResult<Option<DingBotConfig>>> + Send;
fn list_sec_notice(
&self,
req: ListSecNoticeRequest,
) -> impl Future<Output = AppResult<ListSecNoticeResponseData>> + Send;
}

View File

@@ -5,6 +5,7 @@ use crate::{
admin_user::AdminUser,
auth::LoginRequest,
ding_bot::{CreateDingBotRequest, DingBotConfig},
security_notice::{ListSecNoticeRequest, ListSecNoticeResponseData},
sync_data_task::{CreateSyncDataTaskRequest, SyncDataTask},
vuln_information::{
GetVulnInformationRequest, ListVulnInformationRequest,
@@ -75,4 +76,12 @@ where
let ret = self.repo.create_ding_bot_config(req).await?;
Ok(ret)
}
async fn list_sec_notice(
&self,
req: ListSecNoticeRequest,
) -> AppResult<ListSecNoticeResponseData> {
let ret = self.repo.list_sec_notice(req).await?;
Ok(ret)
}
}

View File

@@ -1,5 +1,6 @@
pub mod ding_bot_config;
pub mod login;
pub mod plugin;
pub mod sec_notice;
pub mod sync_data_task;
pub mod vuln_information;

View File

@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use crate::{
input::http::response::{ApiError, ApiSuccess},
output::plugins::get_registry,
output::plugins::vuln::get_registry,
};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]

View File

@@ -0,0 +1,129 @@
use chrono::{DateTime, Utc};
use poem::{
handler,
http::StatusCode,
web::{Data, Query},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
cli::Ctx,
domain::{
models::{
page_utils::{PageFilter, PageNo, PageNoError, PageSize, PageSizeError},
security_notice::{ListSecNoticeRequest, SearchParams, SecuritNotice},
},
ports::VulnService,
},
input::http::response::{ApiError, ApiSuccess},
};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ListSecNoticeRequestBody {
pub page_no: i32,
pub page_size: i32,
pub title: Option<String>,
pub pushed: Option<bool>,
pub source_name: Option<String>,
}
impl ListSecNoticeRequestBody {
pub fn try_into_domain(
self,
) -> Result<ListSecNoticeRequest, ParseListSecNoticeRequestBodyError> {
let page_no = PageNo::try_new(self.page_no)?;
let page_size = PageSize::try_new(self.page_size)?;
let page_filter = PageFilter::new(page_no, page_size);
let search_params = SearchParams::new()
.with_title(self.title)
.with_pushed(self.pushed)
.with_source_name(self.source_name);
Ok(ListSecNoticeRequest::new(page_filter, search_params))
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct SecNoticeData {
pub id: i64,
pub key: String,
pub title: String,
pub product_name: String,
pub risk_level: String,
pub source: String,
pub source_name: String,
pub is_zero_day: bool,
pub publish_time: String,
pub detail_link: String,
pub pushed: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl SecNoticeData {
pub fn new(notice: SecuritNotice) -> Self {
Self {
id: notice.id,
key: notice.key,
title: notice.title,
product_name: notice.product_name,
risk_level: notice.risk_level,
source: notice.source,
source_name: notice.source_name,
is_zero_day: notice.is_zero_day,
publish_time: notice.publish_time,
detail_link: notice.detail_link,
pushed: notice.pushed,
created_at: notice.created_at,
updated_at: notice.updated_at,
}
}
}
#[derive(Debug, Clone, Error)]
pub enum ParseListSecNoticeRequestBodyError {
#[error(transparent)]
InvalidPageNo(#[from] PageNoError),
#[error(transparent)]
InvalidPageSize(#[from] PageSizeError),
}
impl From<ParseListSecNoticeRequestBodyError> for ApiError {
fn from(e: ParseListSecNoticeRequestBodyError) -> Self {
let msg = match e {
ParseListSecNoticeRequestBodyError::InvalidPageNo(e) => {
format!("Invalid page number: {}", e)
}
ParseListSecNoticeRequestBodyError::InvalidPageSize(e) => {
format!("Invalid page size: {}", e)
}
};
ApiError::UnprocessableEntity(msg)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ListSecNoticeHttpResponseData {
pub data: Vec<SecNoticeData>,
pub total_count: i64,
}
#[handler]
pub async fn list_sec_notice<S: VulnService + Send + Sync + 'static>(
state: Data<&Ctx<S>>,
Query(body): Query<ListSecNoticeRequestBody>,
) -> Result<ApiSuccess<ListSecNoticeHttpResponseData>, ApiError> {
let req = body.try_into_domain()?;
state
.vuln_service
.list_sec_notice(req)
.await
.map_err(ApiError::from)
.map(|data| {
let response_data = ListSecNoticeHttpResponseData {
data: data.data.into_iter().map(SecNoticeData::new).collect(),
total_count: data.total,
};
ApiSuccess::new(StatusCode::OK, response_data)
})
}

View File

@@ -18,7 +18,7 @@ use crate::{
cli::Ctx,
domain::ports::VulnService,
input::http::{
handlers::{login, plugin, sync_data_task, vuln_information},
handlers::{login, plugin, sec_notice, sync_data_task, vuln_information},
middleware::auth::AuthMiddleware,
},
utils::runtime::{self, Runtime},
@@ -156,6 +156,10 @@ fn api_routes<S: VulnService + Send + Sync + 'static>() -> impl Endpoint {
.get(ding_bot_config::get_ding_bot_config::<S>::default()),
),
)
.nest(
"/sec_notice",
Route::new().at("", get(sec_notice::list_sec_notice::<S>::default())),
)
.with(AuthMiddleware::<S>::default()),
)
}

View File

@@ -3,5 +3,6 @@ pub mod base;
pub mod ding_bot_config;
pub mod pg;
pub mod repository_impl;
pub mod security_notice;
pub mod sync_data_task;
pub mod vuln_information;

View File

@@ -7,6 +7,7 @@ use crate::{
admin_user::AdminUser,
auth::LoginRequest,
ding_bot::{CreateDingBotRequest, DingBotConfig},
security_notice::{ListSecNoticeRequest, ListSecNoticeResponseData},
sync_data_task::{CreateSyncDataTaskRequest, SyncDataTask},
vuln_information::{
GetVulnInformationRequest, ListVulnInformationRequest,
@@ -18,7 +19,8 @@ use crate::{
errors::Error,
output::db::{
admin_user::AdminUserDao, ding_bot_config::DingBotConfigDao, pg::Pg,
sync_data_task::SyncDataTaskDao, vuln_information::VulnInformationDao,
security_notice::SecurityNoticeDao, sync_data_task::SyncDataTaskDao,
vuln_information::VulnInformationDao,
},
utils::password_hash::verify_password_hash,
};
@@ -127,4 +129,26 @@ impl VulnRepository for Pg {
.change_context_lazy(|| Error::Message("failed to commit transaction".to_string()))?;
Ok(ding_bot_config)
}
async fn list_sec_notice(
&self,
req: ListSecNoticeRequest,
) -> AppResult<ListSecNoticeResponseData> {
let mut tx =
self.pool.begin().await.change_context_lazy(|| {
Error::Message("failed to begin transaction".to_string())
})?;
let data = SecurityNoticeDao::filter_security_notices(
&mut tx,
&req.page_filter,
&req.search_params,
)
.await?;
let count =
SecurityNoticeDao::filter_security_notices_count(&mut tx, &req.search_params).await?;
tx.commit()
.await
.change_context_lazy(|| Error::Message("failed to commit transaction".to_string()))?;
Ok(ListSecNoticeResponseData { data, total: count })
}
}

View File

@@ -0,0 +1,102 @@
use sea_query::Value;
use sqlx::{Postgres, Transaction};
use crate::{
AppResult,
domain::models::{
page_utils::PageFilter,
security_notice::{CreateSecurityNotice, SearchParams, SecuritNotice},
},
output::db::base::{
Dao, DaoQueryBuilder, dao_create, dao_fetch_by_column, dao_fetch_by_id, dao_update_field,
},
};
pub struct SecurityNoticeDao;
impl Dao for SecurityNoticeDao {
const TABLE: &'static str = "security_notice";
}
impl SecurityNoticeDao {
pub async fn create(
tx: &mut Transaction<'_, Postgres>,
req: CreateSecurityNotice,
) -> AppResult<i64> {
let id = dao_create::<Self, _>(tx, req).await?;
Ok(id)
}
pub async fn fetch_by_key(
tx: &mut Transaction<'_, Postgres>,
key: &str,
) -> AppResult<Option<SecuritNotice>> {
dao_fetch_by_column::<Self, SecuritNotice>(tx, "key", key).await
}
pub async fn fetch_by_id(
tx: &mut Transaction<'_, Postgres>,
id: i64,
) -> AppResult<Option<SecuritNotice>> {
dao_fetch_by_id::<Self, SecuritNotice>(tx, id).await
}
pub async fn update_pushed(
tx: &mut Transaction<'_, Postgres>,
id: i64,
status: bool,
) -> AppResult<u64> {
let row = dao_update_field::<Self>(tx, id, "pushed", Value::Bool(Some(status))).await?;
Ok(row)
}
pub async fn filter_security_notices(
tx: &mut Transaction<'_, Postgres>,
page_filter: &PageFilter,
search_params: &SearchParams,
) -> AppResult<Vec<SecuritNotice>> {
let mut query_builder = DaoQueryBuilder::<Self>::new();
if let Some(title) = &search_params.title {
query_builder = query_builder.and_where_like("title", title);
}
if let Some(source_name) = &search_params.source_name {
query_builder = query_builder.and_where_like("source_name", source_name);
}
if let Some(pushed) = &search_params.pushed {
query_builder = query_builder.and_where_bool("pushed", *pushed);
}
let page_no = *page_filter.page_no().as_ref();
let page_size = *page_filter.page_size().as_ref();
let offset = (page_no - 1) * page_size;
query_builder
.order_by_desc("updated_at")
.limit_offset(page_size as i64, offset as i64)
.fetch_all(tx)
.await
}
pub async fn filter_security_notices_count(
tx: &mut Transaction<'_, Postgres>,
search_params: &SearchParams,
) -> AppResult<i64> {
let mut query_builder = DaoQueryBuilder::<Self>::new();
if let Some(title) = &search_params.title {
query_builder = query_builder.and_where_like("title", title);
}
if let Some(source_name) = &search_params.source_name {
query_builder = query_builder.and_where_like("source_name", source_name);
}
if let Some(pushed) = &search_params.pushed {
query_builder = query_builder.and_where_bool("pushed", *pushed);
}
query_builder.count(tx).await
}
}

View File

@@ -2,4 +2,5 @@ pub mod db;
pub mod plugins;
pub mod push;
pub mod scheduler;
pub mod sec_notcie_worker;
pub mod worker;

View File

@@ -1,55 +1,2 @@
pub mod avd;
pub mod kev;
pub mod oscs;
pub mod seekbug;
pub mod threatbook;
pub mod ti;
use async_trait::async_trait;
use dashmap::DashMap;
use lazy_static::lazy_static;
use mea::mpsc::UnboundedSender;
use std::sync::Arc;
use crate::{
AppResult,
domain::models::vuln_information::CreateVulnInformation,
output::plugins::{
avd::AVDPlugin, kev::KevPlugin, oscs::OscsPlugin, seekbug::SeekBugPlugin,
threatbook::ThreatBookPlugin, ti::TiPlugin,
},
};
lazy_static! {
static ref PLUGINS: Arc<DashMap<String, Box<dyn VulnPlugin>>> = Arc::new(DashMap::new());
}
pub fn init(sender: UnboundedSender<CreateVulnInformation>) -> AppResult<()> {
KevPlugin::try_new(sender.clone())?;
AVDPlugin::try_new(sender.clone())?;
OscsPlugin::try_new(sender.clone())?;
SeekBugPlugin::try_new(sender.clone())?;
ThreatBookPlugin::try_new(sender.clone())?;
TiPlugin::try_new(sender)?;
Ok(())
}
#[async_trait]
pub trait VulnPlugin: Send + Sync + 'static {
fn get_name(&self) -> String;
fn get_display_name(&self) -> String;
fn get_link(&self) -> String;
async fn update(&self, page_limit: i32) -> AppResult<()>;
}
pub fn register_plugin(name: String, plugin: Box<dyn VulnPlugin>) {
PLUGINS.insert(name, plugin);
}
pub fn get_registry() -> Arc<DashMap<String, Box<dyn VulnPlugin>>> {
PLUGINS.clone()
}
pub fn list_plugin_names() -> Vec<String> {
PLUGINS.iter().map(|r| r.key().clone()).collect()
}
pub mod sec_notice;
pub mod vuln;

View File

@@ -0,0 +1,40 @@
pub mod yongyou;
use std::sync::Arc;
use async_trait::async_trait;
use dashmap::DashMap;
use lazy_static::lazy_static;
use mea::mpsc::UnboundedSender;
use crate::{
AppResult, domain::models::security_notice::CreateSecurityNotice,
output::plugins::sec_notice::yongyou::YongYouNoticePlugin,
};
lazy_static! {
static ref NOTICE: Arc<DashMap<String, Box<dyn SecNoticePlugin>>> = Arc::new(DashMap::new());
}
pub fn init(sender: UnboundedSender<CreateSecurityNotice>) -> AppResult<()> {
YongYouNoticePlugin::try_new(sender.clone())?;
Ok(())
}
#[async_trait]
pub trait SecNoticePlugin: Send + Sync + 'static {
fn get_name(&self) -> String;
async fn update(&self, page_limit: i32) -> AppResult<()>;
}
pub fn register_sec_notice(name: String, sec_notice: Box<dyn SecNoticePlugin>) {
NOTICE.insert(name, sec_notice);
}
pub fn get_notice_registry() -> Arc<DashMap<String, Box<dyn SecNoticePlugin>>> {
NOTICE.clone()
}
pub fn list_sec_notice_names() -> Vec<String> {
NOTICE.iter().map(|r| r.key().clone()).collect()
}

View File

@@ -0,0 +1,136 @@
use async_trait::async_trait;
use error_stack::ResultExt;
use mea::mpsc::UnboundedSender;
use serde::{Deserialize, Serialize};
use crate::{
AppResult,
domain::models::security_notice::{CreateSecurityNotice, RiskLevel},
errors::Error,
output::plugins::sec_notice::{SecNoticePlugin, register_sec_notice},
utils::http_client::HttpClient,
};
const YONGYOU_NOTICE_URL: &str = "https://security.yonyou.com/web-api/web/notice/page";
const YONGYOU_DEFAULT_PAGE_NO: i32 = 1;
const YONGYOU_DEFAULT_PAGE_SIZE: i32 = 3;
#[derive(Debug, Clone)]
pub struct YongYouNoticePlugin {
name: String,
link: String,
http_client: HttpClient,
sender: UnboundedSender<CreateSecurityNotice>,
}
#[async_trait]
impl SecNoticePlugin for YongYouNoticePlugin {
fn get_name(&self) -> String {
self.name.to_string()
}
async fn update(&self, _page_limit: i32) -> AppResult<()> {
let sec_notices = self
.get_sec_notices(YONGYOU_DEFAULT_PAGE_NO, YONGYOU_DEFAULT_PAGE_SIZE)
.await?;
for sec_notice in sec_notices.data.list {
let detail_link = format!(
"https://security.yonyou.com/#/noticeInfo?id={}",
sec_notice.id
);
let risk_level = self.get_risk_level(&sec_notice.risk_level).to_string();
let create_security_notice = CreateSecurityNotice {
key: sec_notice.identifier,
title: sec_notice.notice,
detail_link,
source: self.link.clone(),
source_name: self.get_name(),
product_name: sec_notice.product_line_name,
is_zero_day: self.is_zero_day(&sec_notice.is_zero_day),
publish_time: sec_notice.publish_time,
risk_level,
pushed: false,
};
self.sender
.send(create_security_notice)
.change_context_lazy(|| {
Error::Message("Failed to send security notice to queue".to_string())
})?;
}
Ok(())
}
}
impl YongYouNoticePlugin {
pub fn try_new(
sender: UnboundedSender<CreateSecurityNotice>,
) -> AppResult<YongYouNoticePlugin> {
let http_client = HttpClient::try_new()?;
let yongyou = YongYouNoticePlugin {
name: "YongYouPlugin".to_string(),
link: "https://security.yonyou.com/#/home".to_string(),
http_client,
sender,
};
register_sec_notice(yongyou.name.clone(), Box::new(yongyou.clone()));
Ok(yongyou)
}
pub async fn get_sec_notices(
&self,
page_no: i32,
page_size: i32,
) -> AppResult<ListYongYouSecNotices> {
let params = serde_json::json!({
"pageNo": page_no,
"pageSize": page_size,
});
let sec_notices: ListYongYouSecNotices = self
.http_client
.post_json(YONGYOU_NOTICE_URL, &params)
.await?
.json()
.await
.map_err(|e| Error::Message(format!("yongyou get sec notices error: {}", e)))?;
Ok(sec_notices)
}
pub fn get_risk_level(&self, risk_level: &str) -> RiskLevel {
match risk_level {
"1" => RiskLevel::Critical,
"2" => RiskLevel::High,
"3" => RiskLevel::Medium,
"4" => RiskLevel::Low,
_ => RiskLevel::Low,
}
}
pub fn is_zero_day(&self, zero_day: &str) -> bool {
matches!(zero_day, "")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListYongYouSecNotices {
code: i32,
data: YongYouNoticeData,
msg: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YongYouNoticeData {
list: Vec<YongYouNotice>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YongYouNotice {
pub id: i32,
pub notice: String,
pub identifier: String,
pub product_line_name: String,
pub publish_time: String,
pub risk_level: String,
pub is_zero_day: String,
}

View File

@@ -11,7 +11,7 @@ use crate::{
AppResult,
domain::models::vuln_information::{CreateVulnInformation, Severity},
errors::Error,
output::plugins::{VulnPlugin, register_plugin},
output::plugins::vuln::{VulnPlugin, register_plugin},
utils::http_client::HttpClient,
};

View File

@@ -8,7 +8,7 @@ use crate::{
AppResult,
domain::models::vuln_information::{CreateVulnInformation, Severity},
errors::Error,
output::plugins::{VulnPlugin, register_plugin},
output::plugins::vuln::{VulnPlugin, register_plugin},
utils::http_client::HttpClient,
};

View File

@@ -0,0 +1,55 @@
pub mod avd;
pub mod kev;
pub mod oscs;
pub mod seekbug;
pub mod threatbook;
pub mod ti;
use async_trait::async_trait;
use dashmap::DashMap;
use lazy_static::lazy_static;
use mea::mpsc::UnboundedSender;
use std::sync::Arc;
use crate::{
AppResult,
domain::models::vuln_information::CreateVulnInformation,
output::plugins::vuln::{
avd::AVDPlugin, kev::KevPlugin, oscs::OscsPlugin, seekbug::SeekBugPlugin,
threatbook::ThreatBookPlugin, ti::TiPlugin,
},
};
lazy_static! {
static ref PLUGINS: Arc<DashMap<String, Box<dyn VulnPlugin>>> = Arc::new(DashMap::new());
}
pub fn init(sender: UnboundedSender<CreateVulnInformation>) -> AppResult<()> {
KevPlugin::try_new(sender.clone())?;
AVDPlugin::try_new(sender.clone())?;
OscsPlugin::try_new(sender.clone())?;
SeekBugPlugin::try_new(sender.clone())?;
ThreatBookPlugin::try_new(sender.clone())?;
TiPlugin::try_new(sender)?;
Ok(())
}
#[async_trait]
pub trait VulnPlugin: Send + Sync + 'static {
fn get_name(&self) -> String;
fn get_display_name(&self) -> String;
fn get_link(&self) -> String;
async fn update(&self, page_limit: i32) -> AppResult<()>;
}
pub fn register_plugin(name: String, plugin: Box<dyn VulnPlugin>) {
PLUGINS.insert(name, plugin);
}
pub fn get_registry() -> Arc<DashMap<String, Box<dyn VulnPlugin>>> {
PLUGINS.clone()
}
pub fn list_plugin_names() -> Vec<String> {
PLUGINS.iter().map(|r| r.key().clone()).collect()
}

View File

@@ -8,7 +8,7 @@ use crate::{
AppResult,
domain::models::vuln_information::{CreateVulnInformation, Severity},
errors::Error,
output::plugins::{VulnPlugin, register_plugin},
output::plugins::vuln::{VulnPlugin, register_plugin},
utils::{http_client::HttpClient, util::timestamp_to_date},
};

View File

@@ -7,7 +7,7 @@ use crate::{
AppResult,
domain::models::vuln_information::{CreateVulnInformation, Severity},
errors::Error,
output::plugins::{VulnPlugin, register_plugin},
output::plugins::vuln::{VulnPlugin, register_plugin},
utils::http_client::HttpClient,
};

View File

@@ -8,7 +8,7 @@ use crate::{
AppResult,
domain::models::vuln_information::{CreateVulnInformation, Severity},
errors::Error,
output::plugins::{VulnPlugin, register_plugin},
output::plugins::vuln::{VulnPlugin, register_plugin},
utils::{http_client::HttpClient, util::check_over_two_month},
};

View File

@@ -8,7 +8,7 @@ use crate::{
AppResult,
domain::models::vuln_information::{CreateVulnInformation, Severity},
errors::Error,
output::plugins::{VulnPlugin, register_plugin},
output::plugins::vuln::{VulnPlugin, register_plugin},
utils::http_client::HttpClient,
};

View File

@@ -6,7 +6,9 @@ use lazy_static::lazy_static;
use serde_json::Value;
use crate::{
AppResult, domain::models::vuln_information::VulnInformation, errors::Error,
AppResult,
domain::models::{security_notice::SecuritNotice, vuln_information::VulnInformation},
errors::Error,
utils::util::render_string,
};
@@ -46,9 +48,19 @@ const VULN_INFO_MSG_TEMPLATE: &str = r####"
{% if github_search | length > 0 %}{% for link in github_search %}{{ loop.index }}. {{ link }}
{% endfor %}{% else %}暂未找到{% endif %}{% endif %}"####;
const SEC_NOTICE_MSG_TEMPLATE: &str = r####"
# {{ title }}
- 危害定级: **{{ risk_level }}**
- 披露日期: **{{ publish_time }}**
- ZeroDay: **{{ is_zero_day }}**
- 产品名称: {{ product_name }}
- 详情链接: {{ detail_link }}
"####;
const MAX_REFERENCE_LENGTH: usize = 8;
pub fn reader_vulninfo(mut vuln: VulnInformation) -> AppResult<String> {
pub fn render_vulninfo(mut vuln: VulnInformation) -> AppResult<String> {
if vuln.reference_links.len() > MAX_REFERENCE_LENGTH {
vuln.reference_links = vuln.reference_links[..MAX_REFERENCE_LENGTH].to_vec();
}
@@ -58,6 +70,13 @@ pub fn reader_vulninfo(mut vuln: VulnInformation) -> AppResult<String> {
Ok(markdown)
}
pub fn render_sec_notice(sec_notice: SecuritNotice) -> AppResult<String> {
let json_value: Value = serde_json::to_value(sec_notice)
.map_err(|e| Error::Message(format!("Failed to serialize sec notice: {e}")))?;
let markdown = render_string(SEC_NOTICE_MSG_TEMPLATE, &json_value)?;
Ok(markdown)
}
pub fn escape_markdown(input: String) -> String {
input
.replace('_', "\\_")

View File

@@ -10,7 +10,10 @@ use crate::{
errors::Error,
output::{
db::{pg::Pg, sync_data_task::SyncDataTaskDao},
plugins::{get_registry, list_plugin_names},
plugins::{
sec_notice::{get_notice_registry, list_sec_notice_names},
vuln::{get_registry, list_plugin_names},
},
},
};
@@ -151,6 +154,21 @@ async fn execute_job(_uuid: Uuid) {
}
});
}
let sec_notices = list_sec_notice_names();
for name in sec_notices {
job_set.spawn(async move {
let notice = get_notice_registry();
log::info!("Updating sec notice: {}", name);
if let Some(notice) = notice.get::<str>(&name)
&& let Err(e) = notice.update(1).await
{
log::error!("Sec notice update failed for {}: {}", name, e)
}
});
}
job_set.join_all().await;
log::info!("Plugin syn finished elapsed {:?}", start.elapsed());
}

View File

@@ -0,0 +1,105 @@
use std::sync::Arc;
use error_stack::ResultExt;
use mea::mpsc::UnboundedReceiver;
use crate::{
AppResult,
domain::models::security_notice::CreateSecurityNotice,
errors::Error,
output::{
db::{ding_bot_config::DingBotConfigDao, pg::Pg, security_notice::SecurityNoticeDao},
push::{MessageBot, ding_bot::DingBot, render_sec_notice},
},
};
pub struct SecNoticeWorker {
pub receiver: UnboundedReceiver<CreateSecurityNotice>,
pub pg: Arc<Pg>,
}
impl SecNoticeWorker {
pub fn new(receiver: UnboundedReceiver<CreateSecurityNotice>, pg: Pg) -> Self {
SecNoticeWorker {
receiver,
pg: Arc::new(pg),
}
}
pub async fn run(&mut self) -> AppResult<()> {
while let Some(req) = self.receiver.recv().await {
match self.store(req).await {
Err(e) => {
log::error!("Failed to store vuln information: {:?}", e);
continue;
}
Ok((id, created)) => {
if created {
self.ding_bot_push(id).await?;
}
}
}
}
Ok(())
}
pub async fn ding_bot_push(&self, id: i64) -> AppResult<()> {
log::info!("ding bot push start! id: {}", id);
let mut tx =
self.pg.pool.begin().await.change_context_lazy(|| {
Error::Message("failed to begin transaction".to_string())
})?;
let ding_bot_config = DingBotConfigDao::first(&mut tx).await?;
if let Some(config) = ding_bot_config
&& config.status
{
let sec_notice = SecurityNoticeDao::fetch_by_id(&mut tx, id).await?;
if let Some(s) = sec_notice
&& !s.pushed
{
let ding = DingBot::try_new(config.access_token, config.secret_token)?;
let title = s.title.clone();
let msg = match render_sec_notice(s) {
Ok(msg) => msg,
Err(e) => {
log::error!("Failed to render sec notice: {:?}", e);
return Err(Error::Message(format!(
"Failed to render sec notice: {:?}",
e
))
.into());
}
};
ding.push_markdown(title, msg).await?;
log::info!("ding bot push success! id: {}", id);
SecurityNoticeDao::update_pushed(&mut tx, id, true).await?;
}
} else {
log::info!("ding bot config not found or status is false");
}
tx.commit()
.await
.change_context_lazy(|| Error::Message("failed to commit transaction".to_string()))?;
Ok(())
}
pub async fn store(&self, req: CreateSecurityNotice) -> AppResult<(i64, bool)> {
let mut tx =
self.pg.pool.begin().await.change_context_lazy(|| {
Error::Message("failed to begin transaction".to_string())
})?;
let res = SecurityNoticeDao::fetch_by_key(&mut tx, &req.key).await?;
let (id, created) = match res {
None => {
let id = SecurityNoticeDao::create(&mut tx, req).await?;
(id, true)
}
Some(sec_notice) => (sec_notice.id, false),
};
tx.commit()
.await
.change_context_lazy(|| Error::Message("failed to commit transaction".to_string()))?;
Ok((id, created))
}
}

View File

@@ -9,7 +9,7 @@ use crate::{
errors::Error,
output::{
db::{ding_bot_config::DingBotConfigDao, pg::Pg, vuln_information::VulnInformationDao},
push::{MessageBot, ding_bot::DingBot, reader_vulninfo},
push::{MessageBot, ding_bot::DingBot, render_vulninfo},
},
utils::search::search_github_poc,
};
@@ -60,7 +60,7 @@ impl Worker {
{
let ding = DingBot::try_new(config.access_token, config.secret_token)?;
let title = v.title.clone();
let msg = match reader_vulninfo(v) {
let msg = match render_vulninfo(v) {
Ok(msg) => msg,
Err(e) => {
log::error!("Failed to read vuln info: {:?}", e);