-
-
- Pika Monitor
+ // 1. 背景改为柔和的灰白色,视觉更轻盈
+
+
+ {/* 2. 卡片去除复杂的渐变边框,使用简单的圆角和优雅的阴影 */}
+
+
+ {/* 3. 头部精简:去掉了 Badge,强调品牌名称 */}
+
+
+ {window.SystemConfig.SystemNameZh}
+
+
+ 保持洞察,稳定运行
+
+
+
+
+ }
+ placeholder="用户名"
+ className="rounded-xl px-4 py-2.5 bg-slate-50 border-slate-200 hover:bg-white focus:bg-white transition-all"
+ />
+
+
+
+ }
+ placeholder="密码"
+ className="rounded-xl px-4 py-2.5 bg-slate-50 border-slate-200 hover:bg-white focus:bg-white transition-all"
+ />
+
+
+
+
+
+
+
+ {/* 5. 第三方登录区域 */}
+ {(oidcEnabled || githubEnabled) && (
+
+
+
+
+
+
+ 或者
-
登录 Pika 控制台
-
面向探针的统一监控后台
-
- }
- placeholder="请输入用户名"
- />
-
-
-
- }
- placeholder="请输入密码"
- />
-
-
-
+ {/* 使用 Grid 布局让按钮并排,减少垂直高度占用 */}
+
+ {githubEnabled && (
-
-
-
- {(oidcEnabled || githubEnabled) && (
-
-
其他登录方式
-
- {githubEnabled && (
-
- )}
- {oidcEnabled && (
-
- )}
-
-
- )}
-
-
- 登录即表示同意平台安全策略
-
+ )}
+ {oidcEnabled && (
+
+ )}
+
-
+ )}
);
};
-export default Login;
+export default Login;
\ No newline at end of file
diff --git a/web/src/pages/Settings/SystemConfig.tsx b/web/src/pages/Settings/SystemConfig.tsx
index 277e7b3..a5e9df6 100644
--- a/web/src/pages/Settings/SystemConfig.tsx
+++ b/web/src/pages/Settings/SystemConfig.tsx
@@ -3,9 +3,9 @@ import {App, Button, Card, Form, Input, Space, Spin, Upload} from 'antd';
import {Upload as UploadIcon} from 'lucide-react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import type {SystemConfig} from '../../api/system-config';
-import {getSystemConfig, saveSystemConfig, DEFAULT_SYSTEM_CONFIG} from '../../api/system-config';
+import {getSystemConfig, saveSystemConfig} from '../../api/system-config';
import {getErrorMessage} from '../../lib/utils';
-import type {RcFile, UploadFile} from 'antd/es/upload/interface';
+import type {RcFile} from 'antd/es/upload/interface';
const SystemConfigComponent = () => {
const [form] = Form.useForm();
@@ -40,15 +40,11 @@ const SystemConfigComponent = () => {
form.setFieldsValue({
systemNameEn: config.systemNameEn,
systemNameZh: config.systemNameZh,
+ icpCode: config.icpCode,
});
if (config.logoBase64) {
setLogoPreview(config.logoBase64);
}
- } else {
- form.setFieldsValue({
- systemNameEn: DEFAULT_SYSTEM_CONFIG.systemNameEn,
- systemNameZh: DEFAULT_SYSTEM_CONFIG.systemNameZh,
- });
}
}, [config, form]);
@@ -100,6 +96,7 @@ const SystemConfigComponent = () => {
systemNameEn: values.systemNameEn,
systemNameZh: values.systemNameZh,
logoBase64: logoPreview,
+ icpCode: values.icpCode || '',
} as SystemConfig);
} catch (error) {
// 表单验证失败
@@ -107,11 +104,15 @@ const SystemConfigComponent = () => {
};
const handleReset = () => {
- form.setFieldsValue({
- systemNameEn: DEFAULT_SYSTEM_CONFIG.systemNameEn,
- systemNameZh: DEFAULT_SYSTEM_CONFIG.systemNameZh,
- });
- setLogoPreview('');
+ // 重置为当前配置的值
+ if (config) {
+ form.setFieldsValue({
+ systemNameEn: config.systemNameEn,
+ systemNameZh: config.systemNameZh,
+ icpCode: config.icpCode,
+ });
+ setLogoPreview(config.logoBase64 || '');
+ }
};
// 获取 Logo 显示 URL
@@ -144,28 +145,39 @@ const SystemConfigComponent = () => {
type="inner"
className="mb-4"
>
-
-
-
+
+
+
+
+
+
+
+
+
-
+
{
>
{({getFieldValue}) => {
- const systemNameEn = getFieldValue('systemNameEn') || DEFAULT_SYSTEM_CONFIG.systemNameEn;
- const systemNameZh = getFieldValue('systemNameZh') || DEFAULT_SYSTEM_CONFIG.systemNameZh;
+ const systemNameEn = getFieldValue('systemNameEn') || '';
+ const systemNameZh = getFieldValue('systemNameZh') || '';
return (
![]()
Date: Wed, 26 Nov 2025 22:09:30 +0800
Subject: [PATCH 10/27] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=AC=E5=91=8A?=
=?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B1=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/components/PublicHeader.tsx | 5 ++-
web/src/pages/Public/MonitorDetail.tsx | 8 +---
web/src/pages/Public/MonitorList.tsx | 55 +++++++++-----------------
web/src/pages/Public/ServerDetail.tsx | 8 +---
web/src/pages/Public/ServerList.tsx | 47 ++++++++++------------
web/src/pages/PublicLayout.tsx | 38 ++++++++++++++++++
web/src/router/index.tsx | 34 +++++++++-------
7 files changed, 104 insertions(+), 91 deletions(-)
create mode 100644 web/src/pages/PublicLayout.tsx
diff --git a/web/src/components/PublicHeader.tsx b/web/src/components/PublicHeader.tsx
index 62ac17c..99d5cc7 100644
--- a/web/src/components/PublicHeader.tsx
+++ b/web/src/components/PublicHeader.tsx
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Activity, LayoutGrid, List, LogIn, Server, Settings} from 'lucide-react';
import {getCurrentUser} from '../api/auth';
-import {Link} from "react-router-dom";
+import {Link, useLocation} from "react-router-dom";
interface PublicHeaderProps {
viewMode?: 'grid' | 'list';
@@ -15,7 +15,7 @@ const PublicHeader = ({
showViewToggle = false
}: PublicHeaderProps) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
- const currentPath = window.location.pathname;
+ let location = useLocation();
useEffect(() => {
// 检查本地是否有 token
@@ -41,6 +41,7 @@ const PublicHeader = ({
}, []);
// 判断导航是否激活
+ const currentPath = location.pathname;
const isDeviceActive = currentPath === '/';
const isMonitorActive = currentPath === '/monitors';
diff --git a/web/src/pages/Public/MonitorDetail.tsx b/web/src/pages/Public/MonitorDetail.tsx
index 377e920..f187010 100644
--- a/web/src/pages/Public/MonitorDetail.tsx
+++ b/web/src/pages/Public/MonitorDetail.tsx
@@ -363,8 +363,8 @@ const MonitorDetail = () => {
];
return (
-
-
+
+
{/* Hero Section */}
@@ -654,10 +654,6 @@ const MonitorDetail = () => {
-
-
);
diff --git a/web/src/pages/Public/MonitorList.tsx b/web/src/pages/Public/MonitorList.tsx
index 4e3665b..f87ff5e 100644
--- a/web/src/pages/Public/MonitorList.tsx
+++ b/web/src/pages/Public/MonitorList.tsx
@@ -1,11 +1,10 @@
-import {useState} from 'react';
+import {useEffect} from 'react';
import {useNavigate} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {AlertCircle, CheckCircle2, Clock, Loader2, Shield} from 'lucide-react';
import {getPublicMonitors} from '../../api/monitor';
import type {PublicMonitor} from '../../types';
-import PublicHeader from '../../components/PublicHeader';
-import PublicFooter from '../../components/PublicFooter';
+import {usePublicLayout} from '../PublicLayout';
type ViewMode = 'grid' | 'list';
@@ -84,7 +83,13 @@ const EmptyState = () => (
const MonitorList = () => {
const navigate = useNavigate();
- const [viewMode, setViewMode] = useState
('grid');
+ const {viewMode, setShowViewToggle} = usePublicLayout();
+
+ // 挂载时启用视图切换,卸载时禁用
+ useEffect(() => {
+ setShowViewToggle(true);
+ return () => setShowViewToggle(false);
+ }, [setShowViewToggle]);
const {data: monitors = [], isLoading, dataUpdatedAt} = useQuery({
queryKey: ['publicMonitors'],
@@ -330,43 +335,21 @@ const MonitorList = () => {
if (isLoading) {
return (
-
-
-
-
-
-
-
-
+
+
);
}
return (
-
-
-
-
-
- {monitorSummaries.length === 0 ? (
-
- ) : viewMode === 'grid' ? (
- renderGridView()
- ) : (
- renderListView()
- )}
-
-
-
-
+
+ {monitorSummaries.length === 0 ? (
+
+ ) : viewMode === 'grid' ? (
+ renderGridView()
+ ) : (
+ renderListView()
+ )}
);
};
diff --git a/web/src/pages/Public/ServerDetail.tsx b/web/src/pages/Public/ServerDetail.tsx
index 3ede118..7fb4a7c 100644
--- a/web/src/pages/Public/ServerDetail.tsx
+++ b/web/src/pages/Public/ServerDetail.tsx
@@ -795,8 +795,8 @@ const ServerDetail = () => {
}
return (
-
-
+
+
{
)}
-
-
);
diff --git a/web/src/pages/Public/ServerList.tsx b/web/src/pages/Public/ServerList.tsx
index 5acefbf..afc0358 100644
--- a/web/src/pages/Public/ServerList.tsx
+++ b/web/src/pages/Public/ServerList.tsx
@@ -1,11 +1,10 @@
-import {type ReactNode, useState} from 'react';
+import {type ReactNode, useEffect} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useQuery} from '@tanstack/react-query';
import {Cpu, EthernetPortIcon, HardDrive, Loader2, MemoryStick, Network} from 'lucide-react';
import {listAgents} from '../../api/agent';
import type {Agent, LatestMetrics} from '../../types';
-import PublicHeader from '../../components/PublicHeader';
-import PublicFooter from '../../components/PublicFooter';
+import {usePublicLayout} from '../PublicLayout';
interface AgentWithMetrics extends Agent {
metrics?: LatestMetrics;
@@ -116,7 +115,13 @@ const getProgressColor = (percent: number) => {
const ServerList = () => {
const navigate = useNavigate();
- const [viewMode, setViewMode] = useState
('grid');
+ const {viewMode, setShowViewToggle} = usePublicLayout();
+
+ // 挂载时启用视图切换,卸载时禁用
+ useEffect(() => {
+ setShowViewToggle(true);
+ return () => setShowViewToggle(false);
+ }, [setShowViewToggle]);
const {data: agents = [], isLoading, dataUpdatedAt} = useQuery({
queryKey: ['agents', 'online'],
@@ -437,29 +442,17 @@ const ServerList = () => {
}
return (
-
-
-
-
-
- {filteredAgents.length === 0 ? (
-
- ) : viewMode === 'grid' ? (
- renderGridView()
- ) : (
- renderListView()
- )}
-
-
-
-
+
+ {filteredAgents.length === 0 ? (
+
+ ) : viewMode === 'grid' ? (
+ renderGridView()
+ ) : (
+ renderListView()
+ )}
);
};
diff --git a/web/src/pages/PublicLayout.tsx b/web/src/pages/PublicLayout.tsx
new file mode 100644
index 0000000..3e893b1
--- /dev/null
+++ b/web/src/pages/PublicLayout.tsx
@@ -0,0 +1,38 @@
+import {Outlet, useOutletContext} from 'react-router-dom';
+import {useState} from 'react';
+import PublicHeader from '../components/PublicHeader';
+import PublicFooter from '../components/PublicFooter';
+
+type ViewMode = 'grid' | 'list';
+
+interface PublicLayoutContextType {
+ viewMode: ViewMode;
+ setViewMode: (mode: ViewMode) => void;
+ showViewToggle: boolean;
+ setShowViewToggle: (show: boolean) => void;
+}
+
+const PublicLayout = () => {
+ const [viewMode, setViewMode] = useState
('grid');
+ const [showViewToggle, setShowViewToggle] = useState(false);
+
+ return (
+
+ );
+};
+
+export function usePublicLayout() {
+ return useOutletContext();
+}
+
+export default PublicLayout;
diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx
index 909b256..41435a7 100644
--- a/web/src/router/index.tsx
+++ b/web/src/router/index.tsx
@@ -5,6 +5,7 @@ import PrivateRoute from '../components/PrivateRoute';
const LoginPage = lazy(() => import('../pages/Login'));
const GitHubCallbackPage = lazy(() => import('../pages/Login/GitHubCallback'));
const OIDCCallbackPage = lazy(() => import('../pages/Login/OIDCCallback'));
+const PublicLayout = lazy(() => import('../pages/PublicLayout'));
const AdminLayout = lazy(() => import('../pages/AdminLayout'));
const AgentListPage = lazy(() => import('../pages/Agents/AgentList'));
const AgentDetailPage = lazy(() => import('../pages/Agents/AgentDetail'));
@@ -45,20 +46,25 @@ const router = createBrowserRouter([
},
// 公开页面 - 不需要登录
{
- path: '/',
- element: lazyLoad(ServerListPage),
- },
- {
- path: '/servers/:id',
- element: lazyLoad(ServerDetailPage),
- },
- {
- path: '/monitors',
- element: lazyLoad(PublicMonitorListPage),
- },
- {
- path: '/monitors/:id',
- element: lazyLoad(PublicMonitorDetailPage),
+ element: lazyLoad(PublicLayout),
+ children: [
+ {
+ path: '/',
+ element: lazyLoad(ServerListPage),
+ },
+ {
+ path: '/servers/:id',
+ element: lazyLoad(ServerDetailPage),
+ },
+ {
+ path: '/monitors',
+ element: lazyLoad(PublicMonitorListPage),
+ },
+ {
+ path: '/monitors/:id',
+ element: lazyLoad(PublicMonitorDetailPage),
+ },
+ ],
},
// 管理员页面 - 需要登录
{
From ab2bf4f293791d929a2eb232a8e2c9ef1c6bbc54 Mon Sep 17 00:00:00 2001
From: dushixiang
Date: Wed, 26 Nov 2025 22:52:28 +0800
Subject: [PATCH 11/27] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E5=85=A8?=
=?UTF-8?q?=E5=AE=A1=E8=AE=A1=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
go.mod | 2 +-
go.sum | 32 +-
internal/protocol/message.go | 33 +-
pkg/agent/audit/asset_file.go | 42 +++
pkg/agent/audit/asset_login.go | 386 +++++++++++++++++++++++
pkg/agent/audit/asset_process.go | 22 ++
pkg/agent/audit/audit.go | 22 +-
pkg/agent/audit/audit_test.go | 279 ----------------
web/src/api/agent.ts | 165 +++++++++-
web/src/pages/Agents/AuditResultView.tsx | 370 ++++++++++++++++++++--
10 files changed, 1010 insertions(+), 343 deletions(-)
create mode 100644 pkg/agent/audit/asset_login.go
delete mode 100644 pkg/agent/audit/audit_test.go
diff --git a/go.mod b/go.mod
index eea15e2..e5639fc 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
github.com/labstack/echo/v4 v4.13.4
github.com/prometheus-community/pro-bing v0.7.0
github.com/shirou/gopsutil/v4 v4.25.10
+ github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.1
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.45.0
@@ -58,7 +59,6 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
- github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
diff --git a/go.sum b/go.sum
index 7ea88ed..35b7af5 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
-github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
-github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -61,6 +59,8 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -150,25 +150,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
-go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
-golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
-golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
-golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
-golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
-golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
@@ -180,15 +171,12 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -210,20 +198,18 @@ gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
-modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
-modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
-modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
-modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
+modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
+modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
-modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
@@ -234,8 +220,6 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.40.0 h1:bNWEDlYhNPAUdUdBzjAvn8icAs/2gaKlj4vM+tQ6KdQ=
-modernc.org/sqlite v1.40.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
diff --git a/internal/protocol/message.go b/internal/protocol/message.go
index 89c88cc..d7bd696 100644
--- a/internal/protocol/message.go
+++ b/internal/protocol/message.go
@@ -321,6 +321,7 @@ type AssetInventory struct {
UserAssets *UserAssets `json:"userAssets,omitempty"` // 用户资产
FileAssets *FileAssets `json:"fileAssets,omitempty"` // 文件资产
KernelAssets *KernelAssets `json:"kernelAssets,omitempty"` // 内核资产
+ LoginAssets *LoginAssets `json:"loginAssets,omitempty"` // 登录资产
}
// AuditStatistics 审计统计摘要
@@ -329,6 +330,7 @@ type AuditStatistics struct {
ProcessStats *ProcessStatistics `json:"processStats,omitempty"` // 进程统计
UserStats *UserStatistics `json:"userStats,omitempty"` // 用户统计
FileStats *FileStatistics `json:"fileStats,omitempty"` // 文件统计
+ LoginStats *LoginStatistics `json:"loginStats,omitempty"` // 登录统计
}
// ==================== 网络资产 ====================
@@ -424,10 +426,11 @@ type NetworkStatistics struct {
// ProcessAssets 进程资产
type ProcessAssets struct {
- RunningProcesses []ProcessInfo `json:"runningProcesses,omitempty"` // 所有运行进程(可选)
- TopCPUProcesses []ProcessInfo `json:"topCpuProcesses,omitempty"` // CPU占用TOP进程
- TopMemoryProcesses []ProcessInfo `json:"topMemoryProcesses,omitempty"` // 内存占用TOP进程
- Statistics *ProcessStatistics `json:"statistics,omitempty"` // 统计信息
+ RunningProcesses []ProcessInfo `json:"runningProcesses,omitempty"` // 所有运行进程(可选)
+ TopCPUProcesses []ProcessInfo `json:"topCpuProcesses,omitempty"` // CPU占用TOP进程
+ TopMemoryProcesses []ProcessInfo `json:"topMemoryProcesses,omitempty"` // 内存占用TOP进程
+ SuspiciousProcesses []ProcessInfo `json:"suspiciousProcesses,omitempty"` // 可疑进程(如已删除exe)
+ Statistics *ProcessStatistics `json:"statistics,omitempty"` // 统计信息
}
// ProcessInfo 进程信息
@@ -443,6 +446,7 @@ type ProcessInfo struct {
MemoryMB uint64 `json:"memoryMb"` // 内存占用(MB)
Status string `json:"status,omitempty"` // 状态
CreateTime int64 `json:"createTime"` // 创建时间(毫秒)
+ ExeDeleted bool `json:"exeDeleted"` // 可执行文件是否已删除
}
// ProcessStatistics 进程统计
@@ -531,6 +535,7 @@ type FileAssets struct {
StartupScripts []StartupScript `json:"startupScripts,omitempty"` // 启动脚本
RecentModified []FileInfo `json:"recentModified,omitempty"` // 最近修改文件
LargeFiles []FileInfo `json:"largeFiles,omitempty"` // 大文件
+ TmpExecutables []FileInfo `json:"tmpExecutables,omitempty"` // 临时目录可执行文件
Statistics *FileStatistics `json:"statistics,omitempty"` // 统计信息
}
@@ -602,3 +607,23 @@ type SecurityModuleInfo struct {
AppArmorStatus string `json:"apparmorStatus,omitempty"` // AppArmor状态
SecureBootState string `json:"secureBootState,omitempty"` // 安全启动状态
}
+
+// ==================== 登录资产 ====================
+
+// LoginAssets 登录资产
+type LoginAssets struct {
+ SuccessfulLogins []LoginRecord `json:"successfulLogins,omitempty"` // 成功登录记录
+ FailedLogins []LoginRecord `json:"failedLogins,omitempty"` // 失败登录记录
+ CurrentSessions []LoginSession `json:"currentSessions,omitempty"` // 当前登录会话
+ Statistics *LoginStatistics `json:"statistics,omitempty"` // 统计信息
+}
+
+// LoginStatistics 登录统计
+type LoginStatistics struct {
+ TotalLogins int `json:"totalLogins"` // 总登录次数
+ FailedLogins int `json:"failedLogins"` // 失败登录次数
+ CurrentSessions int `json:"currentSessions"` // 当前会话数
+ UniqueIPs map[string]int `json:"uniqueIPs,omitempty"` // 唯一IP统计
+ UniqueUsers map[string]int `json:"uniqueUsers,omitempty"` // 唯一用户统计
+ HighFrequencyIPs map[string]int `json:"highFrequencyIPs,omitempty"` // 高频IP (登录次数>10)
+}
diff --git a/pkg/agent/audit/asset_file.go b/pkg/agent/audit/asset_file.go
index f2d5999..8775a61 100644
--- a/pkg/agent/audit/asset_file.go
+++ b/pkg/agent/audit/asset_file.go
@@ -44,6 +44,9 @@ func (fac *FileAssetsCollector) Collect() *protocol.FileAssets {
// 收集大文件
assets.LargeFiles = fac.collectLargeFiles()
+ // 收集临时目录可执行文件
+ assets.TmpExecutables = fac.collectTmpExecutables()
+
// 统计信息
assets.Statistics = fac.calculateStatistics(assets)
@@ -304,6 +307,45 @@ func (fac *FileAssetsCollector) collectLargeFiles() []protocol.FileInfo {
return files
}
+// collectTmpExecutables 收集临时目录下的可执行文件
+func (fac *FileAssetsCollector) collectTmpExecutables() []protocol.FileInfo {
+ var files []protocol.FileInfo
+
+ searchDirs := []string{"/tmp", "/dev/shm", "/var/tmp"}
+
+ for _, dir := range searchDirs {
+ filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil || info.IsDir() {
+ return nil
+ }
+
+ // 跳过符号链接
+ if info.Mode()&os.ModeSymlink != 0 {
+ return nil
+ }
+
+ // 检查是否可执行 (rwx)
+ if info.Mode()&0111 != 0 {
+ fileInfo := fac.convertToFileInfo(path, info)
+ files = append(files, fileInfo)
+ }
+
+ // 限制每个目录的数量,防止遍历太多
+ if len(files) >= 50 {
+ return filepath.SkipDir
+ }
+
+ return nil
+ })
+
+ if len(files) >= 50 {
+ break
+ }
+ }
+
+ return files
+}
+
// convertToFileInfo 转换为文件信息
func (fac *FileAssetsCollector) convertToFileInfo(path string, info os.FileInfo) protocol.FileInfo {
fileInfo := protocol.FileInfo{
diff --git a/pkg/agent/audit/asset_login.go b/pkg/agent/audit/asset_login.go
new file mode 100644
index 0000000..e145b22
--- /dev/null
+++ b/pkg/agent/audit/asset_login.go
@@ -0,0 +1,386 @@
+package audit
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/dushixiang/pika/internal/protocol"
+)
+
+// LoginAssetsCollector 登录日志收集器
+type LoginAssetsCollector struct {
+ config *Config
+ executor *CommandExecutor
+}
+
+// NewLoginAssetsCollector 创建登录日志收集器
+func NewLoginAssetsCollector(config *Config, executor *CommandExecutor) *LoginAssetsCollector {
+ return &LoginAssetsCollector{
+ config: config,
+ executor: executor,
+ }
+}
+
+// Collect 收集登录日志
+func (lac *LoginAssetsCollector) Collect() *protocol.LoginAssets {
+ assets := &protocol.LoginAssets{}
+
+ // 收集成功登录历史
+ assets.SuccessfulLogins = lac.collectSuccessfulLogins()
+
+ // 收集失败登录历史
+ assets.FailedLogins = lac.collectFailedLogins()
+
+ // 收集当前登录会话
+ assets.CurrentSessions = lac.collectCurrentSessions()
+
+ // 统计信息
+ assets.Statistics = lac.calculateStatistics(assets)
+
+ return assets
+}
+
+// collectSuccessfulLogins 收集成功登录历史
+func (lac *LoginAssetsCollector) collectSuccessfulLogins() []protocol.LoginRecord {
+ var records []protocol.LoginRecord
+
+ // 使用 last 命令获取登录历史
+ output, err := lac.executor.Execute("last", "-n", "100", "-F", "-w")
+ if err != nil {
+ globalLogger.Debug("获取登录历史失败: %v", err)
+ return records
+ }
+
+ lines := strings.Split(output, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+
+ // 跳过空行和特殊行
+ if line == "" || strings.HasPrefix(line, "wtmp") ||
+ strings.HasPrefix(line, "reboot") || strings.Contains(line, "system boot") {
+ continue
+ }
+
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ continue
+ }
+
+ username := fields[0]
+ terminal := fields[1]
+ ip := fields[2]
+
+ // 处理本地登录(没有IP的情况)
+ if ip == ":0" || ip == ":0.0" {
+ ip = "localhost"
+ } else if strings.HasPrefix(ip, ":") {
+ ip = "localhost" + ip
+ }
+
+ // 解析登录时间(简化处理)
+ timestamp := time.Now().UnixMilli()
+ if len(fields) >= 4 {
+ // 尝试解析时间字段
+ // 格式通常是: Mon Dec 25 10:30:00 2023
+ if len(fields) >= 8 {
+ timeStr := strings.Join(fields[3:8], " ")
+ if t, err := time.Parse("Mon Jan _2 15:04:05 2006", timeStr); err == nil {
+ timestamp = t.UnixMilli()
+ }
+ }
+ }
+
+ record := protocol.LoginRecord{
+ Username: username,
+ Terminal: terminal,
+ IP: ip,
+ Timestamp: timestamp,
+ Status: "success",
+ }
+
+ records = append(records, record)
+
+ // 限制数量
+ if len(records) >= 100 {
+ break
+ }
+ }
+
+ return records
+}
+
+// collectFailedLogins 收集失败登录历史
+func (lac *LoginAssetsCollector) collectFailedLogins() []protocol.LoginRecord {
+ var records []protocol.LoginRecord
+
+ // 使用 lastb 命令获取失败登录历史
+ output, err := lac.executor.Execute("lastb", "-n", "100", "-F", "-w")
+ if err != nil {
+ globalLogger.Debug("获取失败登录历史失败: %v (需要root权限)", err)
+
+ // 尝试从日志文件读取
+ records = lac.collectFailedLoginsFromAuthLog()
+ return records
+ }
+
+ lines := strings.Split(output, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+
+ // 跳过空行和特殊行
+ if line == "" || strings.HasPrefix(line, "btmp") {
+ continue
+ }
+
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ continue
+ }
+
+ username := fields[0]
+ terminal := fields[1]
+ ip := fields[2]
+
+ // 处理本地登录
+ if ip == ":0" || ip == ":0.0" {
+ ip = "localhost"
+ }
+
+ // 解析登录时间
+ timestamp := time.Now().UnixMilli()
+ if len(fields) >= 8 {
+ timeStr := strings.Join(fields[3:8], " ")
+ if t, err := time.Parse("Mon Jan _2 15:04:05 2006", timeStr); err == nil {
+ timestamp = t.UnixMilli()
+ }
+ }
+
+ record := protocol.LoginRecord{
+ Username: username,
+ Terminal: terminal,
+ IP: ip,
+ Timestamp: timestamp,
+ Status: "failed",
+ }
+
+ records = append(records, record)
+
+ // 限制数量
+ if len(records) >= 100 {
+ break
+ }
+ }
+
+ return records
+}
+
+// collectFailedLoginsFromAuthLog 从认证日志读取失败登录
+func (lac *LoginAssetsCollector) collectFailedLoginsFromAuthLog() []protocol.LoginRecord {
+ var records []protocol.LoginRecord
+
+ // 尝试读取不同的认证日志文件
+ authLogPaths := []string{
+ "/var/log/auth.log",
+ "/var/log/secure",
+ }
+
+ var authLog string
+ for _, path := range authLogPaths {
+ if _, err := os.Stat(path); err == nil {
+ authLog = path
+ break
+ }
+ }
+
+ if authLog == "" {
+ return records
+ }
+
+ file, err := os.Open(authLog)
+ if err != nil {
+ return records
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ count := 0
+
+ for scanner.Scan() && count < 100 {
+ line := scanner.Text()
+
+ // 查找失败的SSH登录
+ if strings.Contains(line, "Failed password") ||
+ strings.Contains(line, "authentication failure") {
+
+ record := lac.parseFailedLoginFromLog(line)
+ if record != nil {
+ records = append(records, *record)
+ count++
+ }
+ }
+ }
+
+ return records
+}
+
+// parseFailedLoginFromLog 从日志行解析失败登录
+func (lac *LoginAssetsCollector) parseFailedLoginFromLog(line string) *protocol.LoginRecord {
+ // 简化解析,提取用户名和IP
+ username := "unknown"
+ ip := "unknown"
+
+ // 提取用户名
+ if idx := strings.Index(line, "user "); idx != -1 {
+ rest := line[idx+5:]
+ if spaceIdx := strings.Index(rest, " "); spaceIdx != -1 {
+ username = rest[:spaceIdx]
+ }
+ } else if idx := strings.Index(line, "for "); idx != -1 {
+ rest := line[idx+4:]
+ if spaceIdx := strings.Index(rest, " "); spaceIdx != -1 {
+ username = rest[:spaceIdx]
+ }
+ }
+
+ // 提取IP地址
+ if idx := strings.Index(line, "from "); idx != -1 {
+ rest := line[idx+5:]
+ if spaceIdx := strings.Index(rest, " "); spaceIdx != -1 {
+ ip = rest[:spaceIdx]
+ } else {
+ ip = rest
+ }
+ }
+
+ return &protocol.LoginRecord{
+ Username: username,
+ IP: ip,
+ Terminal: "ssh",
+ Timestamp: time.Now().UnixMilli(),
+ Status: "failed",
+ }
+}
+
+// collectCurrentSessions 收集当前登录会话
+func (lac *LoginAssetsCollector) collectCurrentSessions() []protocol.LoginSession {
+ var sessions []protocol.LoginSession
+
+ // 使用 w 命令
+ output, err := lac.executor.Execute("w", "-h")
+ if err != nil {
+ globalLogger.Debug("获取当前登录失败: %v", err)
+ return sessions
+ }
+
+ lines := strings.Split(output, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ fields := strings.Fields(line)
+ if len(fields) < 4 {
+ continue
+ }
+
+ username := fields[0]
+ terminal := fields[1]
+ fromIP := fields[2]
+
+ // 处理本地会话
+ if fromIP == "-" || fromIP == "" {
+ fromIP = "localhost"
+ }
+
+ // 解析空闲时间
+ idleStr := fields[3]
+ idleSeconds := lac.parseIdleTime(idleStr)
+
+ // 解析登录时间(从空闲时间推算)
+ loginTime := time.Now().Add(-time.Duration(idleSeconds) * time.Second).UnixMilli()
+
+ session := protocol.LoginSession{
+ Username: username,
+ Terminal: terminal,
+ IP: fromIP,
+ LoginTime: loginTime,
+ IdleTime: idleSeconds,
+ }
+
+ sessions = append(sessions, session)
+ }
+
+ return sessions
+}
+
+// parseIdleTime 解析空闲时间字符串
+func (lac *LoginAssetsCollector) parseIdleTime(idleStr string) int {
+ if idleStr == "-" || idleStr == "?" {
+ return 0
+ }
+
+ // 支持格式: "1.00s", "2:30", "1:00m", "3days"
+ idleStr = strings.TrimSpace(idleStr)
+
+ // 秒
+ if strings.HasSuffix(idleStr, "s") {
+ var seconds float64
+ if _, err := fmt.Sscanf(idleStr, "%f", &seconds); err == nil {
+ return int(seconds)
+ }
+ }
+
+ // 分:秒 或 时:分
+ if strings.Contains(idleStr, ":") {
+ parts := strings.Split(idleStr, ":")
+ if len(parts) == 2 {
+ var hours, minutes int
+ if _, err := fmt.Sscanf(idleStr, "%d:%d", &hours, &minutes); err == nil {
+ return hours*3600 + minutes*60
+ }
+ }
+ }
+
+ // 天
+ if strings.Contains(idleStr, "day") {
+ var days int
+ if _, err := fmt.Sscanf(idleStr, "%dday", &days); err == nil {
+ return days * 86400
+ }
+ }
+
+ return 0
+}
+
+// calculateStatistics 计算统计信息
+func (lac *LoginAssetsCollector) calculateStatistics(assets *protocol.LoginAssets) *protocol.LoginStatistics {
+ stats := &protocol.LoginStatistics{
+ TotalLogins: len(assets.SuccessfulLogins),
+ FailedLogins: len(assets.FailedLogins),
+ CurrentSessions: len(assets.CurrentSessions),
+ UniqueIPs: make(map[string]int),
+ UniqueUsers: make(map[string]int),
+ }
+
+ // 统计唯一IP和用户
+ for _, login := range assets.SuccessfulLogins {
+ stats.UniqueIPs[login.IP]++
+ stats.UniqueUsers[login.Username]++
+ }
+
+ // 查找高频IP
+ for ip, count := range stats.UniqueIPs {
+ if count > 10 {
+ if stats.HighFrequencyIPs == nil {
+ stats.HighFrequencyIPs = make(map[string]int)
+ }
+ stats.HighFrequencyIPs[ip] = count
+ }
+ }
+
+ return stats
+}
diff --git a/pkg/agent/audit/asset_process.go b/pkg/agent/audit/asset_process.go
index 8f8fa3d..74e74e7 100644
--- a/pkg/agent/audit/asset_process.go
+++ b/pkg/agent/audit/asset_process.go
@@ -2,6 +2,7 @@ package audit
import (
"sort"
+ "strings"
"github.com/dushixiang/pika/internal/protocol"
"github.com/shirou/gopsutil/v4/process"
@@ -33,10 +34,16 @@ func (pac *ProcessAssetsCollector) Collect() *protocol.ProcessAssets {
// 收集所有进程信息
var allProcesses []protocol.ProcessInfo
+ var suspiciousProcesses []protocol.ProcessInfo
+
for _, p := range procs {
procInfo := pac.convertToProcessInfo(p)
if procInfo != nil {
allProcesses = append(allProcesses, *procInfo)
+ // 检查是否可疑
+ if procInfo.ExeDeleted {
+ suspiciousProcesses = append(suspiciousProcesses, *procInfo)
+ }
}
}
@@ -46,6 +53,9 @@ func (pac *ProcessAssetsCollector) Collect() *protocol.ProcessAssets {
// TOP 内存进程
assets.TopMemoryProcesses = pac.getTopProcesses(allProcesses, "memory", 15)
+ // 可疑进程
+ assets.SuspiciousProcesses = suspiciousProcesses
+
// 可选:完整进程列表 (配置控制)
// 暂时不包含完整列表,避免数据过大
// assets.RunningProcesses = allProcesses
@@ -79,6 +89,17 @@ func (pac *ProcessAssetsCollector) convertToProcessInfo(p *process.Process) *pro
memoryMB = memInfo.RSS / 1024 / 1024
}
+ // 检查 Exe 是否已删除
+ exeDeleted := false
+ if exe != "" {
+ // Linux 下已删除的 exe 通常以 " (deleted)" 结尾
+ // 注意:gopsutil 可能会处理这个,但为了保险起见我们自己检查
+ if strings.HasSuffix(exe, " (deleted)") || strings.Contains(exe, "; rm ") {
+ exeDeleted = true
+ }
+ // TODO: 可以增加更多检查逻辑,如 exe 指向 /tmp 或 /dev/shm
+ }
+
return &protocol.ProcessInfo{
PID: p.Pid,
Name: name,
@@ -91,6 +112,7 @@ func (pac *ProcessAssetsCollector) convertToProcessInfo(p *process.Process) *pro
MemoryMB: memoryMB,
Status: status[0], // 取第一个状态
CreateTime: createTime,
+ ExeDeleted: exeDeleted,
}
}
diff --git a/pkg/agent/audit/audit.go b/pkg/agent/audit/audit.go
index ba30152..3406fdb 100644
--- a/pkg/agent/audit/audit.go
+++ b/pkg/agent/audit/audit.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"runtime"
+ "sync"
"time"
"github.com/dushixiang/pika/internal/protocol"
@@ -29,6 +30,7 @@ type Auditor struct {
userAssetsCollector *UserAssetsCollector
fileAssetsCollector *FileAssetsCollector
kernelAssetsCollector *KernelAssetsCollector
+ loginAssetsCollector *LoginAssetsCollector
}
// NewAuditor 创建审计器
@@ -52,6 +54,7 @@ func NewAuditor(config *Config) *Auditor {
userAssetsCollector: NewUserAssetsCollector(config, executor),
fileAssetsCollector: NewFileAssetsCollector(config, executor),
kernelAssetsCollector: NewKernelAssetsCollector(config, executor),
+ loginAssetsCollector: NewLoginAssetsCollector(config, executor),
}
}
@@ -136,13 +139,22 @@ func (a *Auditor) collectAssets() *protocol.AssetInventory {
{"内核资产", func() {
inventory.KernelAssets = a.kernelAssetsCollector.Collect()
}},
+ {"登录资产", func() {
+ inventory.LoginAssets = a.loginAssetsCollector.Collect()
+ }},
}
- // 串行执行(避免资源竞争,实际可以优化为并发)
+ // 并发执行
+ var wg sync.WaitGroup
for _, task := range tasks {
- globalLogger.Debug("收集%s...", task.name)
- task.fn()
+ wg.Add(1)
+ go func(t assetTask) {
+ defer wg.Done()
+ globalLogger.Debug("收集%s...", t.name)
+ t.fn()
+ }(task)
}
+ wg.Wait()
return inventory
}
@@ -167,6 +179,10 @@ func (a *Auditor) calculateStatistics(inventory *protocol.AssetInventory) *proto
stats.FileStats = inventory.FileAssets.Statistics
}
+ if inventory.LoginAssets != nil {
+ stats.LoginStats = inventory.LoginAssets.Statistics
+ }
+
return stats
}
diff --git a/pkg/agent/audit/audit_test.go b/pkg/agent/audit/audit_test.go
deleted file mode 100644
index ac37ff2..0000000
--- a/pkg/agent/audit/audit_test.go
+++ /dev/null
@@ -1,279 +0,0 @@
-package audit
-
-import (
- "os"
- "testing"
- "time"
-)
-
-func TestDefaultConfig(t *testing.T) {
- config := DefaultConfig()
-
- if config == nil {
- t.Fatal("DefaultConfig returned nil")
- }
-
- // 验证关键配置
- if config.ProcessConfig.HighCPUThreshold != 70.0 {
- t.Errorf("Expected HighCPUThreshold to be 70.0, got %f", config.ProcessConfig.HighCPUThreshold)
- }
-
- if len(config.ProcessConfig.MinerKeywords) == 0 {
- t.Error("MinerKeywords should not be empty")
- }
-
- if config.ScoringConfig.CriticalThreshold != 80 {
- t.Errorf("Expected CriticalThreshold to be 80, got %d", config.ScoringConfig.CriticalThreshold)
- }
-}
-
-func TestProcessCache(t *testing.T) {
- cache := NewProcessCache(100 * time.Millisecond)
-
- // 第一次获取
- procs1, err := cache.Get()
- if err != nil {
- t.Fatalf("Failed to get processes: %v", err)
- }
-
- if len(procs1) == 0 {
- t.Error("Expected processes, got empty list")
- }
-
- // 第二次获取(应该从缓存)
- procs2, err := cache.Get()
- if err != nil {
- t.Fatalf("Failed to get cached processes: %v", err)
- }
-
- // 应该是相同的引用
- if len(procs1) != len(procs2) {
- t.Error("Cache should return same process list")
- }
-
- // 等待缓存过期
- time.Sleep(150 * time.Millisecond)
-
- // 获取新的进程列表
- procs3, err := cache.Get()
- if err != nil {
- t.Fatalf("Failed to get refreshed processes: %v", err)
- }
-
- if len(procs3) == 0 {
- t.Error("Expected processes after cache expiry")
- }
-}
-
-func TestCommandExecutor(t *testing.T) {
- executor := NewCommandExecutor(5 * time.Second)
-
- // 测试成功的命令
- output, err := executor.Execute("echo", "hello")
- if err != nil {
- t.Fatalf("Failed to execute command: %v", err)
- }
-
- if output != "hello\n" {
- t.Errorf("Expected 'hello\\n', got '%s'", output)
- }
-
- // 测试超时
- executor2 := NewCommandExecutor(100 * time.Millisecond)
- _, err = executor2.Execute("sleep", "1")
- if err == nil {
- t.Error("Expected timeout error")
- }
-}
-
-func TestFileHashCache(t *testing.T) {
- cache := NewFileHashCache()
-
- // 创建临时文件
- tmpFile, err := os.CreateTemp("", "test-hash-*.txt")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile.Name())
-
- tmpFile.WriteString("test content")
- tmpFile.Close()
-
- // 第一次计算
- hash1 := cache.GetSHA256(tmpFile.Name())
- if hash1 == "" {
- t.Error("Expected hash, got empty string")
- }
-
- // 第二次应该从缓存
- hash2 := cache.GetSHA256(tmpFile.Name())
- if hash1 != hash2 {
- t.Error("Cache should return same hash")
- }
-
- // 修改文件
- os.WriteFile(tmpFile.Name(), []byte("modified content"), 0644)
-
- // 应该计算新的哈希
- hash3 := cache.GetSHA256(tmpFile.Name())
- if hash3 == hash1 {
- t.Error("Hash should be different after file modification")
- }
-}
-
-func TestStringUtils(t *testing.T) {
- su := &StringUtils{}
-
- // 测试 Truncate
- if result := su.Truncate("hello", 10); result != "hello" {
- t.Errorf("Expected 'hello', got '%s'", result)
- }
-
- if result := su.Truncate("hello world", 5); result != "hello" {
- t.Errorf("Expected 'hello', got '%s'", result)
- }
-
- // 测试 ContainsAny
- keywords := []string{"foo", "bar", "baz"}
-
- if !su.ContainsAny("hello foobar", keywords) {
- t.Error("Expected true for 'hello foobar'")
- }
-
- if su.ContainsAny("hello world", keywords) {
- t.Error("Expected false for 'hello world'")
- }
-
- // 测试大小写不敏感
- if !su.ContainsAny("HELLO FOO", keywords) {
- t.Error("Expected true for case-insensitive match")
- }
-}
-
-func TestIsLocalIP(t *testing.T) {
- tests := []struct {
- ip string
- expected bool
- }{
- {"127.0.0.1", true},
- {"::1", true},
- {"localhost", true},
- {"192.168.1.1", true},
- {"10.0.0.1", true},
- {"172.16.0.1", true},
- {"8.8.8.8", false},
- {"1.1.1.1", false},
- }
-
- for _, tt := range tests {
- result := IsLocalIP(tt.ip)
- if result != tt.expected {
- t.Errorf("IsLocalIP(%s) = %v, expected %v", tt.ip, result, tt.expected)
- }
- }
-}
-
-func TestBatchProcessor(t *testing.T) {
- bp := NewBatchProcessor(3)
-
- items := []string{"a", "b", "c", "d", "e", "f", "g"}
- processed := []string{}
-
- err := bp.Process(items, func(batch []string) error {
- processed = append(processed, batch...)
- return nil
- })
-
- if err != nil {
- t.Fatalf("Process failed: %v", err)
- }
-
- if len(processed) != len(items) {
- t.Errorf("Expected %d items, got %d", len(items), len(processed))
- }
-
- // 验证顺序
- for i, item := range items {
- if processed[i] != item {
- t.Errorf("Order mismatch at index %d: expected %s, got %s", i, item, processed[i])
- }
- }
-}
-
-func TestWarningCollector(t *testing.T) {
- wc := NewWarningCollector()
-
- wc.Add("warning 1")
- wc.Add("warning 2")
- wc.Add("warning 3")
-
- warnings := wc.GetAll()
-
- if len(warnings) != 3 {
- t.Errorf("Expected 3 warnings, got %d", len(warnings))
- }
-
- if warnings[0] != "warning 1" {
- t.Errorf("Expected 'warning 1', got '%s'", warnings[0])
- }
-}
-
-// 集成测试(需要 root 权限和 Linux 环境)
-func TestAuditorIntegration(t *testing.T) {
- // 跳过如果不是 Linux 或没有 root 权限
- if os.Getenv("RUN_INTEGRATION_TESTS") != "true" {
- t.Skip("Skipping integration test. Set RUN_INTEGRATION_TESTS=true to run")
- }
-
- auditor := NewAuditor(nil)
-
- result, err := auditor.RunAudit()
- if err != nil {
- t.Fatalf("RunAudit failed: %v", err)
- }
-
- if result == nil {
- t.Fatal("Expected result, got nil")
- }
-
- if len(result.SecurityChecks) == 0 {
- t.Error("Expected security checks, got none")
- }
-
- if result.RiskScore < 0 || result.RiskScore > 100 {
- t.Errorf("Invalid risk score: %d", result.RiskScore)
- }
-
- if result.ThreatLevel == "" {
- t.Error("Threat level should not be empty")
- }
-
- t.Logf("Audit completed in %dms", result.EndTime-result.StartTime)
- t.Logf("Risk Score: %d, Threat Level: %s", result.RiskScore, result.ThreatLevel)
- t.Logf("Security Checks: %d", len(result.SecurityChecks))
-}
-
-// 性能基准测试
-func BenchmarkProcessCache(b *testing.B) {
- cache := NewProcessCache(1 * time.Second)
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- cache.Get()
- }
-}
-
-func BenchmarkFileHashCache(b *testing.B) {
- cache := NewFileHashCache()
-
- // 创建临时文件
- tmpFile, _ := os.CreateTemp("", "bench-*.txt")
- tmpFile.WriteString("test content for benchmarking")
- tmpFile.Close()
- defer os.Remove(tmpFile.Name())
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- cache.GetSHA256(tmpFile.Name())
- }
-}
diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts
index e63748c..8cc2fb3 100644
--- a/web/src/api/agent.ts
+++ b/web/src/api/agent.ts
@@ -137,10 +137,63 @@ export interface ListeningPort {
isPublic: boolean;
}
+export interface NetworkConnection {
+ protocol: string;
+ localAddr: string;
+ localPort: number;
+ remoteAddr: string;
+ remotePort: number;
+ state: string;
+ processPid?: number;
+ processName?: string;
+}
+
+export interface NetworkInterface {
+ name: string;
+ macAddress: string;
+ addresses?: string[];
+ mtu: number;
+ isUp: boolean;
+ flags?: string[];
+}
+
+export interface RouteEntry {
+ destination: string;
+ gateway: string;
+ genmask: string;
+ interface: string;
+ metric: number;
+}
+
+export interface FirewallRule {
+ chain: string;
+ target: string;
+ protocol?: string;
+ source?: string;
+ dest?: string;
+ port?: string;
+}
+
+export interface FirewallInfo {
+ type: string;
+ status: string;
+ rules?: FirewallRule[];
+}
+
+export interface ARPEntry {
+ ipAddress: string;
+ macAddress: string;
+ interface: string;
+}
+
export interface NetworkAssets {
listeningPorts?: ListeningPort[];
- connections?: any[];
- interfaces?: any[];
+ connections?: NetworkConnection[];
+ interfaces?: NetworkInterface[];
+ routingTable?: RouteEntry[];
+ firewallRules?: FirewallInfo;
+ dnsServers?: string[];
+ arpTable?: ARPEntry[];
}
export interface ProcessInfo {
@@ -152,11 +205,14 @@ export interface ProcessInfo {
cpuPercent: number;
memPercent: number;
memoryMb: number;
+ exeDeleted?: boolean;
}
export interface ProcessAssets {
+ runningProcesses?: ProcessInfo[];
topCpuProcesses?: ProcessInfo[];
topMemoryProcesses?: ProcessInfo[];
+ suspiciousProcesses?: ProcessInfo[];
}
export interface UserInfo {
@@ -170,25 +226,109 @@ export interface UserInfo {
hasPassword: boolean;
}
+export interface LoginRecord {
+ username: string;
+ ip?: string;
+ terminal: string;
+ timestamp: number;
+ status?: string;
+}
+
+export interface LoginSession {
+ username: string;
+ terminal: string;
+ ip: string;
+ loginTime: number;
+ idleTime: number;
+}
+
+export interface SSHKeyInfo {
+ username: string;
+ keyType: string;
+ fingerprint: string;
+ comment?: string;
+ filePath: string;
+ addedTime?: number;
+}
+
+export interface SudoUserInfo {
+ username: string;
+ rules?: string;
+ noPasswd: boolean;
+}
+
export interface UserAssets {
systemUsers?: UserInfo[];
- loginHistory?: any[];
- sshKeys?: any[];
- sudoUsers?: any[];
+ loginHistory?: LoginRecord[];
+ currentLogins?: LoginSession[];
+ sshKeys?: SSHKeyInfo[];
+ sudoUsers?: SudoUserInfo[];
+}
+
+export interface LoginAssets {
+ successfulLogins?: LoginRecord[];
+ failedLogins?: LoginRecord[];
+ currentSessions?: LoginSession[];
+}
+
+export interface FileInfo {
+ path: string;
+ size: number;
+ modTime: number;
+ permissions?: string;
+ owner?: string;
+ group?: string;
+ isExecutable: boolean;
+}
+
+export interface CronJob {
+ user: string;
+ schedule: string;
+ command: string;
+ filePath?: string;
+}
+
+export interface SystemdService {
+ name: string;
+ state?: string;
+ enabled: boolean;
+ execStart?: string;
+ description?: string;
+ unitFile?: string;
+}
+
+export interface StartupScript {
+ type: string;
+ path: string;
+ name: string;
+ enabled: boolean;
}
export interface FileAssets {
- cronJobs?: any[];
- systemdServices?: any[];
- startupScripts?: any[];
- recentModified?: any[];
- largeFiles?: any[];
+ cronJobs?: CronJob[];
+ systemdServices?: SystemdService[];
+ startupScripts?: StartupScript[];
+ recentModified?: FileInfo[];
+ largeFiles?: FileInfo[];
+ tmpExecutables?: FileInfo[];
+}
+
+export interface KernelModule {
+ name: string;
+ size: number;
+ usedBy: number;
+}
+
+export interface SecurityModuleInfo {
+ selinuxStatus?: string;
+ apparmorStatus?: string;
+ secureBootState?: string;
}
export interface KernelAssets {
- loadedModules?: any[];
+ loadedModules?: KernelModule[];
kernelParameters?: Record;
- securityModules?: any;
+ securityModules?: SecurityModuleInfo;
}
export interface AssetInventory {
@@ -197,6 +337,7 @@ export interface AssetInventory {
userAssets?: UserAssets;
fileAssets?: FileAssets;
kernelAssets?: KernelAssets;
+ loginAssets?: LoginAssets;
}
export interface AuditStatistics {
diff --git a/web/src/pages/Agents/AuditResultView.tsx b/web/src/pages/Agents/AuditResultView.tsx
index 7f6e57b..e980082 100644
--- a/web/src/pages/Agents/AuditResultView.tsx
+++ b/web/src/pages/Agents/AuditResultView.tsx
@@ -13,9 +13,11 @@ import {
Activity,
Calendar,
Settings,
- PlayCircle
+ PlayCircle,
+ AlertOctagon,
+ LogIn
} from 'lucide-react';
-import type {VPSAuditResult, VPSAuditAnalysis} from '../../api/agent';
+import type {VPSAuditResult, ProcessInfo, FileInfo, LoginRecord, LoginSession, SSHKeyInfo, SudoUserInfo} from '../../api/agent';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
@@ -23,11 +25,98 @@ dayjs.extend(duration);
const {Panel} = Collapse;
+interface RiskItem {
+ level: 'critical' | 'high' | 'medium' | 'low';
+ title: string;
+ description: string;
+}
+
interface AuditResultViewProps {
result: VPSAuditResult;
}
const AuditResultView = ({result}: AuditResultViewProps) => {
+ const analyzeRisks = (result: VPSAuditResult): RiskItem[] => {
+ const risks: RiskItem[] = [];
+
+ // 1. Suspicious Processes
+ if (result.assetInventory.processAssets?.suspiciousProcesses?.length) {
+ risks.push({
+ level: 'critical',
+ title: '发现可疑进程',
+ description: `发现 ${result.assetInventory.processAssets.suspiciousProcesses.length} 个可执行文件已删除的进程`
+ });
+ }
+
+ // 2. Tmp Executables
+ if (result.assetInventory.fileAssets?.tmpExecutables?.length) {
+ risks.push({
+ level: 'high',
+ title: '发现临时目录可执行文件',
+ description: `在 /tmp 或 /dev/shm 目录下发现 ${result.assetInventory.fileAssets.tmpExecutables.length} 个可执行文件`
+ });
+ }
+
+ // 3. Root Equivalent Users
+ const rootEquivUsers = result.assetInventory.userAssets?.systemUsers?.filter(u => u.isRootEquiv);
+ if (rootEquivUsers?.length) {
+ risks.push({
+ level: 'high',
+ title: '存在Root权限用户',
+ description: `发现 ${rootEquivUsers.length} 个UID为0的非root用户: ${rootEquivUsers.map(u => u.username).join(', ')}`
+ });
+ }
+
+ // 4. Loginable Users with No Password
+ const noPwdUsers = result.assetInventory.userAssets?.systemUsers?.filter(u => u.isLoginable && !u.hasPassword);
+ if (noPwdUsers?.length) {
+ risks.push({
+ level: 'medium',
+ title: '存在无密码可登录用户',
+ description: `发现 ${noPwdUsers.length} 个用户无密码且可登录: ${noPwdUsers.map(u => u.username).join(', ')}`
+ });
+ }
+
+ // 5. Failed Login Attempts
+ const failedLogins = result.assetInventory.loginAssets?.failedLogins?.length || 0;
+ if (failedLogins > 50) {
+ risks.push({
+ level: 'high',
+ title: '存在大量失败登录尝试',
+ description: `发现 ${failedLogins} 次失败登录尝试,可能存在暴力破解攻击`
+ });
+ } else if (failedLogins > 20) {
+ risks.push({
+ level: 'medium',
+ title: '存在失败登录尝试',
+ description: `发现 ${failedLogins} 次失败登录尝试`
+ });
+ }
+
+ // 6. High Frequency Login IPs
+ if (result.assetInventory.loginAssets?.successfulLogins) {
+ const ipCount: Record = {};
+ result.assetInventory.loginAssets.successfulLogins.forEach(login => {
+ if (login.ip && login.ip !== 'localhost') {
+ ipCount[login.ip] = (ipCount[login.ip] || 0) + 1;
+ }
+ });
+
+ const highFreqIPs = Object.entries(ipCount).filter(([_, count]) => count > 30);
+ if (highFreqIPs.length > 0) {
+ risks.push({
+ level: 'medium',
+ title: '发现高频登录IP',
+ description: `以下IP登录次数异常频繁: ${highFreqIPs.map(([ip, count]) => `${ip}(${count}次)`).join(', ')}`
+ });
+ }
+ }
+
+ return risks;
+ };
+
+ const risks = analyzeRisks(result);
+
const getStatusIcon = (status: string) => {
switch (status) {
case 'pass':
@@ -108,15 +197,15 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
{
title: '本地地址',
key: 'local',
- render: (record: any) => `${record.laddr?.ip || ''}:${record.laddr?.port || ''}`,
+ render: (record: any) => `${record.localAddr || ''}:${record.localPort || ''}`,
},
{
title: '远程地址',
key: 'remote',
- render: (record: any) => `${record.raddr?.ip || ''}:${record.raddr?.port || ''}`,
+ render: (record: any) => `${record.remoteAddr || ''}:${record.remotePort || ''}`,
},
- {title: '状态', dataIndex: 'status', key: 'status', width: 120},
- {title: 'PID', dataIndex: 'pid', key: 'pid', width: 80},
+ {title: '状态', dataIndex: 'state', key: 'state', width: 120},
+ {title: 'PID', dataIndex: 'processPid', key: 'processPid', width: 80},
{title: '进程', dataIndex: 'processName', key: 'processName'},
];
@@ -168,6 +257,76 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
},
];
+ const loginRecordColumns = [
+ {title: '用户名', dataIndex: 'username', key: 'username', width: 120},
+ {title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 150},
+ {title: '终端', dataIndex: 'terminal', key: 'terminal', width: 100},
+ {
+ title: '登录时间',
+ dataIndex: 'timestamp',
+ key: 'timestamp',
+ render: (val: number) => dayjs(val).format('YYYY-MM-DD HH:mm:ss')
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 80,
+ render: (status: string) => status === 'success' ?
+ 成功 : 失败
+ },
+ ];
+
+ const currentSessionColumns = [
+ {title: '用户名', dataIndex: 'username', key: 'username', width: 120},
+ {title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 150},
+ {title: '终端', dataIndex: 'terminal', key: 'terminal', width: 100},
+ {
+ title: '登录时间',
+ dataIndex: 'loginTime',
+ key: 'loginTime',
+ render: (val: number) => dayjs(val).format('YYYY-MM-DD HH:mm:ss')
+ },
+ {
+ title: '空闲时间',
+ dataIndex: 'idleTime',
+ key: 'idleTime',
+ width: 100,
+ render: (seconds: number) => {
+ if (seconds === 0) return '-';
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+ }
+ },
+ ];
+
+ const sshKeyColumns = [
+ {title: '用户', dataIndex: 'username', key: 'username', width: 120},
+ {title: '密钥类型', dataIndex: 'keyType', key: 'keyType', width: 120},
+ {title: '指纹', dataIndex: 'fingerprint', key: 'fingerprint', ellipsis: true},
+ {title: '注释', dataIndex: 'comment', key: 'comment', ellipsis: true},
+ {
+ title: '添加时间',
+ dataIndex: 'addedTime',
+ key: 'addedTime',
+ width: 180,
+ render: (val: number) => val ? dayjs(val).format('YYYY-MM-DD HH:mm:ss') : '-'
+ },
+ ];
+
+ const sudoUserColumns = [
+ {title: '用户名', dataIndex: 'username', key: 'username', width: 150},
+ {
+ title: '免密码',
+ dataIndex: 'noPasswd',
+ key: 'noPasswd',
+ width: 100,
+ render: (val: boolean) => val ? 是 : 否
+ },
+ {title: '规则', dataIndex: 'rules', key: 'rules', ellipsis: true},
+ ];
+
const cronColumns = [
{title: '用户', dataIndex: 'user', key: 'user', width: 100},
{title: '计划', dataIndex: 'schedule', key: 'schedule', width: 150},
@@ -210,6 +369,27 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
return (
+ {/* 风险概览 */}
+ {risks.length > 0 && (
+ 风险概览}
+ className="border-red-200 bg-red-50"
+ >
+ (
+
+ {item.title}}
+ description={item.description}
+ />
+
+ )}
+ />
+
+ )}
+
{/* 系统信息 */}
系统信息}
@@ -274,7 +454,7 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
size="small"
dataSource={result.assetInventory.networkAssets.connections}
columns={connectionColumns}
- rowKey={(record, index) => `${record.laddr?.ip}:${record.laddr?.port}-${record.raddr?.ip}:${record.raddr?.port}-${index}`}
+ rowKey={(record, index) => `${record.localAddr}:${record.localPort}-${record.remoteAddr}:${record.remotePort}-${index}`}
pagination={false}
/>
) : (
@@ -304,6 +484,34 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
label: 进程资产,
children: (
+ {result.assetInventory.processAssets?.suspiciousProcesses && result.assetInventory.processAssets.suspiciousProcesses.length > 0 && (
+
+ )}
+ 可疑进程}>
+ {result.assetInventory.processAssets?.suspiciousProcesses?.length ? (
+ record.exeDeleted ? Exe已删除 : null
+ }
+ ]}
+ rowKey="pid"
+ pagination={false}
+ />
+ ) : (
+
+ )}
+
{result.assetInventory.processAssets?.topCpuProcesses?.length ? (
{
key: 'user',
label: 用户资产,
children: (
-
- {result.assetInventory.userAssets?.systemUsers?.length ? (
-
- ) : (
-
- )}
-
+
+
+ {result.assetInventory.userAssets?.systemUsers?.length ? (
+
+ ) : (
+
+ )}
+
+
+ {result.assetInventory.userAssets?.sshKeys?.length ? (
+ `${record.username}-${index}`}
+ pagination={false}
+ />
+ ) : (
+
+ )}
+
+
+ {result.assetInventory.userAssets?.sudoUsers?.length ? (
+
+ ) : (
+
+ )}
+
+
+ {result.assetInventory.userAssets?.currentLogins?.length ? (
+ `${record.username}-${record.terminal}-${index}`}
+ pagination={false}
+ />
+ ) : (
+
+ )}
+
+
),
},
{
@@ -357,6 +606,32 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
label: 文件资产,
children: (
+ {result.assetInventory.fileAssets?.tmpExecutables && result.assetInventory.fileAssets.tmpExecutables.length > 0 && (
+
+ )}
+ 临时目录可执行文件}>
+ {result.assetInventory.fileAssets?.tmpExecutables?.length ? (
+ `${(val / 1024).toFixed(2)} KB`},
+ {title: '修改时间', dataIndex: 'modTime', key: 'modTime', render: (val: number) => dayjs(val).format('YYYY-MM-DD HH:mm:ss')},
+ {title: '权限', dataIndex: 'permissions', key: 'permissions'},
+ ]}
+ rowKey="path"
+ pagination={false}
+ />
+ ) : (
+
+ )}
+
定时任务}>
{result.assetInventory.fileAssets?.cronJobs?.length ? (
{
),
},
+ {
+ key: 'login',
+ label: 登录日志,
+ children: (
+
+ {result.assetInventory.loginAssets?.failedLogins && result.assetInventory.loginAssets.failedLogins.length > 0 && (
+
+ )}
+
+ {result.assetInventory.loginAssets?.successfulLogins?.length ? (
+ `success-${index}`}
+ pagination={{pageSize: 20}}
+ />
+ ) : (
+
+ )}
+
+ 失败登录历史}>
+ {result.assetInventory.loginAssets?.failedLogins?.length ? (
+ `failed-${index}`}
+ pagination={{pageSize: 20}}
+ />
+ ) : (
+
+ )}
+
+
+ {result.assetInventory.loginAssets?.currentSessions?.length ? (
+ `session-${index}`}
+ pagination={false}
+ />
+ ) : (
+
+ )}
+
+
+ ),
+ },
{
key: 'kernel',
label: 内核信息,
From 919111502f1a0fe12d2799f578c61dfd9d180e5d Mon Sep 17 00:00:00 2001
From: dushixiang
Date: Wed, 26 Nov 2025 23:18:22 +0800
Subject: [PATCH 12/27] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E5=85=A8?=
=?UTF-8?q?=E5=AE=A1=E8=AE=A1=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/protocol/message.go | 17 ++
pkg/agent/audit/asset_file.go | 90 ++++++++--
pkg/agent/audit/asset_login.go | 92 +++++++---
pkg/agent/audit/asset_user.go | 100 +++++++++++
web/src/api/agent.ts | 16 ++
web/src/pages/Agents/AuditResultView.tsx | 216 +++++++++++++++++++++--
6 files changed, 481 insertions(+), 50 deletions(-)
diff --git a/internal/protocol/message.go b/internal/protocol/message.go
index d7bd696..604c434 100644
--- a/internal/protocol/message.go
+++ b/internal/protocol/message.go
@@ -467,6 +467,7 @@ type UserAssets struct {
CurrentLogins []LoginSession `json:"currentLogins,omitempty"` // 当前登录
SSHKeys []SSHKeyInfo `json:"sshKeys,omitempty"` // SSH密钥
SudoUsers []SudoUserInfo `json:"sudoUsers,omitempty"` // Sudo用户
+ SSHConfig *SSHConfig `json:"sshConfig,omitempty"` // SSH配置
Statistics *UserStatistics `json:"statistics,omitempty"` // 统计信息
}
@@ -517,6 +518,22 @@ type SudoUserInfo struct {
NoPasswd bool `json:"noPasswd"` // 是否免密
}
+// SSHConfig SSH配置信息
+type SSHConfig struct {
+ Port int `json:"port"` // SSH端口
+ PermitRootLogin string `json:"permitRootLogin"` // 是否允许root登录 (yes/no/prohibit-password)
+ PasswordAuthentication bool `json:"passwordAuthentication"` // 是否允许密码认证
+ PubkeyAuthentication bool `json:"pubkeyAuthentication"` // 是否允许公钥认证
+ PermitEmptyPasswords bool `json:"permitEmptyPasswords"` // 是否允许空密码
+ Protocol string `json:"protocol,omitempty"` // 协议版本
+ MaxAuthTries int `json:"maxAuthTries,omitempty"` // 最大认证尝试次数
+ ClientAliveInterval int `json:"clientAliveInterval,omitempty"` // 客户端保活间隔
+ ClientAliveCountMax int `json:"clientAliveCountMax,omitempty"` // 客户端保活最大次数
+ X11Forwarding bool `json:"x11Forwarding,omitempty"` // 是否允许X11转发
+ UsePAM bool `json:"usePAM,omitempty"` // 是否使用PAM
+ ConfigFilePath string `json:"configFilePath,omitempty"` // 配置文件路径
+}
+
// UserStatistics 用户统计
type UserStatistics struct {
TotalUsers int `json:"totalUsers"` // 用户总数
diff --git a/pkg/agent/audit/asset_file.go b/pkg/agent/audit/asset_file.go
index 8775a61..9a5323b 100644
--- a/pkg/agent/audit/asset_file.go
+++ b/pkg/agent/audit/asset_file.go
@@ -141,8 +141,8 @@ func (fac *FileAssetsCollector) parseCronFile(filePath string, defaultUser strin
func (fac *FileAssetsCollector) collectSystemdServices() []protocol.SystemdService {
var services []protocol.SystemdService
- // 使用 systemctl list-units
- output, err := fac.executor.Execute("systemctl", "list-units", "--type=service", "--all", "--no-pager")
+ // 使用 systemctl list-units 获取服务列表
+ output, err := fac.executor.Execute("systemctl", "list-units", "--type=service", "--all", "--no-pager", "--no-legend")
if err != nil {
globalLogger.Debug("获取systemd服务失败: %v", err)
return services
@@ -151,7 +151,7 @@ func (fac *FileAssetsCollector) collectSystemdServices() []protocol.SystemdServi
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "UNIT") || strings.HasPrefix(line, "●") {
+ if line == "" {
continue
}
@@ -165,21 +165,33 @@ func (fac *FileAssetsCollector) collectSystemdServices() []protocol.SystemdServi
continue
}
- state := ""
- if len(fields) > 2 {
- state = fields[2]
+ // 加载状态和活动状态
+ loadState := fields[1] // loaded/not-found
+ activeState := fields[2] // active/inactive/failed
+ // subState := fields[3] // running/dead/exited (目前未使用)
+
+ // 获取服务的详细信息
+ service := protocol.SystemdService{
+ Name: serviceName,
+ State: activeState,
}
- service := protocol.SystemdService{
- Name: serviceName,
- State: state,
- Enabled: strings.Contains(line, "enabled"),
+ // 判断是否启用(开机自启)
+ if loadState == "loaded" {
+ enabledOutput, err := fac.executor.Execute("systemctl", "is-enabled", serviceName)
+ if err == nil {
+ enabledStatus := strings.TrimSpace(enabledOutput)
+ service.Enabled = enabledStatus == "enabled"
+ }
}
+ // 获取服务详细信息
+ fac.enrichServiceInfo(&service)
+
services = append(services, service)
// 限制数量
- if len(services) >= 100 {
+ if len(services) >= 150 {
break
}
}
@@ -187,6 +199,62 @@ func (fac *FileAssetsCollector) collectSystemdServices() []protocol.SystemdServi
return services
}
+// enrichServiceInfo 丰富服务信息
+func (fac *FileAssetsCollector) enrichServiceInfo(service *protocol.SystemdService) {
+ // 使用 systemctl show 获取详细信息
+ output, err := fac.executor.Execute("systemctl", "show", service.Name, "--no-pager")
+ if err != nil {
+ return
+ }
+
+ lines := strings.Split(output, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ // 解析 Key=Value 格式
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ key := parts[0]
+ value := parts[1]
+
+ switch key {
+ case "Description":
+ service.Description = value
+ case "ExecStart":
+ // ExecStart 格式: { path=/usr/bin/xxx ; argv[]=/usr/bin/xxx ... }
+ // 提取可执行路径
+ if strings.Contains(value, "path=") {
+ start := strings.Index(value, "path=")
+ if start != -1 {
+ start += 5
+ end := strings.Index(value[start:], " ")
+ if end != -1 {
+ service.ExecStart = value[start : start+end]
+ } else {
+ // 可能到结尾了
+ end = strings.Index(value[start:], ";")
+ if end != -1 {
+ service.ExecStart = value[start : start+end]
+ }
+ }
+ }
+ }
+ // 如果没有 path=,尝试直接提取
+ if service.ExecStart == "" && value != "" {
+ service.ExecStart = value
+ }
+ case "FragmentPath":
+ service.UnitFile = value
+ }
+ }
+}
+
// collectStartupScripts 收集启动脚本
func (fac *FileAssetsCollector) collectStartupScripts() []protocol.StartupScript {
var scripts []protocol.StartupScript
diff --git a/pkg/agent/audit/asset_login.go b/pkg/agent/audit/asset_login.go
index e145b22..6392930 100644
--- a/pkg/agent/audit/asset_login.go
+++ b/pkg/agent/audit/asset_login.go
@@ -80,18 +80,8 @@ func (lac *LoginAssetsCollector) collectSuccessfulLogins() []protocol.LoginRecor
ip = "localhost" + ip
}
- // 解析登录时间(简化处理)
- timestamp := time.Now().UnixMilli()
- if len(fields) >= 4 {
- // 尝试解析时间字段
- // 格式通常是: Mon Dec 25 10:30:00 2023
- if len(fields) >= 8 {
- timeStr := strings.Join(fields[3:8], " ")
- if t, err := time.Parse("Mon Jan _2 15:04:05 2006", timeStr); err == nil {
- timestamp = t.UnixMilli()
- }
- }
- }
+ // 解析登录时间
+ timestamp := lac.parseLoginTime(fields)
record := protocol.LoginRecord{
Username: username,
@@ -112,6 +102,38 @@ func (lac *LoginAssetsCollector) collectSuccessfulLogins() []protocol.LoginRecor
return records
}
+// parseLoginTime 解析登录时间
+func (lac *LoginAssetsCollector) parseLoginTime(fields []string) int64 {
+ // last -F 输出格式示例:
+ // username pts/0 192.168.1.1 Mon Dec 25 10:30:00 2023 - Mon Dec 25 11:00:00 2023
+ // 时间在第4-8个字段
+
+ if len(fields) < 8 {
+ return time.Now().UnixMilli()
+ }
+
+ // 尝试多种时间格式
+ timeFormats := []string{
+ "Mon Jan _2 15:04:05 2006", // 标准格式
+ "Mon Jan 2 15:04:05 2006", // 不带下划线
+ "2006-01-02 15:04:05", // ISO格式
+ }
+
+ // 从第3个字段开始(索引2),因为前面是 username terminal ip
+ timeStr := strings.Join(fields[3:8], " ")
+
+ for _, format := range timeFormats {
+ if t, err := time.Parse(format, timeStr); err == nil {
+ // 使用本地时区解析时间
+ return t.UnixMilli()
+ }
+ }
+
+ // 如果解析失败,返回当前时间
+ globalLogger.Debug("无法解析登录时间: %s", timeStr)
+ return time.Now().UnixMilli()
+}
+
// collectFailedLogins 收集失败登录历史
func (lac *LoginAssetsCollector) collectFailedLogins() []protocol.LoginRecord {
var records []protocol.LoginRecord
@@ -150,13 +172,7 @@ func (lac *LoginAssetsCollector) collectFailedLogins() []protocol.LoginRecord {
}
// 解析登录时间
- timestamp := time.Now().UnixMilli()
- if len(fields) >= 8 {
- timeStr := strings.Join(fields[3:8], " ")
- if t, err := time.Parse("Mon Jan _2 15:04:05 2006", timeStr); err == nil {
- timestamp = t.UnixMilli()
- }
- }
+ timestamp := lac.parseLoginTime(fields)
record := protocol.LoginRecord{
Username: username,
@@ -255,15 +271,51 @@ func (lac *LoginAssetsCollector) parseFailedLoginFromLog(line string) *protocol.
}
}
+ // 尝试解析日志时间
+ // syslog 格式: Dec 25 10:30:00
+ timestamp := lac.parseSyslogTime(line)
+
return &protocol.LoginRecord{
Username: username,
IP: ip,
Terminal: "ssh",
- Timestamp: time.Now().UnixMilli(),
+ Timestamp: timestamp,
Status: "failed",
}
}
+// parseSyslogTime 解析syslog时间格式
+func (lac *LoginAssetsCollector) parseSyslogTime(line string) int64 {
+ // syslog 时间格式通常在行首: Dec 25 10:30:00
+ fields := strings.Fields(line)
+ if len(fields) < 3 {
+ return time.Now().UnixMilli()
+ }
+
+ // 获取当前年份(syslog不包含年份)
+ currentYear := time.Now().Year()
+
+ // 尝试解析: Month Day Time
+ timeStr := fmt.Sprintf("%s %s %s %d", fields[0], fields[1], fields[2], currentYear)
+
+ formats := []string{
+ "Jan _2 15:04:05 2006",
+ "Jan 2 15:04:05 2006",
+ }
+
+ for _, format := range formats {
+ if t, err := time.Parse(format, timeStr); err == nil {
+ // 如果解析的时间比当前时间晚,说明是去年的日志
+ if t.After(time.Now()) {
+ t = t.AddDate(-1, 0, 0)
+ }
+ return t.UnixMilli()
+ }
+ }
+
+ return time.Now().UnixMilli()
+}
+
// collectCurrentSessions 收集当前登录会话
func (lac *LoginAssetsCollector) collectCurrentSessions() []protocol.LoginSession {
var sessions []protocol.LoginSession
diff --git a/pkg/agent/audit/asset_user.go b/pkg/agent/audit/asset_user.go
index 3f503b8..b657026 100644
--- a/pkg/agent/audit/asset_user.go
+++ b/pkg/agent/audit/asset_user.go
@@ -44,6 +44,9 @@ func (uac *UserAssetsCollector) Collect() *protocol.UserAssets {
// 收集Sudo用户
assets.SudoUsers = uac.collectSudoUsers()
+ // 收集SSH配置
+ assets.SSHConfig = uac.collectSSHConfig()
+
// 统计信息
assets.Statistics = uac.calculateStatistics(assets)
@@ -369,6 +372,103 @@ func (uac *UserAssetsCollector) getAllUserDirectories() []string {
return dirs
}
+// collectSSHConfig 收集SSH配置
+func (uac *UserAssetsCollector) collectSSHConfig() *protocol.SSHConfig {
+ configPath := "/etc/ssh/sshd_config"
+
+ // 检查配置文件是否存在
+ if _, err := os.Stat(configPath); err != nil {
+ globalLogger.Debug("SSH配置文件不存在: %v", err)
+ return nil
+ }
+
+ file, err := os.Open(configPath)
+ if err != nil {
+ globalLogger.Debug("读取SSH配置失败: %v", err)
+ return nil
+ }
+ defer file.Close()
+
+ // 默认值(OpenSSH默认配置)
+ config := &protocol.SSHConfig{
+ Port: 22,
+ PermitRootLogin: "prohibit-password", // 默认值因发行版而异
+ PasswordAuthentication: true,
+ PubkeyAuthentication: true,
+ PermitEmptyPasswords: false,
+ MaxAuthTries: 6,
+ ClientAliveInterval: 0,
+ ClientAliveCountMax: 3,
+ X11Forwarding: false,
+ UsePAM: true,
+ ConfigFilePath: configPath,
+ }
+
+ // 解析配置文件
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+
+ // 跳过注释和空行
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ // 解析配置项
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ continue
+ }
+
+ key := strings.ToLower(fields[0])
+ value := strings.Join(fields[1:], " ")
+
+ switch key {
+ case "port":
+ if port := parseInt(value); port > 0 {
+ config.Port = port
+ }
+ case "permitrootlogin":
+ config.PermitRootLogin = strings.ToLower(value)
+ case "passwordauthentication":
+ config.PasswordAuthentication = parseBool(value)
+ case "pubkeyauthentication":
+ config.PubkeyAuthentication = parseBool(value)
+ case "permitemptypasswords":
+ config.PermitEmptyPasswords = parseBool(value)
+ case "protocol":
+ config.Protocol = value
+ case "maxauthtries":
+ if tries := parseInt(value); tries > 0 {
+ config.MaxAuthTries = tries
+ }
+ case "clientaliveinterval":
+ config.ClientAliveInterval = parseInt(value)
+ case "clientalivecountmax":
+ config.ClientAliveCountMax = parseInt(value)
+ case "x11forwarding":
+ config.X11Forwarding = parseBool(value)
+ case "usepam":
+ config.UsePAM = parseBool(value)
+ }
+ }
+
+ return config
+}
+
+// parseInt 解析整数
+func parseInt(s string) int {
+ var result int
+ fmt.Sscanf(s, "%d", &result)
+ return result
+}
+
+// parseBool 解析布尔值
+func parseBool(s string) bool {
+ s = strings.ToLower(s)
+ return s == "yes" || s == "true" || s == "1"
+}
+
// calculateStatistics 计算统计信息
func (uac *UserAssetsCollector) calculateStatistics(assets *protocol.UserAssets) *protocol.UserStatistics {
stats := &protocol.UserStatistics{
diff --git a/web/src/api/agent.ts b/web/src/api/agent.ts
index 8cc2fb3..5ab7432 100644
--- a/web/src/api/agent.ts
+++ b/web/src/api/agent.ts
@@ -257,12 +257,28 @@ export interface SudoUserInfo {
noPasswd: boolean;
}
+export interface SSHConfig {
+ port: number;
+ permitRootLogin: string;
+ passwordAuthentication: boolean;
+ pubkeyAuthentication: boolean;
+ permitEmptyPasswords: boolean;
+ protocol?: string;
+ maxAuthTries?: number;
+ clientAliveInterval?: number;
+ clientAliveCountMax?: number;
+ x11Forwarding?: boolean;
+ usePAM?: boolean;
+ configFilePath?: string;
+}
+
export interface UserAssets {
systemUsers?: UserInfo[];
loginHistory?: LoginRecord[];
currentLogins?: LoginSession[];
sshKeys?: SSHKeyInfo[];
sudoUsers?: SudoUserInfo[];
+ sshConfig?: SSHConfig;
}
export interface LoginAssets {
diff --git a/web/src/pages/Agents/AuditResultView.tsx b/web/src/pages/Agents/AuditResultView.tsx
index e980082..00f7da7 100644
--- a/web/src/pages/Agents/AuditResultView.tsx
+++ b/web/src/pages/Agents/AuditResultView.tsx
@@ -20,6 +20,7 @@ import {
import type {VPSAuditResult, ProcessInfo, FileInfo, LoginRecord, LoginSession, SSHKeyInfo, SudoUserInfo} from '../../api/agent';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
+import React from 'react';
dayjs.extend(duration);
@@ -36,6 +37,22 @@ interface AuditResultViewProps {
}
const AuditResultView = ({result}: AuditResultViewProps) => {
+ // 判断是否为内网IP
+ const isPrivateIP = (ip: string): boolean => {
+ if (!ip || ip === 'localhost') return true;
+
+ // IPv4 私有地址
+ const privateRanges = [
+ /^10\./, // 10.0.0.0/8
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
+ /^192\.168\./, // 192.168.0.0/16
+ /^127\./, // 127.0.0.0/8 (loopback)
+ /^169\.254\./, // 169.254.0.0/16 (link-local)
+ ];
+
+ return privateRanges.some(range => range.test(ip));
+ };
+
const analyzeRisks = (result: VPSAuditResult): RiskItem[] => {
const risks: RiskItem[] = [];
@@ -67,47 +84,118 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
});
}
- // 4. Loginable Users with No Password
- const noPwdUsers = result.assetInventory.userAssets?.systemUsers?.filter(u => u.isLoginable && !u.hasPassword);
+ // 4. Loginable Users with No Password (排除系统用户)
+ const systemUsernames = ['sync', 'shutdown', 'halt', 'daemon', 'bin', 'sys', 'adm', 'games', 'ftp', 'nobody', 'systemd-network', 'systemd-resolve'];
+ const noPwdUsers = result.assetInventory.userAssets?.systemUsers?.filter(u => {
+ // 必须可登录且无密码
+ if (!u.isLoginable || u.hasPassword) return false;
+
+ // 排除系统用户 (UID < 1000)
+ const uid = parseInt(u.uid);
+ if (!isNaN(uid) && uid < 1000) return false;
+
+ // 排除常见系统用户名
+ if (systemUsernames.includes(u.username)) return false;
+
+ return true;
+ });
+
if (noPwdUsers?.length) {
risks.push({
level: 'medium',
title: '存在无密码可登录用户',
- description: `发现 ${noPwdUsers.length} 个用户无密码且可登录: ${noPwdUsers.map(u => u.username).join(', ')}`
+ description: `发现 ${noPwdUsers.length} 个普通用户无密码且可登录: ${noPwdUsers.map(u => u.username).join(', ')}`
});
}
// 5. Failed Login Attempts
const failedLogins = result.assetInventory.loginAssets?.failedLogins?.length || 0;
- if (failedLogins > 50) {
+ if (failedLogins > 100) {
risks.push({
level: 'high',
title: '存在大量失败登录尝试',
description: `发现 ${failedLogins} 次失败登录尝试,可能存在暴力破解攻击`
});
- } else if (failedLogins > 20) {
+ } else if (failedLogins > 50) {
risks.push({
level: 'medium',
title: '存在失败登录尝试',
- description: `发现 ${failedLogins} 次失败登录尝试`
+ description: `发现 ${failedLogins} 次失败登录尝试,建议检查日志`
});
}
- // 6. High Frequency Login IPs
+ // 6. SSH Configuration Security Issues
+ const sshConfig = result.assetInventory.userAssets?.sshConfig;
+ if (sshConfig) {
+ // 允许root密码登录
+ if (sshConfig.permitRootLogin === 'yes' && sshConfig.passwordAuthentication) {
+ risks.push({
+ level: 'high',
+ title: 'SSH允许root密码登录',
+ description: 'SSH配置允许root用户使用密码登录,存在被暴力破解的风险,建议设置为 prohibit-password 或 no'
+ });
+ }
+
+ // 允许空密码登录
+ if (sshConfig.permitEmptyPasswords) {
+ risks.push({
+ level: 'critical',
+ title: 'SSH允许空密码登录',
+ description: 'SSH配置允许空密码登录,严重的安全隐患!'
+ });
+ }
+
+ // 仅使用密码认证,没有启用公钥认证
+ if (sshConfig.passwordAuthentication && !sshConfig.pubkeyAuthentication) {
+ risks.push({
+ level: 'medium',
+ title: 'SSH仅启用密码认证',
+ description: 'SSH配置仅启用密码认证,建议启用公钥认证以提高安全性'
+ });
+ }
+
+ // 使用旧协议
+ if (sshConfig.protocol && sshConfig.protocol.includes('1')) {
+ risks.push({
+ level: 'high',
+ title: 'SSH使用不安全的协议版本',
+ description: 'SSH配置使用Protocol 1,存在安全漏洞,应仅使用Protocol 2'
+ });
+ }
+ }
+
+ // 7. High Frequency Login IPs (区分内外网)
if (result.assetInventory.loginAssets?.successfulLogins) {
- const ipCount: Record = {};
+ const publicIPCount: Record = {};
+ const privateIPCount: Record = {};
+
result.assetInventory.loginAssets.successfulLogins.forEach(login => {
- if (login.ip && login.ip !== 'localhost') {
- ipCount[login.ip] = (ipCount[login.ip] || 0) + 1;
+ if (!login.ip || login.ip === 'localhost') return;
+
+ if (isPrivateIP(login.ip)) {
+ privateIPCount[login.ip] = (privateIPCount[login.ip] || 0) + 1;
+ } else {
+ publicIPCount[login.ip] = (publicIPCount[login.ip] || 0) + 1;
}
});
- const highFreqIPs = Object.entries(ipCount).filter(([_, count]) => count > 30);
- if (highFreqIPs.length > 0) {
+ // 外网IP:超过20次就警告
+ const highFreqPublicIPs = Object.entries(publicIPCount).filter(([_, count]) => count > 20);
+ if (highFreqPublicIPs.length > 0) {
risks.push({
level: 'medium',
- title: '发现高频登录IP',
- description: `以下IP登录次数异常频繁: ${highFreqIPs.map(([ip, count]) => `${ip}(${count}次)`).join(', ')}`
+ title: '发现高频外网IP登录',
+ description: `以下公网IP登录次数较多: ${highFreqPublicIPs.map(([ip, count]) => `${ip}(${count}次)`).join(', ')}`
+ });
+ }
+
+ // 内网IP:超过100次才警告(内网环境下频繁登录是正常的)
+ const highFreqPrivateIPs = Object.entries(privateIPCount).filter(([_, count]) => count > 100);
+ if (highFreqPrivateIPs.length > 0) {
+ risks.push({
+ level: 'low',
+ title: '内网IP登录次数较多',
+ description: `以下内网IP登录次数较多: ${highFreqPrivateIPs.map(([ip, count]) => `${ip}(${count}次)`).join(', ')}`
});
}
}
@@ -334,9 +422,20 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
{title: '文件路径', dataIndex: 'filePath', key: 'filePath', width: 200, ellipsis: true},
];
+ const [showAllServices, setShowAllServices] = React.useState(false);
+
const serviceColumns = [
{title: '服务名', dataIndex: 'name', key: 'name'},
- {title: '状态', dataIndex: 'state', key: 'state', width: 100},
+ {
+ title: '状态',
+ dataIndex: 'state',
+ key: 'state',
+ width: 100,
+ render: (state: string) => {
+ const color = state === 'active' ? 'success' : state === 'failed' ? 'error' : 'default';
+ return {state || '-'};
+ }
+ },
{
title: '开机启动',
dataIndex: 'enabled',
@@ -348,6 +447,15 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
{title: '描述', dataIndex: 'description', key: 'description', ellipsis: true},
];
+ // 过滤 systemd 服务
+ const filteredServices = React.useMemo(() => {
+ const services = result.assetInventory.fileAssets?.systemdServices || [];
+ if (showAllServices) {
+ return services;
+ }
+ return services.filter(s => s.state === 'active');
+ }, [result.assetInventory.fileAssets?.systemdServices, showAllServices]);
+
const startupScriptColumns = [
{title: '类型', dataIndex: 'type', key: 'type', width: 150},
{title: '名称', dataIndex: 'name', key: 'name'},
@@ -598,6 +706,59 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
)}
+ {result.assetInventory.userAssets?.sshConfig && (
+
+
+
+ {result.assetInventory.userAssets.sshConfig.port}
+ {result.assetInventory.userAssets.sshConfig.port !== 22 && (
+ 非标准端口
+ )}
+
+
+ {result.assetInventory.userAssets.sshConfig.permitRootLogin === 'yes' ? (
+ 允许
+ ) : result.assetInventory.userAssets.sshConfig.permitRootLogin === 'prohibit-password' ? (
+ 仅密钥
+ ) : (
+ 禁止
+ )}
+
+
+ {result.assetInventory.userAssets.sshConfig.passwordAuthentication ? (
+ 启用
+ ) : (
+ 禁用
+ )}
+
+
+ {result.assetInventory.userAssets.sshConfig.pubkeyAuthentication ? (
+ 启用
+ ) : (
+ 禁用
+ )}
+
+
+ {result.assetInventory.userAssets.sshConfig.permitEmptyPasswords ? (
+ 允许
+ ) : (
+ 禁止
+ )}
+
+
+ {result.assetInventory.userAssets.sshConfig.maxAuthTries || '-'}
+
+ {result.assetInventory.userAssets.sshConfig.protocol && (
+
+ {result.assetInventory.userAssets.sshConfig.protocol}
+ {result.assetInventory.userAssets.sshConfig.protocol.includes('1') && (
+ 不安全
+ )}
+
+ )}
+
+
+ )}
),
},
@@ -645,17 +806,34 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
)}
- Systemd服务}>
- {result.assetInventory.fileAssets?.systemdServices?.length ? (
+ Systemd服务}
+ extra={
+
+
+ 共 {result.assetInventory.fileAssets?.systemdServices?.length || 0} 个服务
+ {!showAllServices && ` (显示 ${filteredServices.length} 个活跃服务)`}
+
+
+
+ }
+ >
+ {filteredServices.length ? (
) : (
-
+
)}
启动脚本}>
From 0379b8064c076651e4c5420832ac0a909572c06f Mon Sep 17 00:00:00 2001
From: dushixiang
Date: Wed, 26 Nov 2025 23:27:11 +0800
Subject: [PATCH 13/27] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=89=E5=85=A8?=
=?UTF-8?q?=E5=AE=A1=E8=AE=A1=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
web/src/pages/Agents/AuditResultView.tsx | 36 ++++++++++++------------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/web/src/pages/Agents/AuditResultView.tsx b/web/src/pages/Agents/AuditResultView.tsx
index 00f7da7..fb3214c 100644
--- a/web/src/pages/Agents/AuditResultView.tsx
+++ b/web/src/pages/Agents/AuditResultView.tsx
@@ -127,15 +127,6 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
// 6. SSH Configuration Security Issues
const sshConfig = result.assetInventory.userAssets?.sshConfig;
if (sshConfig) {
- // 允许root密码登录
- if (sshConfig.permitRootLogin === 'yes' && sshConfig.passwordAuthentication) {
- risks.push({
- level: 'high',
- title: 'SSH允许root密码登录',
- description: 'SSH配置允许root用户使用密码登录,存在被暴力破解的风险,建议设置为 prohibit-password 或 no'
- });
- }
-
// 允许空密码登录
if (sshConfig.permitEmptyPasswords) {
risks.push({
@@ -145,15 +136,6 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
});
}
- // 仅使用密码认证,没有启用公钥认证
- if (sshConfig.passwordAuthentication && !sshConfig.pubkeyAuthentication) {
- risks.push({
- level: 'medium',
- title: 'SSH仅启用密码认证',
- description: 'SSH配置仅启用密码认证,建议启用公钥认证以提高安全性'
- });
- }
-
// 使用旧协议
if (sshConfig.protocol && sshConfig.protocol.includes('1')) {
risks.push({
@@ -162,6 +144,24 @@ const AuditResultView = ({result}: AuditResultViewProps) => {
description: 'SSH配置使用Protocol 1,存在安全漏洞,应仅使用Protocol 2'
});
}
+
+ // 允许root密码登录
+ if (sshConfig.permitRootLogin === 'yes' && sshConfig.passwordAuthentication) {
+ risks.push({
+ level: 'medium',
+ title: 'SSH允许root密码登录',
+ description: 'SSH配置允许root用户使用密码登录,建议配合fail2ban等防护措施,或设置为 prohibit-password'
+ });
+ }
+
+ // 仅使用密码认证,没有启用公钥认证
+ if (sshConfig.passwordAuthentication && !sshConfig.pubkeyAuthentication) {
+ risks.push({
+ level: 'low',
+ title: 'SSH仅启用密码认证',
+ description: 'SSH配置仅启用密码认证,建议同时启用公钥认证以提高安全性'
+ });
+ }
}
// 7. High Frequency Login IPs (区分内外网)
From 35b2934d0a1a263c8e5d3adcd7b6bc5231e155bf Mon Sep 17 00:00:00 2001
From: dushixiang
Date: Wed, 26 Nov 2025 23:55:49 +0800
Subject: [PATCH 14/27] =?UTF-8?q?=E5=AE=8C=E5=96=84=20README.md?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 52 +++++++++++++++++++++++++++++++++++-----
screenshots/deploy.png | Bin 98069 -> 0 bytes
screenshots/public2.png | Bin 190639 -> 0 bytes
3 files changed, 46 insertions(+), 6 deletions(-)
delete mode 100644 screenshots/deploy.png
delete mode 100644 screenshots/public2.png
diff --git a/README.md b/README.md
index 765b63c..3cfe724 100644
--- a/README.md
+++ b/README.md
@@ -4,20 +4,54 @@
一个基于 Go + PostgreSQL 的实时探针监控系统
-[快速开始](#快速开始) • [功能特性](#功能特性) • [文档](#文档) • [架构](#架构)
+[快速开始](#快速开始) • [功能特性](#功能特性) • [文档](#文档)
## 简介
-Pika 是一个轻量级的探针监控系统,支持实时数据采集、存储和查询。系统采用 WebSocket 进行探针与服务端的通信,使用 PostgreSQL 存储时序数据。
+Pika 是一个轻量级的探针监控系统,支持实时数据采集、存储和查询。系统采用 WebSocket 进行探针与服务端的通信,使用 PostgreSQL 存储时序数据。除了基础监控功能外,还提供 Linux 应急响应和安全基线检查能力,帮助快速发现和分析系统安全风险。
+
+## 功能特性
+
+### 📊 实时性能监控
+
+- 系统资源监控:CPU、内存、磁盘、网络、GPU、温度等指标
+- 时序数据查询:支持多种时间范围(5分钟、15分钟、30分钟、1小时),实时刷新和历史趋势分析
+
+### 🔍 服务监控
+
+- HTTP/HTTPS 监控:支持状态码检查、响应时间测量、内容匹配、HTTPS 证书到期检测
+- TCP 端口监控:检测端口连通性和响应时间
+- ICMP/Ping 监控:测量网络延迟和丢包率
+
+### 🛡️ 防篡改保护
+
+- 文件保护:保护关键目录,防止未授权修改
+- 实时监控:基于 fsnotify 实时监控受保护目录的文件变动
+- 属性巡检:定期检查不可变属性,自动恢复被篡改的保护
+- 事件告警:实时上报文件变动事件和属性篡改告警
+
+### 🔒 安全审计与应急响应
+
+- **资产清单收集**:支持收集网络资产(端口、连接、防火墙)、进程资产、用户资产(SSH配置、密钥)、登录日志、文件资产(Cron、服务、启动脚本)、内核资产等
+- **安全风险分析**:自动检测登录异常、可疑进程、用户权限风险、SSH配置安全问题等,并按严重程度分级(Critical/High/Medium/Low)
+- **历史审计记录**:保存审计历史,支持查询和对比
+
+### 🔐 认证与授权
+
+- 支持多种认证方式:Basic Auth(bcrypt)、OIDC、GitHub OAuth
+- 灵活的权限管理:管理员权限、公开页面、JWT Token 认证
+
+### 📦 部署与运维
+
+- Docker Compose 一键部署,数据持久化到 PostgreSQL
+- 灵活的 YAML 配置文件,支持网卡过滤和数据保留策略
## 截图

-
-
## 快速开始
@@ -210,9 +244,15 @@ docker-compose restart
#### 端口冲突
-如果 8080 或 5432 端口被占用,修改 `docker-compose.yml` 中的端口映射:
+如果 8080 端口被占用,修改 `docker-compose.yml` 中的端口映射:
```yaml
ports:
- "8081:8080" # 将 8080 改为其他端口
-```
\ No newline at end of file
+```
+
+#### 网卡过滤
+
+如果探针采集到了很多网卡,说明默认的过滤规则已经不适用于你的环境了。
+
+你可以参考 [agent.example.yaml](cmd/agent/agent.example.yaml) 修改 `collector` 下的 `network_include` 或者 `network_exclude` 配置。
\ No newline at end of file
diff --git a/screenshots/deploy.png b/screenshots/deploy.png
deleted file mode 100644
index 9ee0c2810b8bc08d035ab07d307b6f6f00b960f4..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 98069
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
z