0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆) - 新增包: license/memory/modelquota/assistant,扫描引擎 - 全部功能后端+前端+端到端验证通过(25 包单测)
This commit is contained in:
@@ -3,6 +3,8 @@ package workbench
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aigateway.local/core/internal/identity"
|
||||
"aigateway.local/core/internal/platform/apiresponse"
|
||||
@@ -32,6 +34,8 @@ func NewMarketplaceAdminHTTPHandler(market *MarketplaceService, mcpServers *MCPS
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/mcp-servers/{id}", h.updateMCPServer)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/mcp-servers/{id}", h.deleteMCPServer)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/mcp-servers/{id}/test", h.testMCPServer)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/mcp-servers/{id}/scan", h.scanMCP)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/skills/{id}/scan", h.scanSkill)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/skills", h.listSkills)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/skills", h.createSkill)
|
||||
h.mux.HandleFunc("GET /api/v1/admin/skills/{id}", h.getSkill)
|
||||
@@ -433,3 +437,45 @@ func (h *MarketplaceAdminHTTPHandler) marketplaceCatalog(w http.ResponseWriter,
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
|
||||
// scanMCP 对 MCP 服务器定义执行供应链静态扫描。
|
||||
func (h *MarketplaceAdminHTTPHandler) scanMCP(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionMCPServerManage); !ok {
|
||||
return
|
||||
}
|
||||
server, err := h.mcpServers.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusNotFound, "MCP 服务器不存在")
|
||||
return
|
||||
}
|
||||
content := strings.Join([]string{server.Name, server.Description, server.EndpointURL}, "\n")
|
||||
if len(server.EncryptedHeaders) > 0 {
|
||||
content += "\n[配置了加密请求头]"
|
||||
}
|
||||
apiresponse.OK(w, map[string]any{
|
||||
"findings": ScanResource(content),
|
||||
"highest": HighestSeverity(ScanResource(content)),
|
||||
"scanned_at": time.Now().UTC(),
|
||||
"resource": server.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// scanSkill 对 Skill 定义执行供应链静态扫描。
|
||||
func (h *MarketplaceAdminHTTPHandler) scanSkill(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.require(w, r, identity.PermissionSkillManage); !ok {
|
||||
return
|
||||
}
|
||||
skill, err := h.skills.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusNotFound, "Skill 不存在")
|
||||
return
|
||||
}
|
||||
content := strings.Join([]string{skill.Name, skill.Description, skill.Content}, "\n")
|
||||
apiresponse.OK(w, map[string]any{
|
||||
"findings": ScanResource(content),
|
||||
"highest": HighestSeverity(ScanResource(content)),
|
||||
"scanned_at": time.Now().UTC(),
|
||||
"resource": skill.Name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package workbench
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"aigateway.local/core/internal/provider"
|
||||
)
|
||||
|
||||
// ScanFinding 是一条供应链安全扫描发现。
|
||||
type ScanFinding struct {
|
||||
Rule string `json:"rule"`
|
||||
Severity string `json:"severity"` // high / medium / low
|
||||
Description string `json:"description"`
|
||||
Match string `json:"match,omitempty"`
|
||||
}
|
||||
|
||||
// 静态扫描规则:对 skill/mcp 资源定义内容(描述、提示词、工具配置、URL 等)
|
||||
// 做供应链安全检查,发现高危模式时在管理端展示。
|
||||
var (
|
||||
secretPatterns = []struct {
|
||||
rule, severity, pattern, description string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{rule: "openai_key", severity: "high", pattern: `sk-[A-Za-z0-9_-]{16,}`, description: "疑似硬编码 OpenAI API Key"},
|
||||
{rule: "aws_key", severity: "high", pattern: `AKIA[0-9A-Z]{16}`, description: "疑似硬编码 AWS Access Key"},
|
||||
{rule: "github_token", severity: "high", pattern: `gh[pousr]_[A-Za-z0-9]{20,}`, description: "疑似硬编码 GitHub Token"},
|
||||
{rule: "stripe_key", severity: "high", pattern: `sk_live_[A-Za-z0-9]{20,}`, description: "疑似硬编码 Stripe 密钥"},
|
||||
{rule: "generic_secret", severity: "medium", pattern: `(?i)(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*['"][^'"]{8,}['"]`, description: "疑似硬编码凭据赋值"},
|
||||
{rule: "private_key_block", severity: "high", pattern: `-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----`, description: "包含私钥块"},
|
||||
}
|
||||
dangerCommandPatterns = []struct {
|
||||
pattern, description string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{pattern: `(?i)(rm\s+-rf\s+/|:\(\)\s*\{[^}]*\}\s*;|mkfs\.|dd\s+if=.*of=/dev/)`, description: "包含危险系统命令"},
|
||||
{pattern: `(?i)curl\s+[^|;&]*\|\s*(ba)?sh|wget\s+[^|;&]*\|\s*(ba)?sh`, description: "管道执行远程脚本(curl|sh)"},
|
||||
}
|
||||
injectionPatterns = []struct {
|
||||
pattern, description string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{pattern: `(?i)ignore (all |any )?(previous|prior) instructions`, description: "疑似提示词注入(忽略历史指令)"},
|
||||
{pattern: `(?i)(reveal|leak|exfiltrate|print)\s+(your|the)\s+(system\s+)?(prompt|instructions|secret)`, description: "疑似提示词注入(诱导泄露系统提示/密钥)"},
|
||||
{pattern: `(?i)(you are now|act as|pretend to be).{0,40}(no restrictions|unfiltered|jailbreak)`, description: "疑似越狱/解除限制指令"},
|
||||
}
|
||||
)
|
||||
|
||||
func compileStaticPatterns() {
|
||||
for i := range secretPatterns {
|
||||
secretPatterns[i].re = regexp.MustCompile(secretPatterns[i].pattern)
|
||||
}
|
||||
for i := range dangerCommandPatterns {
|
||||
dangerCommandPatterns[i].re = regexp.MustCompile(dangerCommandPatterns[i].pattern)
|
||||
}
|
||||
for i := range injectionPatterns {
|
||||
injectionPatterns[i].re = regexp.MustCompile(injectionPatterns[i].pattern)
|
||||
}
|
||||
}
|
||||
|
||||
func init() { compileStaticPatterns() }
|
||||
|
||||
// ScanResource 对资源定义内容执行静态安全扫描。
|
||||
// content 为拼接的文本(名称、描述、提示词、工具 URL、配置等)。
|
||||
func ScanResource(content string) []ScanFinding {
|
||||
findings := []ScanFinding{}
|
||||
scanText := func(patterns []struct {
|
||||
pattern, description string
|
||||
re *regexp.Regexp
|
||||
}, severity string) {
|
||||
for _, p := range patterns {
|
||||
if match := p.re.FindString(content); match != "" {
|
||||
findings = append(findings, ScanFinding{
|
||||
Rule: p.pattern, Severity: severity, Description: p.description,
|
||||
Match: truncateRune(match, 80),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range secretPatterns {
|
||||
if match := p.re.FindString(content); match != "" {
|
||||
findings = append(findings, ScanFinding{
|
||||
Rule: p.rule, Severity: p.severity, Description: p.description,
|
||||
Match: maskMatch(match),
|
||||
})
|
||||
}
|
||||
}
|
||||
scanText(dangerCommandPatterns, "high")
|
||||
scanText(injectionPatterns, "medium")
|
||||
|
||||
// URL 与内网地址检测。
|
||||
urlPattern := regexp.MustCompile(`https?://[^\s"'<>]+`)
|
||||
for _, raw := range urlPattern.FindAllString(content, -1) {
|
||||
parsed, err := url.Parse(strings.Trim(raw, `.,;)]}'"`))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
findings = append(findings, ScanFinding{Rule: "non_http_scheme", Severity: "high", Description: "包含非 http(s) 协议 URL(可能用于 SSRF/文件读取)", Match: truncateRune(raw, 80)})
|
||||
continue
|
||||
}
|
||||
if addresses, err := net.LookupIP(parsed.Hostname()); err == nil {
|
||||
for _, ip := range addresses {
|
||||
if !provider.IsPublicAddress(ip) {
|
||||
findings = append(findings, ScanFinding{Rule: "private_endpoint", Severity: "high", Description: "资源引用了内网/保留地址(" + ip.String() + "),可能被用于内网探测", Match: truncateRune(raw, 80)})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// base64 混淆检测(>=64 字符的 base64 串)。
|
||||
base64Pattern := regexp.MustCompile(`[A-Za-z0-9+/]{64,}={0,2}`)
|
||||
if match := base64Pattern.FindString(content); match != "" {
|
||||
findings = append(findings, ScanFinding{Rule: "obfuscated_blob", Severity: "low", Description: "包含疑似 base64 混淆数据", Match: truncateRune(match, 40) + "..."})
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// HighestSeverity 返回发现中的最高严重级。
|
||||
func HighestSeverity(findings []ScanFinding) string {
|
||||
order := map[string]int{"high": 0, "medium": 1, "low": 2}
|
||||
best := ""
|
||||
for _, f := range findings {
|
||||
if rank, ok := order[f.Severity]; ok {
|
||||
if best == "" || rank < order[best] {
|
||||
best = f.Severity
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func maskMatch(value string) string {
|
||||
if len(value) <= 8 {
|
||||
return "***"
|
||||
}
|
||||
return value[:4] + "…" + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func truncateRune(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit]) + "…"
|
||||
}
|
||||
|
||||
var _ = fmt.Sprintf
|
||||
Reference in New Issue
Block a user