feat: add new Scan Provider type: Security Scan (#5430)
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/casdoor/casdoor/object"
|
||||
"github.com/casdoor/casdoor/scan"
|
||||
)
|
||||
|
||||
// Scan
|
||||
// @Title Scan
|
||||
// @Tag Scan API
|
||||
// @Description run scan provider (type=Security Scan), persist result to provider metadata, and return parsed result
|
||||
// @Param owner query string true "The provider owner"
|
||||
// @Param name query string true "The provider name"
|
||||
// @Param target query string false "Optional scan target"
|
||||
// @Success 200 {object} controllers.Response The Response object
|
||||
// @router /scan [get]
|
||||
func (c *ApiController) Scan() {
|
||||
owner := strings.TrimSpace(c.GetString("owner"))
|
||||
name := strings.TrimSpace(c.GetString("name"))
|
||||
target := strings.TrimSpace(c.GetString("target"))
|
||||
if owner == "" || name == "" {
|
||||
c.ResponseError("provider owner and name are required")
|
||||
return
|
||||
}
|
||||
|
||||
providerId := fmt.Sprintf("%s/%s", owner, name)
|
||||
configuredProvider, err := object.GetProvider(providerId)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
if configuredProvider == nil {
|
||||
c.ResponseError("provider does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
if !c.requireProviderPermission(configuredProvider) {
|
||||
return
|
||||
}
|
||||
|
||||
if configuredProvider.Category != "Scan" || configuredProvider.Type != "Security Scan" {
|
||||
c.ResponseError("provider type Security Scan is required")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.EqualFold(configuredProvider.SubType, "Url") && target == "" && strings.TrimSpace(configuredProvider.Content) == "" {
|
||||
c.ResponseError("target URL is required for Url scan")
|
||||
return
|
||||
}
|
||||
|
||||
scanProvider, err := scan.GetScanProviderFromProvider(configuredProvider)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rawResult, err := scanProvider.Scan(target, "")
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
parsedResult, err := scanProvider.ParseResult(rawResult)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
configuredProvider.Metadata = parsedResult
|
||||
_, err = object.UpdateProvider(configuredProvider.GetId(), configuredProvider)
|
||||
if err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
if err := json.Unmarshal([]byte(parsedResult), &result); err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk(result)
|
||||
}
|
||||
@@ -83,16 +83,16 @@ func (c *ApiController) SyncIntranetServers() {
|
||||
return
|
||||
}
|
||||
|
||||
var result scan.SyncInnerServersResult
|
||||
if err := json.Unmarshal([]byte(parsedResult), &result); err != nil {
|
||||
var intranetResult scan.SyncInnerServersResult
|
||||
if err := json.Unmarshal([]byte(parsedResult), &intranetResult); err != nil {
|
||||
c.ResponseError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.ResponseOk(&scan.SyncInnerServersResult{
|
||||
CIDR: result.CIDR,
|
||||
ScannedHosts: result.ScannedHosts,
|
||||
OnlineHosts: result.OnlineHosts,
|
||||
Servers: result.Servers,
|
||||
CIDR: intranetResult.CIDR,
|
||||
ScannedHosts: intranetResult.ScannedHosts,
|
||||
OnlineHosts: intranetResult.OnlineHosts,
|
||||
Servers: intranetResult.Servers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ func InitAPI() {
|
||||
|
||||
web.Router("/api/get-servers", &controllers.ApiController{}, "GET:GetServers")
|
||||
web.Router("/api/get-online-servers", &controllers.ApiController{}, "GET:GetOnlineServers")
|
||||
web.Router("/api/scan", &controllers.ApiController{}, "GET:Scan")
|
||||
web.Router("/api/sync-intranet-servers", &controllers.ApiController{}, "POST:SyncIntranetServers")
|
||||
web.Router("/api/get-server", &controllers.ApiController{}, "GET:GetServer")
|
||||
web.Router("/api/update-server", &controllers.ApiController{}, "POST:UpdateServer")
|
||||
|
||||
@@ -31,10 +31,6 @@ const (
|
||||
defaultIntranetSyncTimeoutMs = 1200
|
||||
defaultIntranetSyncMaxConcurrency = 32
|
||||
maxIntranetSyncHosts = 1024
|
||||
|
||||
ScanProviderCategory = "Scan"
|
||||
McpScanProviderType = "MCP Scan"
|
||||
IntranetScanProviderSubType = "Intranet Scan"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+7
-1
@@ -31,8 +31,14 @@ func GetScanProviderFromProvider(provider *object.Provider) (ScanProvider, error
|
||||
return nil, fmt.Errorf("provider is nil")
|
||||
}
|
||||
|
||||
if provider.Category != "Scan" {
|
||||
return nil, fmt.Errorf("provider %s is not supported", provider.Category)
|
||||
}
|
||||
|
||||
switch {
|
||||
case provider.Category == ScanProviderCategory && provider.Type == McpScanProviderType && provider.SubType == IntranetScanProviderSubType:
|
||||
case provider.Type == "Security Scan":
|
||||
return NewScanProviderFromProvider(provider), nil
|
||||
case provider.Type == "MCP Scan" && provider.SubType == "Intranet Scan":
|
||||
return NewIntranetServerProvider(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package scan
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
"github.com/casdoor/casdoor/object"
|
||||
)
|
||||
|
||||
const dataSourceUrl = "https://casdoor.ai/casdoor-data/data.json"
|
||||
|
||||
type CVE struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Summary string `json:"summary"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Suggestion string `json:"suggestion"`
|
||||
Rule string `json:"rule"`
|
||||
References []string `json:"references"`
|
||||
}
|
||||
|
||||
type FingerprintHttpInfo struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Matchers []FingerprintMatcher `json:"matchers"`
|
||||
}
|
||||
|
||||
type FingerprintMatcher struct {
|
||||
Pos string `json:"pos"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type FingerprintVersionInfo struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Part string `json:"part"`
|
||||
Regex string `json:"regex"`
|
||||
}
|
||||
|
||||
type Fingerprint struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Product string `json:"product"`
|
||||
Vendor string `json:"vendor"`
|
||||
|
||||
HttpInfo FingerprintHttpInfo `json:"httpInfo"`
|
||||
VersionInfo FingerprintVersionInfo `json:"versionInfo"`
|
||||
}
|
||||
|
||||
type SecurityScanProvider struct {
|
||||
Type string `json:"type"`
|
||||
Owner string `json:"owner"`
|
||||
|
||||
OnlineList string `json:"onlineList"`
|
||||
TargetURL string `json:"targetUrl"`
|
||||
}
|
||||
|
||||
type SecurityScanFinding struct {
|
||||
Name string `json:"name"`
|
||||
Product string `json:"product"`
|
||||
Vendor string `json:"vendor"`
|
||||
Version string `json:"version"`
|
||||
Severity string `json:"severity"`
|
||||
TargetURL string `json:"targetUrl"`
|
||||
CVEs []CVE `json:"cves"`
|
||||
}
|
||||
|
||||
type securityScanTarget struct {
|
||||
Name string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type onlineScanLists struct {
|
||||
CVEList []CVE `json:"cveList"`
|
||||
FingerprintList []Fingerprint `json:"fingerprintList"`
|
||||
}
|
||||
|
||||
func NewScanProviderFromProvider(provider *object.Provider) SecurityScanProvider {
|
||||
return SecurityScanProvider{Type: provider.SubType, Owner: provider.Owner, OnlineList: provider.Endpoint, TargetURL: provider.Content}
|
||||
}
|
||||
|
||||
func (v SecurityScanProvider) Scan(target string, command string) (string, error) {
|
||||
_ = command
|
||||
|
||||
if !strings.EqualFold(v.Type, "Site") && !strings.EqualFold(v.Type, "Url") {
|
||||
return "", fmt.Errorf("scan provider sub type: %s is not supported", v.Type)
|
||||
}
|
||||
|
||||
cves, fingerprints, err := getOnlineScanLists(dataSourceUrl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
runtimeCVEList := buildCVEMap(cves)
|
||||
runtimeFingerprintList := buildFingerprintList(fingerprints)
|
||||
|
||||
if strings.TrimSpace(v.OnlineList) != "" {
|
||||
onlineCVEList, onlineFingerprintList, err := getOnlineScanLists(v.OnlineList)
|
||||
if err != nil {
|
||||
logs.Warning("scan: failed to load online scan lists, onlineList = %s, err = %v", v.OnlineList, err)
|
||||
} else {
|
||||
mergeOnlineCVEs(runtimeCVEList, onlineCVEList)
|
||||
runtimeFingerprintList = mergeOnlineFingerprints(runtimeFingerprintList, onlineFingerprintList)
|
||||
}
|
||||
}
|
||||
|
||||
scanTargets, err := v.getScanTargets(strings.TrimSpace(target))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||
},
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
findings := make([]SecurityScanFinding, 0)
|
||||
findingMap := map[string]int{}
|
||||
|
||||
for _, scanTarget := range scanTargets {
|
||||
hasFindingForTarget := false
|
||||
for _, fingerprint := range runtimeFingerprintList {
|
||||
matched, err := isFingerprintMatched(client, scanTarget.BaseURL, fingerprint)
|
||||
if err != nil {
|
||||
logs.Warning("scan: fingerprint probe failed, target = %s, fingerprint = %s, err = %v", scanTarget.Name, fingerprint.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
|
||||
hasFindingForTarget = true
|
||||
|
||||
version, err := getFingerprintVersion(client, scanTarget.BaseURL, fingerprint)
|
||||
if err != nil {
|
||||
logs.Warning("scan: version probe failed, target = %s, fingerprint = %s, err = %v", scanTarget.Name, fingerprint.Name, err)
|
||||
}
|
||||
|
||||
cves := filterMatchedCVEs(runtimeCVEList[fingerprint.Name], version)
|
||||
finding := SecurityScanFinding{
|
||||
Name: scanTarget.Name,
|
||||
Product: fingerprint.Product,
|
||||
Vendor: fingerprint.Vendor,
|
||||
Version: version,
|
||||
Severity: fingerprint.Severity,
|
||||
TargetURL: scanTarget.BaseURL,
|
||||
CVEs: cves,
|
||||
}
|
||||
finding = normalizeUnknownFinding(finding)
|
||||
|
||||
findingKey := fmt.Sprintf("%s|%s", finding.Name, finding.TargetURL)
|
||||
if idx, ok := findingMap[findingKey]; ok {
|
||||
findings[idx] = mergeFinding(findings[idx], finding)
|
||||
continue
|
||||
}
|
||||
|
||||
findingMap[findingKey] = len(findings)
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
if !hasFindingForTarget {
|
||||
unknownFinding := normalizeUnknownFinding(SecurityScanFinding{
|
||||
Name: scanTarget.Name,
|
||||
TargetURL: scanTarget.BaseURL,
|
||||
CVEs: []CVE{},
|
||||
})
|
||||
findingMapKey := fmt.Sprintf("%s|%s", unknownFinding.Name, unknownFinding.TargetURL)
|
||||
if idx, ok := findingMap[findingMapKey]; ok {
|
||||
findings[idx] = mergeFinding(findings[idx], unknownFinding)
|
||||
} else {
|
||||
findingMap[findingMapKey] = len(findings)
|
||||
findings = append(findings, unknownFinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(findings, func(i, j int) bool {
|
||||
if findings[i].TargetURL == findings[j].TargetURL {
|
||||
return findings[i].Name < findings[j].Name
|
||||
}
|
||||
return findings[i].TargetURL < findings[j].TargetURL
|
||||
})
|
||||
|
||||
resultBytes, err := json.Marshal(findings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(resultBytes), nil
|
||||
}
|
||||
|
||||
func (v SecurityScanProvider) ParseResult(rawResult string) (string, error) {
|
||||
var findings []SecurityScanFinding
|
||||
if err := json.Unmarshal([]byte(rawResult), &findings); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resultBytes, err := json.Marshal(findings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(resultBytes), nil
|
||||
}
|
||||
|
||||
func (v SecurityScanProvider) GetResultSummary(result string) string {
|
||||
var findings []SecurityScanFinding
|
||||
if err := json.Unmarshal([]byte(result), &findings); err != nil {
|
||||
return fmt.Sprintf("invalid result: %v", err)
|
||||
}
|
||||
|
||||
targetSet := map[string]struct{}{}
|
||||
cveCount := 0
|
||||
for _, finding := range findings {
|
||||
targetSet[finding.TargetURL] = struct{}{}
|
||||
cveCount += len(finding.CVEs)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("targets=%d findings=%d cves=%d", len(targetSet), len(findings), cveCount)
|
||||
}
|
||||
|
||||
func (v SecurityScanProvider) getScanTargets(target string) ([]securityScanTarget, error) {
|
||||
if strings.EqualFold(v.Type, "Site") {
|
||||
sites, err := object.GetSites(v.Owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := make([]securityScanTarget, 0)
|
||||
for _, site := range sites {
|
||||
if site == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, baseURL := range getSiteBaseURLs(site) {
|
||||
if target != "" && !strings.Contains(baseURL, target) {
|
||||
continue
|
||||
}
|
||||
res = append(res, securityScanTarget{Name: site.Name, BaseURL: baseURL})
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
targetURL := strings.TrimSpace(target)
|
||||
if targetURL == "" {
|
||||
targetURL = strings.TrimSpace(v.TargetURL)
|
||||
}
|
||||
if targetURL == "" {
|
||||
return nil, fmt.Errorf("target URL is required for Url scan")
|
||||
}
|
||||
|
||||
targetURLs := splitScanTargets(targetURL)
|
||||
if len(targetURLs) == 0 {
|
||||
return nil, fmt.Errorf("target URL is required for Url scan")
|
||||
}
|
||||
|
||||
res := make([]securityScanTarget, 0, len(targetURLs))
|
||||
for _, currentTargetURL := range targetURLs {
|
||||
targetName, baseURL, err := normalizeScanBaseURL(currentTargetURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = append(res, securityScanTarget{Name: targetName, BaseURL: baseURL})
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
+694
@@ -0,0 +1,694 @@
|
||||
package scan
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/beego/beego/v2/core/logs"
|
||||
"github.com/casdoor/casdoor/object"
|
||||
)
|
||||
|
||||
func getSiteBaseURLs(site *object.Site) []string {
|
||||
res := []string{}
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
schemes := []string{"http"}
|
||||
lowerSslMode := strings.ToLower(strings.TrimSpace(site.SslMode))
|
||||
if strings.Contains(lowerSslMode, "https") || strings.Contains(lowerSslMode, "enable") {
|
||||
schemes = []string{"https"}
|
||||
} else if lowerSslMode == "" || strings.Contains(lowerSslMode, "auto") {
|
||||
schemes = []string{"https", "http"}
|
||||
}
|
||||
|
||||
appendCandidate := func(raw string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return
|
||||
}
|
||||
baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
|
||||
if _, ok := seen[baseURL]; !ok {
|
||||
seen[baseURL] = struct{}{}
|
||||
res = append(res, baseURL)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
raw = strings.TrimPrefix(raw, "//")
|
||||
for _, scheme := range schemes {
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, raw)
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || u.Host == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[baseURL]; ok {
|
||||
continue
|
||||
}
|
||||
seen[baseURL] = struct{}{}
|
||||
res = append(res, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
appendCandidate(site.Domain)
|
||||
for _, domain := range site.OtherDomains {
|
||||
appendCandidate(domain)
|
||||
}
|
||||
appendCandidate(site.Host)
|
||||
for _, host := range site.Hosts {
|
||||
appendCandidate(host)
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
appendCandidate(site.GetHost())
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func splitScanTargets(raw string) []string {
|
||||
parts := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
|
||||
res := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if strings.TrimSpace(part) == "" {
|
||||
continue
|
||||
}
|
||||
res = append(res, part)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func normalizeScanBaseURL(rawURL string) (string, string, error) {
|
||||
candidate := strings.TrimSpace(rawURL)
|
||||
if candidate == "" {
|
||||
return "", "", fmt.Errorf("target URL is empty")
|
||||
}
|
||||
|
||||
lower := strings.ToLower(candidate)
|
||||
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
|
||||
candidate = fmt.Sprintf("https://%s", candidate)
|
||||
}
|
||||
|
||||
u, err := url.Parse(candidate)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", "", fmt.Errorf("unsupported URL scheme: %s", u.Scheme)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(u.Host) == "" {
|
||||
return "", "", fmt.Errorf("invalid target URL: host is empty")
|
||||
}
|
||||
|
||||
baseURL := fmt.Sprintf("%s://%s", scheme, u.Host)
|
||||
return rawURL, baseURL, nil
|
||||
}
|
||||
|
||||
func getOnlineScanLists(link string) ([]CVE, []Fingerprint, error) {
|
||||
request, err := http.NewRequest(http.MethodGet, strings.TrimSpace(link), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, nil, fmt.Errorf("unexpected status code: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 8*1024*1024))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
res := onlineScanLists{}
|
||||
err = json.Unmarshal(body, &res)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return res.CVEList, res.FingerprintList, nil
|
||||
}
|
||||
|
||||
func mergeOnlineCVEs(target map[string][]CVE, online []CVE) {
|
||||
for _, cve := range online {
|
||||
name := strings.TrimSpace(cve.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
for _, existingCve := range target[name] {
|
||||
if existingCve.Code == "" {
|
||||
continue
|
||||
}
|
||||
seen[existingCve.Code] = struct{}{}
|
||||
}
|
||||
|
||||
if cve.Code != "" {
|
||||
if _, ok := seen[cve.Code]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
target[name] = append(target[name], cve)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeOnlineFingerprints(base []Fingerprint, online []Fingerprint) []Fingerprint {
|
||||
seen := map[string]struct{}{}
|
||||
for _, fingerprint := range base {
|
||||
name := strings.TrimSpace(fingerprint.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, fingerprint := range online {
|
||||
name := strings.TrimSpace(fingerprint.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
base = append(base, fingerprint)
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
func isFingerprintMatched(client *http.Client, baseURL string, fingerprint Fingerprint) (bool, error) {
|
||||
method := strings.TrimSpace(fingerprint.HttpInfo.Method)
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
|
||||
_, headers, body, err := doRequest(client, method, baseURL, fingerprint.HttpInfo.Path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
headersText := buildHeadersText(headers)
|
||||
for _, matcher := range fingerprint.HttpInfo.Matchers {
|
||||
if isMatcherMatched(client, baseURL, matcher, body, headersText) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isMatcherMatched(client *http.Client, baseURL string, matcher FingerprintMatcher, body string, headersText string) bool {
|
||||
value := strings.TrimSpace(matcher.Value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(matcher.Pos)) {
|
||||
case "body":
|
||||
return strings.Contains(body, value)
|
||||
case "header", "headers":
|
||||
return strings.Contains(strings.ToLower(headersText), strings.ToLower(value))
|
||||
case "icon":
|
||||
statusCode, _, iconBody, err := doRequest(client, http.MethodGet, baseURL, "/favicon.ico")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hashes := []string{
|
||||
strconv.FormatUint(uint64(len(iconBody)), 10),
|
||||
strconv.FormatUint(uint64(statusCode), 10),
|
||||
}
|
||||
if strings.Contains(iconBody, value) {
|
||||
return true
|
||||
}
|
||||
for _, hash := range hashes {
|
||||
if hash == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getFingerprintVersion(client *http.Client, baseURL string, fingerprint Fingerprint) (string, error) {
|
||||
regex := strings.TrimSpace(fingerprint.VersionInfo.Regex)
|
||||
if regex == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
method := strings.TrimSpace(fingerprint.VersionInfo.Method)
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
|
||||
_, headers, body, err := doRequest(client, method, baseURL, fingerprint.VersionInfo.Path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
target := body
|
||||
if strings.EqualFold(strings.TrimSpace(fingerprint.VersionInfo.Part), "header") {
|
||||
target = strings.ToLower(buildHeadersText(headers))
|
||||
}
|
||||
|
||||
target = strings.ReplaceAll(target, " ", "")
|
||||
|
||||
re, err := regexp.Compile(regex)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
matches := re.FindStringSubmatch(target)
|
||||
if len(matches) < 2 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return strings.TrimSpace(matches[1]), nil
|
||||
}
|
||||
|
||||
func doRequest(client *http.Client, method string, baseURL string, path string) (int, http.Header, string, error) {
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
|
||||
relative, err := url.Parse(strings.TrimSpace(path))
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
|
||||
if relative.Path == "" {
|
||||
relative.Path = "/"
|
||||
}
|
||||
|
||||
requestURL := base.ResolveReference(relative)
|
||||
requestMethod := method
|
||||
maxRedirects := 8
|
||||
|
||||
for i := 0; i <= maxRedirects; i++ {
|
||||
req, err := http.NewRequest(requestMethod, requestURL.String(), nil)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
|
||||
bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return 0, nil, "", readErr
|
||||
}
|
||||
|
||||
if (resp.StatusCode == http.StatusMovedPermanently || resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther || resp.StatusCode == http.StatusTemporaryRedirect || resp.StatusCode == http.StatusPermanentRedirect) && i < maxRedirects {
|
||||
location := strings.TrimSpace(resp.Header.Get("Location"))
|
||||
if location != "" {
|
||||
locationURL, parseErr := url.Parse(location)
|
||||
if parseErr == nil {
|
||||
requestURL = requestURL.ResolveReference(locationURL)
|
||||
if resp.StatusCode == http.StatusMovedPermanently || resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusSeeOther {
|
||||
requestMethod = http.MethodGet
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp.StatusCode, resp.Header.Clone(), string(bodyBytes), nil
|
||||
}
|
||||
|
||||
return 0, nil, "", fmt.Errorf("too many redirects")
|
||||
}
|
||||
|
||||
func buildHeadersText(headers http.Header) string {
|
||||
if headers == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for key, values := range headers {
|
||||
for _, value := range values {
|
||||
builder.WriteString(key)
|
||||
builder.WriteString(": ")
|
||||
builder.WriteString(value)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func filterMatchedCVEs(cves []CVE, version string) []CVE {
|
||||
matched := make([]CVE, 0, len(cves))
|
||||
for _, cve := range cves {
|
||||
rule := strings.TrimSpace(cve.Rule)
|
||||
if rule == "" {
|
||||
matched = append(matched, cve)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.TrimSpace(version) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ok, err := evalVersionRule(rule, version)
|
||||
if err != nil {
|
||||
logs.Warning("scan: failed to evaluate cve rule, cve = %s, rule = %s, version = %s, err = %v", cve.Code, cve.Rule, version, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
matched = append(matched, cve)
|
||||
}
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
func evalVersionRule(rule string, version string) (bool, error) {
|
||||
comparisonRegexp := regexp.MustCompile(`version\s*(<=|>=|==|!=|<|>)\s*"([^"&|)]*)"?`)
|
||||
replaced := comparisonRegexp.ReplaceAllStringFunc(rule, func(part string) string {
|
||||
match := comparisonRegexp.FindStringSubmatch(part)
|
||||
if len(match) != 3 {
|
||||
return "false"
|
||||
}
|
||||
|
||||
cmp := compareVersion(version, strings.TrimSpace(match[2]))
|
||||
switch match[1] {
|
||||
case "<":
|
||||
return strconv.FormatBool(cmp < 0)
|
||||
case "<=":
|
||||
return strconv.FormatBool(cmp <= 0)
|
||||
case ">":
|
||||
return strconv.FormatBool(cmp > 0)
|
||||
case ">=":
|
||||
return strconv.FormatBool(cmp >= 0)
|
||||
case "==":
|
||||
return strconv.FormatBool(cmp == 0)
|
||||
case "!=":
|
||||
return strconv.FormatBool(cmp != 0)
|
||||
default:
|
||||
return "false"
|
||||
}
|
||||
})
|
||||
|
||||
if strings.Contains(replaced, "version") {
|
||||
return false, fmt.Errorf("invalid version expression: %s", rule)
|
||||
}
|
||||
|
||||
return evalBoolExpr(replaced)
|
||||
}
|
||||
|
||||
func evalBoolExpr(expr string) (bool, error) {
|
||||
tokens := tokenizeBoolExpr(expr)
|
||||
index := 0
|
||||
|
||||
var parseExpr func() (bool, error)
|
||||
var parseTerm func() (bool, error)
|
||||
var parseFactor func() (bool, error)
|
||||
|
||||
parseExpr = func() (bool, error) {
|
||||
left, err := parseTerm()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for index < len(tokens) && tokens[index] == "||" {
|
||||
index++
|
||||
right, err := parseTerm()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
left = left || right
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
parseTerm = func() (bool, error) {
|
||||
left, err := parseFactor()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for index < len(tokens) && tokens[index] == "&&" {
|
||||
index++
|
||||
right, err := parseFactor()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
left = left && right
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
parseFactor = func() (bool, error) {
|
||||
if index >= len(tokens) {
|
||||
return false, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
|
||||
token := tokens[index]
|
||||
index++
|
||||
|
||||
switch token {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
case "(":
|
||||
value, err := parseExpr()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if index >= len(tokens) || tokens[index] != ")" {
|
||||
return false, fmt.Errorf("missing closing parenthesis")
|
||||
}
|
||||
index++
|
||||
return value, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid token: %s", token)
|
||||
}
|
||||
}
|
||||
|
||||
value, err := parseExpr()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if index != len(tokens) {
|
||||
return false, fmt.Errorf("invalid expression: %s", expr)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func tokenizeBoolExpr(expr string) []string {
|
||||
expr = strings.ReplaceAll(expr, "(", " ( ")
|
||||
expr = strings.ReplaceAll(expr, ")", " ) ")
|
||||
parts := strings.Fields(expr)
|
||||
tokens := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
token := strings.TrimSpace(strings.ToLower(part))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func compareVersion(left string, right string) int {
|
||||
leftTokens := tokenizeVersion(left)
|
||||
rightTokens := tokenizeVersion(right)
|
||||
|
||||
maxLength := len(leftTokens)
|
||||
if len(rightTokens) > maxLength {
|
||||
maxLength = len(rightTokens)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLength; i++ {
|
||||
if i >= len(leftTokens) {
|
||||
return compareVersionTail(nil, rightTokens[i:])
|
||||
}
|
||||
if i >= len(rightTokens) {
|
||||
return -compareVersionTail(nil, leftTokens[i:])
|
||||
}
|
||||
|
||||
cmp := compareVersionToken(leftTokens[i], rightTokens[i])
|
||||
if cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func compareVersionTail(_ []versionToken, remain []versionToken) int {
|
||||
if len(remain) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
for _, token := range remain {
|
||||
if token.isNumber {
|
||||
if token.raw != "" && token.raw != "0" {
|
||||
return -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
type versionToken struct {
|
||||
raw string
|
||||
isNumber bool
|
||||
}
|
||||
|
||||
func tokenizeVersion(version string) []versionToken {
|
||||
version = strings.ToLower(strings.TrimSpace(version))
|
||||
version = strings.TrimPrefix(version, "v")
|
||||
segmentRegexp := regexp.MustCompile(`[0-9]+|[a-z]+`)
|
||||
parts := segmentRegexp.FindAllString(version, -1)
|
||||
tokens := make([]versionToken, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, versionToken{raw: part, isNumber: part[0] >= '0' && part[0] <= '9'})
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
tokens = append(tokens, versionToken{raw: version, isNumber: false})
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func compareVersionToken(left versionToken, right versionToken) int {
|
||||
if left.isNumber && right.isNumber {
|
||||
leftTrim := strings.TrimLeft(left.raw, "0")
|
||||
rightTrim := strings.TrimLeft(right.raw, "0")
|
||||
if leftTrim == "" {
|
||||
leftTrim = "0"
|
||||
}
|
||||
if rightTrim == "" {
|
||||
rightTrim = "0"
|
||||
}
|
||||
if len(leftTrim) < len(rightTrim) {
|
||||
return -1
|
||||
}
|
||||
if len(leftTrim) > len(rightTrim) {
|
||||
return 1
|
||||
}
|
||||
if leftTrim < rightTrim {
|
||||
return -1
|
||||
}
|
||||
if leftTrim > rightTrim {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if left.isNumber && !right.isNumber {
|
||||
return 1
|
||||
}
|
||||
if !left.isNumber && right.isNumber {
|
||||
return -1
|
||||
}
|
||||
|
||||
if left.raw < right.raw {
|
||||
return -1
|
||||
}
|
||||
if left.raw > right.raw {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func mergeFinding(left SecurityScanFinding, right SecurityScanFinding) SecurityScanFinding {
|
||||
if left.Version == "" {
|
||||
left.Version = right.Version
|
||||
}
|
||||
|
||||
if len(right.CVEs) == 0 {
|
||||
return left
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
for _, cve := range left.CVEs {
|
||||
seen[cve.Code] = struct{}{}
|
||||
}
|
||||
for _, cve := range right.CVEs {
|
||||
if _, ok := seen[cve.Code]; ok {
|
||||
continue
|
||||
}
|
||||
seen[cve.Code] = struct{}{}
|
||||
left.CVEs = append(left.CVEs, cve)
|
||||
}
|
||||
|
||||
return normalizeUnknownFinding(left)
|
||||
}
|
||||
|
||||
func normalizeUnknownFinding(finding SecurityScanFinding) SecurityScanFinding {
|
||||
if strings.TrimSpace(finding.Product) == "" {
|
||||
finding.Product = "Unknown"
|
||||
}
|
||||
if strings.TrimSpace(finding.Vendor) == "" {
|
||||
finding.Vendor = "Unknown"
|
||||
}
|
||||
if strings.TrimSpace(finding.Version) == "" {
|
||||
finding.Version = "Unknown"
|
||||
}
|
||||
if strings.TrimSpace(finding.Severity) == "" {
|
||||
finding.Severity = "Unknown"
|
||||
}
|
||||
return finding
|
||||
}
|
||||
|
||||
func buildCVEMap(cves []CVE) map[string][]CVE {
|
||||
cveMap := make(map[string][]CVE)
|
||||
for _, cve := range cves {
|
||||
name := strings.TrimSpace(cve.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cveMap[name] = append(cveMap[name], cve)
|
||||
}
|
||||
|
||||
return cveMap
|
||||
}
|
||||
|
||||
func buildFingerprintList(fingerprints []Fingerprint) []Fingerprint {
|
||||
result := make([]Fingerprint, 0, len(fingerprints))
|
||||
for _, fingerprint := range fingerprints {
|
||||
if strings.TrimSpace(fingerprint.Name) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, fingerprint)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -522,6 +522,11 @@ class ProviderEditPage extends React.Component {
|
||||
return ([
|
||||
{id: "OpenClaw", name: "OpenClaw"},
|
||||
]);
|
||||
} else if (type === "Security Scan") {
|
||||
return ([
|
||||
{id: "Site", name: "Site"},
|
||||
{id: "Url", name: "Url"},
|
||||
]);
|
||||
} else if (type === "MCP Scan") {
|
||||
return ([
|
||||
{id: "Intranet Scan", name: "Intranet Scan"},
|
||||
@@ -703,22 +708,43 @@ class ProviderEditPage extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
submitProviderScan() {
|
||||
submitProviderScan(target = "") {
|
||||
const provider = this.state.provider;
|
||||
if (!provider?.owner || !provider?.name) {
|
||||
Setting.showMessage("error", i18next.t("provider:Provider owner and name are required"));
|
||||
return;
|
||||
}
|
||||
|
||||
const isSecurityUrlScan = provider.type === "Security Scan" && provider.subType === "Url";
|
||||
const rawTarget = isSecurityUrlScan ? (target || provider.content || "") : target;
|
||||
|
||||
this.setState({scanLoading: true});
|
||||
ServerBackend.syncIntranetServers(provider.owner, provider.name)
|
||||
const scanApi = provider.type === "Security Scan"
|
||||
? ServerBackend.scanProvider(provider.owner, provider.name, rawTarget)
|
||||
: ServerBackend.syncIntranetServers(provider.owner, provider.name);
|
||||
|
||||
scanApi
|
||||
.then((res) => {
|
||||
this.setState({scanLoading: false});
|
||||
if (res.status === "ok") {
|
||||
const scanResult = res.data ?? {};
|
||||
const scanServers = scanResult.servers ?? [];
|
||||
this.setState({scanResult: scanResult, scanServers: scanServers});
|
||||
Setting.showMessage("success", `${i18next.t("general:Successfully got")}: ${scanServers.length} server(s)`);
|
||||
const scanResult = res.data ?? null;
|
||||
const scanServers = scanResult?.servers ?? [];
|
||||
const nextProvider = Setting.deepCopy(this.state.provider);
|
||||
nextProvider.metadata = scanResult === null ? "" : JSON.stringify(scanResult);
|
||||
|
||||
this.setState({
|
||||
provider: nextProvider,
|
||||
scanResult: scanResult,
|
||||
scanServers: scanServers,
|
||||
});
|
||||
|
||||
if (Array.isArray(scanResult)) {
|
||||
Setting.showMessage("success", `${i18next.t("general:Successfully got")}: ${scanResult.length} finding(s)`);
|
||||
} else if (Array.isArray(scanServers)) {
|
||||
Setting.showMessage("success", `${i18next.t("general:Successfully got")}: ${scanServers.length} server(s)`);
|
||||
} else {
|
||||
Setting.showMessage("success", i18next.t("general:Successfully saved"));
|
||||
}
|
||||
} else {
|
||||
Setting.showMessage("error", `${i18next.t("general:Failed to get")}: ${res.msg}`);
|
||||
}
|
||||
@@ -910,6 +936,8 @@ class ProviderEditPage extends React.Component {
|
||||
if (!this.state.provider?.endpoint) {
|
||||
this.updateProviderField("endpoint", "/,/mcp,/sse,/mcp/sse");
|
||||
}
|
||||
} else if (value === "Security Scan") {
|
||||
this.updateProviderField("subType", "Site");
|
||||
}
|
||||
if (this.state.nameNotUserEdited) {
|
||||
this.updateProviderField("name", getAutoProviderName(this.state.provider.category, value, ""));
|
||||
|
||||
@@ -478,6 +478,10 @@ export const OtherProviderInfo = {
|
||||
},
|
||||
},
|
||||
Scan: {
|
||||
"Security Scan": {
|
||||
logo: `${StaticBaseUrl}/img/social_default.png`,
|
||||
url: "",
|
||||
},
|
||||
"MCP Scan": {
|
||||
logo: `${StaticBaseUrl}/img/social_default.png`,
|
||||
url: "",
|
||||
@@ -1458,6 +1462,7 @@ export function getProviderTypeOptions(category) {
|
||||
]);
|
||||
} else if (category === "Scan") {
|
||||
return ([
|
||||
{id: "Security Scan", name: "Security Scan"},
|
||||
{id: "MCP Scan", name: "MCP Scan"},
|
||||
]);
|
||||
} else {
|
||||
|
||||
@@ -71,6 +71,13 @@ export function deleteServer(server) {
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function scanProvider(owner, name, target = "") {
|
||||
return fetch(`${Setting.ServerUrl}/api/scan?owner=${encodeURIComponent(owner)}&name=${encodeURIComponent(name)}&target=${encodeURIComponent(target)}`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
}).then(res => res.json());
|
||||
}
|
||||
|
||||
export function syncIntranetServers(owner, name) {
|
||||
return fetch(`${Setting.ServerUrl}/api/sync-intranet-servers?owner=${encodeURIComponent(owner)}&name=${encodeURIComponent(name)}`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import {Button, Col, Row, Select, Table} from "antd";
|
||||
import {Button, Col, Input, Row, Select, Table} from "antd";
|
||||
import * as Setting from "../Setting";
|
||||
import i18next from "i18next";
|
||||
import {scanColumns} from "../common/modal/ScanServerModal";
|
||||
import ScanTable from "../table/ScanTable";
|
||||
|
||||
const {TextArea} = Input;
|
||||
|
||||
const hostOptions = [
|
||||
{label: "127.0.0.1/32", value: "127.0.0.1/32"},
|
||||
@@ -47,84 +50,122 @@ function normalizeAndJoin(values) {
|
||||
}
|
||||
|
||||
export function renderScanProviderFields(provider, updateProviderField, options = {}) {
|
||||
if (provider.type !== "MCP Scan" || provider.subType !== "Intranet Scan") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canScan = options.mode !== "add";
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:Host")}:
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{width: "100%"}}
|
||||
value={toList(provider.scopes)}
|
||||
options={hostOptions}
|
||||
onChange={value => updateProviderField("scopes", normalizeAndJoin(value))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:Port")}:
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{width: "100%"}}
|
||||
value={toList(provider?.content)}
|
||||
options={portOptions}
|
||||
onChange={value => updateProviderField("content", normalizeAndJoin(value))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:Path")}:
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{width: "100%"}}
|
||||
value={toList(provider?.endpoint)}
|
||||
options={pathOptions}
|
||||
onChange={value => updateProviderField("endpoint", normalizeAndJoin(value))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col span={22} offset={(Setting.isMobile()) ? 0 : 2}>
|
||||
<Button type="primary" loading={options.scanLoading} disabled={!canScan} onClick={options.onScan}>
|
||||
{i18next.t("server:Scan server")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{options.scanResult !== null ? (
|
||||
if (provider.type === "MCP Scan" && provider.subType === "Intranet Scan") {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col span={22} offset={(Setting.isMobile()) ? 0 : 2}>
|
||||
<Table
|
||||
scroll={{x: "max-content", y: 320}}
|
||||
dataSource={options.scanServers || []}
|
||||
columns={scanColumns}
|
||||
rowKey={(record, index) => `${record.url}-${index}`}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
title={() => {
|
||||
const scannedHosts = i18next.t("server:Scanned hosts") + `:${options.scanResult?.scannedHosts ?? 0}`;
|
||||
const onlineHosts = i18next.t("server:Online hosts") + `:${options.scanResult?.onlineHosts?.length ?? 0}`;
|
||||
const foundServers = i18next.t("server:Found servers") + `:${options.scanServers.length}`;
|
||||
return `${scannedHosts},${onlineHosts},${foundServers}`;
|
||||
}}
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:Host")}:
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{width: "100%"}}
|
||||
value={toList(provider.scopes)}
|
||||
options={hostOptions}
|
||||
onChange={value => updateProviderField("scopes", normalizeAndJoin(value))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:Port")}:
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{width: "100%"}}
|
||||
value={toList(provider?.content)}
|
||||
options={portOptions}
|
||||
onChange={value => updateProviderField("content", normalizeAndJoin(value))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:Path")}:
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{width: "100%"}}
|
||||
value={toList(provider?.endpoint)}
|
||||
options={pathOptions}
|
||||
onChange={value => updateProviderField("endpoint", normalizeAndJoin(value))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col span={22} offset={(Setting.isMobile()) ? 0 : 2}>
|
||||
<Button type="primary" loading={options.scanLoading} disabled={!canScan} onClick={options.onScan}>
|
||||
{i18next.t("server:Scan server")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{options.scanResult !== null ? (
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col span={22} offset={(Setting.isMobile()) ? 0 : 2}>
|
||||
<Table
|
||||
scroll={{x: "max-content", y: 320}}
|
||||
dataSource={options.scanServers || []}
|
||||
columns={scanColumns}
|
||||
rowKey={(record, index) => `${record.url}-${index}`}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
title={() => {
|
||||
const scannedHosts = i18next.t("server:Scanned hosts") + `:${options.scanResult?.scannedHosts ?? 0}`;
|
||||
const onlineHosts = i18next.t("server:Online hosts") + `:${options.scanResult?.onlineHosts?.length ?? 0}`;
|
||||
const foundServers = i18next.t("server:Found servers") + `:${options.scanServers.length}`;
|
||||
return `${scannedHosts},${onlineHosts},${foundServers}`;
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else if (provider.type === "Security Scan") {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("provider:Online list")}:
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<Input value={provider.endpoint} onChange={e => updateProviderField("endpoint", e.target.value)} />
|
||||
</Col>
|
||||
</Row>
|
||||
{provider.subType === "Url" ? (
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||
{i18next.t("general:URL")}:
|
||||
</Col>
|
||||
<Col span={22}>
|
||||
<TextArea
|
||||
autoSize={{minRows: 3, maxRows: 10}}
|
||||
value={provider.content}
|
||||
placeholder="https://example.com\nhttps://another.example.com"
|
||||
onChange={e => updateProviderField("content", e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
) : null}
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col span={22} offset={(Setting.isMobile()) ? 0 : 2}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={options.scanLoading}
|
||||
disabled={!canScan}
|
||||
onClick={() => options.onScan(provider.subType === "Url" ? provider.content : "")}
|
||||
>
|
||||
{i18next.t("general:Scan")}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<ScanTable provider={provider} options={{...options, subType: provider.subType, owner: provider.owner}} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import React from "react";
|
||||
import {Col, Row, Table} from "antd";
|
||||
import i18next from "i18next";
|
||||
import {Link} from "react-router-dom";
|
||||
import * as Setting from "../Setting";
|
||||
|
||||
function parseFindings(provider, scanResult) {
|
||||
if (Array.isArray(scanResult)) {
|
||||
return scanResult;
|
||||
}
|
||||
|
||||
const metadata = provider?.metadata;
|
||||
if (!metadata) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadata);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCVEs(cves) {
|
||||
return Array.isArray(cves) ? cves : [];
|
||||
}
|
||||
|
||||
function getCveLabel(cve) {
|
||||
return cve?.code || cve?.name || "-";
|
||||
}
|
||||
|
||||
function getCveLink(cve) {
|
||||
const references = Array.isArray(cve?.references) ? cve.references : [];
|
||||
return references.find((reference) => {
|
||||
if (typeof reference !== "string") {
|
||||
return false;
|
||||
}
|
||||
const value = reference.trim();
|
||||
return value.startsWith("http://") || value.startsWith("https://");
|
||||
}) || "";
|
||||
}
|
||||
|
||||
function getEntryPath(subType, owner, name) {
|
||||
if (!owner || !name) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (subType === "Site") {
|
||||
return `/sites/${owner}/${name}`;
|
||||
}
|
||||
|
||||
if (subType === "Agent") {
|
||||
return `/agents/${owner}/${name}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export default function ScanTable({provider, options}) {
|
||||
const findings = parseFindings(provider, options.scanResult);
|
||||
const subType = options?.subType || provider?.subType;
|
||||
const owner = options?.owner || provider?.owner;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: i18next.t("general:Name"),
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 160,
|
||||
render: (text) => {
|
||||
const entryPath = getEntryPath(subType, owner, text);
|
||||
if (!entryPath) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={entryPath}>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Product"),
|
||||
dataIndex: "product",
|
||||
key: "product",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Vendor"),
|
||||
dataIndex: "vendor",
|
||||
key: "vendor",
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Version"),
|
||||
dataIndex: "version",
|
||||
key: "version",
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: i18next.t("general:Severity"),
|
||||
dataIndex: "severity",
|
||||
key: "severity",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "CVEs",
|
||||
key: "cves",
|
||||
width: 420,
|
||||
render: (_, record) => {
|
||||
const cves = normalizeCVEs(record?.cves);
|
||||
if (cves.length === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{cves.map((cve, index) => {
|
||||
const label = getCveLabel(cve);
|
||||
const link = getCveLink(cve);
|
||||
const content = (
|
||||
<React.Fragment>
|
||||
<div>
|
||||
{label}
|
||||
{cve?.severity ? ` (${cve.severity})` : ""}
|
||||
</div>
|
||||
{cve?.summary ? <div style={{color: "#8c8c8c"}}>{cve.summary}</div> : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={`${label}-${index}`} style={{marginBottom: index === cves.length - 1 ? 0 : 8}}>
|
||||
{link ? (
|
||||
<a target="_blank" rel="noreferrer" href={link} style={{display: "block"}}>
|
||||
{content}
|
||||
</a>
|
||||
) : content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{findings.length > 0 ? (
|
||||
<Row style={{marginTop: "20px"}}>
|
||||
<Col span={22} offset={(Setting.isMobile()) ? 0 : 2}>
|
||||
<Table
|
||||
scroll={{x: "max-content", y: 800}}
|
||||
dataSource={findings}
|
||||
columns={columns}
|
||||
rowKey={(record, index) => `${record?.targetUrl}-${record?.name}-${index}`}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
bordered
|
||||
title={() => `${i18next.t("general:Scan")}: ${findings.length}`}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user