feat: add new Scan Provider type: Security Scan (#5430)

This commit is contained in:
DacongDA
2026-04-24 13:10:56 +08:00
committed by GitHub
parent 9489fc3d4a
commit 0b56ef909b
12 changed files with 1452 additions and 93 deletions
+101
View File
@@ -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)
}
+6 -6
View File
@@ -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,
})
}
+1
View File
@@ -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")
-4
View File
@@ -31,10 +31,6 @@ const (
defaultIntranetSyncTimeoutMs = 1200
defaultIntranetSyncMaxConcurrency = 32
maxIntranetSyncHosts = 1024
ScanProviderCategory = "Scan"
McpScanProviderType = "MCP Scan"
IntranetScanProviderSubType = "Intranet Scan"
)
var (
+7 -1
View File
@@ -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
}
+297
View File
@@ -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
View File
@@ -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
}
+34 -6
View File
@@ -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, ""));
+5
View File
@@ -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 {
+7
View File
@@ -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",
+117 -76
View File
@@ -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>
);
}
}
+183
View File
@@ -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>
);
}