Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
513 lines
18 KiB
Go
513 lines
18 KiB
Go
package securityaudit
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
)
|
|
|
|
const (
|
|
DefaultWorkerCount = 4
|
|
MaxWorkerCount = 32
|
|
DefaultQueueCapacity = 32768
|
|
MaxQueueCapacity = 100000
|
|
DefaultTimeoutMS = 3000
|
|
MinTimeoutMS = 100
|
|
MaxTimeoutMS = 30000
|
|
DefaultInputLimit = 4000
|
|
MinInputLimit = 128
|
|
MaxInputLimit = 100000
|
|
DefaultPayloadTTL = 30 * time.Minute
|
|
)
|
|
|
|
type SecretEncryptor interface {
|
|
Encrypt(plaintext string) (string, error)
|
|
Decrypt(ciphertext string) (string, error)
|
|
}
|
|
|
|
// ConfigStore is the injectable boundary between hot-path prompt auditing and
|
|
// the concrete settings/PostgreSQL/Redis-backed configuration manager.
|
|
type ConfigStore interface {
|
|
Start(ctx context.Context) error
|
|
Shutdown(ctx context.Context) error
|
|
Active() (ActiveConfig, bool)
|
|
EffectiveMode() Mode
|
|
// BlockingActivationDegraded is true when storage intent requires blocking
|
|
// but no usable blocking snapshot is active (cold start or failed reload).
|
|
// It must stay false when blocking is not intended, even if config is
|
|
// untrusted—otherwise default-off deployments fail closed for all traffic.
|
|
BlockingActivationDegraded() bool
|
|
Public() (PublicConfig, error)
|
|
Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error)
|
|
RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string)
|
|
Encrypt(value string) (string, error)
|
|
Decrypt(value string) (string, error)
|
|
}
|
|
|
|
type StorageEndpoint struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Protocol string `json:"protocol"`
|
|
BaseURL string `json:"base_url"`
|
|
Model string `json:"model"`
|
|
TokenCiphertext string `json:"token_ciphertext,omitempty"`
|
|
TimeoutMS int `json:"timeout_ms"`
|
|
InputLimit int `json:"input_limit"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type storageConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
BlockingEnabled bool `json:"blocking_enabled"`
|
|
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
|
StorePassEvents bool `json:"store_pass_events"`
|
|
Strategy string `json:"strategy"`
|
|
WorkerCount int `json:"worker_count"`
|
|
QueueCapacity int `json:"queue_capacity"`
|
|
Scanners []string `json:"scanners"`
|
|
AllGroups bool `json:"all_groups"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
Endpoints []StorageEndpoint `json:"endpoints"`
|
|
ConfigVersion int64 `json:"config_version"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
UpdatedBy int64 `json:"updated_by"`
|
|
ChangeSummary string `json:"change_summary"`
|
|
}
|
|
|
|
type ActiveEndpoint struct {
|
|
ID string
|
|
Name string
|
|
Protocol string
|
|
BaseURL string
|
|
Model string
|
|
Token string
|
|
TimeoutMS int
|
|
InputLimit int
|
|
Enabled bool
|
|
// TokenInvalid marks an endpoint whose persisted token ciphertext cannot be
|
|
// decrypted with the current encryption key (key changed or auto-generated
|
|
// on restart). The endpoint is kept visible for admins but excluded from
|
|
// runtime use until the token is re-entered or cleared (issue #4887).
|
|
TokenInvalid bool
|
|
}
|
|
|
|
type ActiveConfig struct {
|
|
RiskControlEnabled bool
|
|
Enabled bool
|
|
BlockingEnabled bool
|
|
BlockingLatestTurnOnly bool
|
|
StorePassEvents bool
|
|
Strategy string
|
|
WorkerCount int
|
|
QueueCapacity int
|
|
Scanners []string
|
|
AllGroups bool
|
|
GroupIDs []int64
|
|
Endpoints []ActiveEndpoint
|
|
ConfigVersion int64
|
|
UpdatedAt time.Time
|
|
UpdatedBy int64
|
|
ChangeSummary string
|
|
}
|
|
|
|
type PublicEndpoint struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Protocol string `json:"protocol"`
|
|
BaseURL string `json:"base_url"`
|
|
Model string `json:"model"`
|
|
TimeoutMS int `json:"timeout_ms"`
|
|
InputLimit int `json:"input_limit"`
|
|
Enabled bool `json:"enabled"`
|
|
HasToken bool `json:"has_token"`
|
|
TokenStatus string `json:"token_status"`
|
|
}
|
|
|
|
type PublicConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
BlockingEnabled bool `json:"blocking_enabled"`
|
|
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
|
StorePassEvents bool `json:"store_pass_events"`
|
|
EffectiveMode Mode `json:"effective_mode"`
|
|
Strategy string `json:"strategy"`
|
|
WorkerCount int `json:"worker_count"`
|
|
QueueCapacity int `json:"queue_capacity"`
|
|
Scanners []string `json:"scanners"`
|
|
AllGroups bool `json:"all_groups"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
Endpoints []PublicEndpoint `json:"endpoints"`
|
|
ConfigVersion int64 `json:"config_version"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
UpdatedBy int64 `json:"updated_by"`
|
|
ChangeSummary string `json:"change_summary"`
|
|
}
|
|
|
|
type UpdateEndpoint struct {
|
|
ID string `json:"id" binding:"required"`
|
|
Name string `json:"name" binding:"required"`
|
|
Protocol string `json:"protocol"`
|
|
BaseURL string `json:"base_url" binding:"required"`
|
|
Model string `json:"model"`
|
|
Token string `json:"token,omitempty"`
|
|
ClearToken bool `json:"clear_token"`
|
|
TimeoutMS int `json:"timeout_ms"`
|
|
InputLimit int `json:"input_limit"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type UpdateConfigRequest struct {
|
|
ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"`
|
|
Enabled bool `json:"enabled"`
|
|
BlockingEnabled bool `json:"blocking_enabled"`
|
|
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
|
StorePassEvents bool `json:"store_pass_events"`
|
|
Strategy string `json:"strategy"`
|
|
WorkerCount int `json:"worker_count"`
|
|
QueueCapacity int `json:"queue_capacity"`
|
|
Scanners []string `json:"scanners"`
|
|
AllGroups bool `json:"all_groups"`
|
|
GroupIDs []int64 `json:"group_ids"`
|
|
Endpoints []UpdateEndpoint `json:"endpoints"`
|
|
}
|
|
|
|
func DefaultStorageConfig() storageConfig {
|
|
return storageConfig{
|
|
Enabled: false,
|
|
BlockingEnabled: false,
|
|
BlockingLatestTurnOnly: false,
|
|
StorePassEvents: false,
|
|
Strategy: "priority",
|
|
WorkerCount: DefaultWorkerCount,
|
|
QueueCapacity: DefaultQueueCapacity,
|
|
Scanners: append([]string(nil), AllScannerIDs...),
|
|
AllGroups: true,
|
|
GroupIDs: []int64{},
|
|
Endpoints: []StorageEndpoint{},
|
|
ConfigVersion: 1,
|
|
}
|
|
}
|
|
|
|
func ParseStorageConfig(raw string) (storageConfig, error) {
|
|
cfg := DefaultStorageConfig()
|
|
if strings.TrimSpace(raw) == "" {
|
|
return cfg, nil
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
|
return storageConfig{}, fmt.Errorf("decode prompt audit config: %w", err)
|
|
}
|
|
normalizeStorageConfig(&cfg)
|
|
if err := validateStorageConfig(cfg); err != nil {
|
|
return storageConfig{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func normalizeStorageConfig(cfg *storageConfig) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
if cfg.ConfigVersion < 1 {
|
|
cfg.ConfigVersion = 1
|
|
}
|
|
if strings.TrimSpace(cfg.Strategy) == "" {
|
|
cfg.Strategy = "priority"
|
|
}
|
|
if cfg.WorkerCount == 0 {
|
|
cfg.WorkerCount = DefaultWorkerCount
|
|
}
|
|
if cfg.QueueCapacity == 0 {
|
|
cfg.QueueCapacity = DefaultQueueCapacity
|
|
}
|
|
if len(cfg.Scanners) == 0 {
|
|
cfg.Scanners = append([]string(nil), AllScannerIDs...)
|
|
}
|
|
cfg.Scanners = canonicalScannerIDs(cfg.Scanners)
|
|
cfg.GroupIDs = canonicalInt64s(cfg.GroupIDs)
|
|
// Preserve an invalid blocking-without-audit combination so validation can
|
|
// reject it instead of silently changing administrator intent.
|
|
for i := range cfg.Endpoints {
|
|
ep := &cfg.Endpoints[i]
|
|
ep.ID = strings.TrimSpace(ep.ID)
|
|
ep.Name = strings.TrimSpace(ep.Name)
|
|
ep.Protocol = strings.TrimSpace(ep.Protocol)
|
|
if ep.Protocol == "" {
|
|
ep.Protocol = "openai_compatible"
|
|
}
|
|
ep.BaseURL = strings.TrimSpace(ep.BaseURL)
|
|
ep.Model = strings.TrimSpace(ep.Model)
|
|
if ep.Model == "" {
|
|
ep.Model = DefaultGuardModel
|
|
}
|
|
if ep.TimeoutMS == 0 {
|
|
ep.TimeoutMS = DefaultTimeoutMS
|
|
}
|
|
if ep.InputLimit == 0 {
|
|
ep.InputLimit = DefaultInputLimit
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateStorageConfig(cfg storageConfig) error {
|
|
if cfg.BlockingEnabled && !cfg.Enabled {
|
|
return infraerrors.BadRequest(ErrorCodeRequiresEnabled, "开启同步阻止前必须先启用提示词审计")
|
|
}
|
|
if cfg.Strategy != "priority" {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_strategy", "提示词审计策略仅支持 priority")
|
|
}
|
|
if cfg.WorkerCount < 1 || cfg.WorkerCount > MaxWorkerCount {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_worker_count", "Worker 数量超出允许范围")
|
|
}
|
|
if cfg.QueueCapacity < 1 || cfg.QueueCapacity > MaxQueueCapacity {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_queue_capacity", "队列容量超出允许范围")
|
|
}
|
|
if !cfg.AllGroups && len(cfg.GroupIDs) == 0 {
|
|
return infraerrors.BadRequest("prompt_audit_groups_required", "指定分组模式至少需要选择一个分组")
|
|
}
|
|
if len(cfg.Scanners) == 0 {
|
|
return infraerrors.BadRequest("prompt_audit_scanners_required", "至少需要启用一个风险分类")
|
|
}
|
|
seen := make(map[string]struct{}, len(cfg.Endpoints))
|
|
enabled := 0
|
|
for _, ep := range cfg.Endpoints {
|
|
if ep.ID == "" || ep.Name == "" {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_endpoint", "审计节点 ID 和名称不能为空")
|
|
}
|
|
if _, ok := seen[ep.ID]; ok {
|
|
return infraerrors.BadRequest("prompt_audit_duplicate_endpoint", "审计节点 ID 不能重复")
|
|
}
|
|
seen[ep.ID] = struct{}{}
|
|
if ep.Protocol != "openai_compatible" {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_endpoint_protocol", "审计节点仅支持 OpenAI 兼容协议")
|
|
}
|
|
if _, err := NormalizeBaseURL(ep.BaseURL); err != nil {
|
|
return err
|
|
}
|
|
if ep.TimeoutMS < MinTimeoutMS || ep.TimeoutMS > MaxTimeoutMS {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_timeout", "审计节点超时超出允许范围")
|
|
}
|
|
if ep.InputLimit < MinInputLimit || ep.InputLimit > MaxInputLimit {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_input_limit", "审计节点输入上限超出允许范围")
|
|
}
|
|
if ep.Enabled {
|
|
enabled++
|
|
}
|
|
}
|
|
if cfg.Enabled && enabled == 0 {
|
|
return infraerrors.BadRequest("prompt_audit_endpoint_required", "启用提示词审计前至少需要启用一个审计节点")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateUpdateConfigRequest(req UpdateConfigRequest) error {
|
|
if strings.TrimSpace(req.Strategy) != "priority" {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_strategy", "提示词审计策略仅支持 priority")
|
|
}
|
|
if req.WorkerCount < 1 || req.WorkerCount > MaxWorkerCount {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_worker_count", "Worker 数量超出允许范围")
|
|
}
|
|
if req.QueueCapacity < 1 || req.QueueCapacity > MaxQueueCapacity {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_queue_capacity", "队列容量超出允许范围")
|
|
}
|
|
if len(req.Scanners) == 0 {
|
|
return infraerrors.BadRequest("prompt_audit_scanners_required", "至少需要启用一个风险分类")
|
|
}
|
|
for _, scanner := range req.Scanners {
|
|
if _, ok := ScannerCatalog[NormalizeCategory(scanner)]; !ok {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_scanner", "提示词审计风险分类无效")
|
|
}
|
|
}
|
|
if !req.AllGroups {
|
|
if len(req.GroupIDs) == 0 {
|
|
return infraerrors.BadRequest("prompt_audit_groups_required", "指定分组模式至少需要选择一个分组")
|
|
}
|
|
for _, groupID := range req.GroupIDs {
|
|
if groupID <= 0 {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_group", "提示词审计分组 ID 无效")
|
|
}
|
|
}
|
|
}
|
|
for _, endpoint := range req.Endpoints {
|
|
if endpoint.TimeoutMS < MinTimeoutMS || endpoint.TimeoutMS > MaxTimeoutMS {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_timeout", "审计节点超时超出允许范围")
|
|
}
|
|
if endpoint.InputLimit < MinInputLimit || endpoint.InputLimit > MaxInputLimit {
|
|
return infraerrors.BadRequest("prompt_audit_invalid_input_limit", "审计节点输入上限超出允许范围")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cfg ActiveConfig) EffectiveMode() Mode {
|
|
if !cfg.RiskControlEnabled || !cfg.Enabled {
|
|
return ModeOff
|
|
}
|
|
if cfg.BlockingEnabled {
|
|
return ModeBlocking
|
|
}
|
|
return ModeAsync
|
|
}
|
|
|
|
func (cfg ActiveConfig) IncludesGroup(groupID *int64) bool {
|
|
if cfg.AllGroups {
|
|
return true
|
|
}
|
|
if groupID == nil {
|
|
return false
|
|
}
|
|
i := sort.Search(len(cfg.GroupIDs), func(i int) bool { return cfg.GroupIDs[i] >= *groupID })
|
|
return i < len(cfg.GroupIDs) && cfg.GroupIDs[i] == *groupID
|
|
}
|
|
|
|
func (cfg ActiveConfig) EnabledEndpoints() []ActiveEndpoint {
|
|
result := make([]ActiveEndpoint, 0, len(cfg.Endpoints))
|
|
for _, ep := range cfg.Endpoints {
|
|
if ep.Enabled {
|
|
result = append(result, ep)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// InvalidTokenEndpointIDs lists endpoints whose stored token could not be
|
|
// decrypted with the current encryption key.
|
|
func (cfg ActiveConfig) InvalidTokenEndpointIDs() []string {
|
|
ids := make([]string, 0)
|
|
for _, ep := range cfg.Endpoints {
|
|
if ep.TokenInvalid {
|
|
ids = append(ids, ep.ID)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenEndpointIDs []string) PublicConfig {
|
|
invalid := make(map[string]struct{}, len(invalidTokenEndpointIDs))
|
|
for _, id := range invalidTokenEndpointIDs {
|
|
invalid[id] = struct{}{}
|
|
}
|
|
scanners := append([]string{}, cfg.Scanners...)
|
|
groupIDs := append([]int64{}, cfg.GroupIDs...)
|
|
endpoints := make([]PublicEndpoint, 0, len(cfg.Endpoints))
|
|
for _, ep := range cfg.Endpoints {
|
|
hasToken := strings.TrimSpace(ep.TokenCiphertext) != ""
|
|
status := "missing"
|
|
if hasToken {
|
|
status = "configured"
|
|
if _, ok := invalid[ep.ID]; ok {
|
|
status = "invalid"
|
|
}
|
|
}
|
|
endpoints = append(endpoints, PublicEndpoint{
|
|
ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL,
|
|
Model: ep.Model, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit,
|
|
Enabled: ep.Enabled, HasToken: hasToken, TokenStatus: status,
|
|
})
|
|
}
|
|
active := ActiveConfig{RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled}
|
|
return PublicConfig{
|
|
Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly, StorePassEvents: cfg.StorePassEvents,
|
|
EffectiveMode: active.EffectiveMode(), Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
|
|
QueueCapacity: cfg.QueueCapacity, Scanners: scanners, AllGroups: cfg.AllGroups,
|
|
GroupIDs: groupIDs, Endpoints: endpoints, ConfigVersion: cfg.ConfigVersion,
|
|
UpdatedAt: cfg.UpdatedAt, UpdatedBy: cfg.UpdatedBy, ChangeSummary: cfg.ChangeSummary,
|
|
}
|
|
}
|
|
|
|
func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor SecretEncryptor) (ActiveConfig, error) {
|
|
active := ActiveConfig{
|
|
RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled,
|
|
BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly,
|
|
StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
|
|
QueueCapacity: cfg.QueueCapacity, Scanners: append([]string(nil), cfg.Scanners...), AllGroups: cfg.AllGroups,
|
|
GroupIDs: append([]int64(nil), cfg.GroupIDs...), ConfigVersion: cfg.ConfigVersion,
|
|
UpdatedAt: cfg.UpdatedAt, UpdatedBy: cfg.UpdatedBy, ChangeSummary: cfg.ChangeSummary,
|
|
Endpoints: make([]ActiveEndpoint, 0, len(cfg.Endpoints)),
|
|
}
|
|
for _, ep := range cfg.Endpoints {
|
|
token := ""
|
|
tokenInvalid := false
|
|
if ep.TokenCiphertext != "" {
|
|
if encryptor == nil {
|
|
return ActiveConfig{}, fmt.Errorf("prompt audit secret encryptor unavailable")
|
|
}
|
|
plain, err := encryptor.Decrypt(ep.TokenCiphertext)
|
|
if err != nil {
|
|
// An undecryptable token (encryption key changed or regenerated)
|
|
// must not take the whole config down: admins would otherwise be
|
|
// locked out of the real config version and unable to recover
|
|
// (issue #4887). Keep the ciphertext persisted, but exclude the
|
|
// endpoint from runtime use until the token is re-entered.
|
|
tokenInvalid = true
|
|
} else {
|
|
token = plain
|
|
}
|
|
}
|
|
active.Endpoints = append(active.Endpoints, ActiveEndpoint{
|
|
ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL, Model: ep.Model,
|
|
Token: token, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit,
|
|
Enabled: ep.Enabled && !tokenInvalid, TokenInvalid: tokenInvalid,
|
|
})
|
|
}
|
|
return active, nil
|
|
}
|
|
|
|
func changeSummary(cfg storageConfig) string {
|
|
summary := struct {
|
|
Enabled bool `json:"enabled"`
|
|
BlockingEnabled bool `json:"blocking_enabled"`
|
|
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
|
StorePassEvents bool `json:"store_pass_events"`
|
|
EndpointCount int `json:"endpoint_count"`
|
|
ScannerCount int `json:"scanner_count"`
|
|
AllGroups bool `json:"all_groups"`
|
|
GroupCount int `json:"group_count"`
|
|
GroupHash string `json:"group_hash"`
|
|
}{cfg.Enabled, cfg.BlockingEnabled, cfg.BlockingLatestTurnOnly, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""}
|
|
rawGroups, _ := json.Marshal(cfg.GroupIDs)
|
|
digest := sha256.Sum256(rawGroups)
|
|
summary.GroupHash = hex.EncodeToString(digest[:])
|
|
raw, _ := json.Marshal(summary)
|
|
return string(raw)
|
|
}
|
|
|
|
func canonicalInt64s(values []int64) []int64 {
|
|
seen := make(map[int64]struct{}, len(values))
|
|
result := make([]int64, 0, len(values))
|
|
for _, value := range values {
|
|
if value <= 0 {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
|
|
return result
|
|
}
|
|
|
|
func canonicalScannerIDs(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
id := NormalizeCategory(value)
|
|
if _, ok := ScannerCatalog[id]; ok {
|
|
seen[id] = struct{}{}
|
|
}
|
|
}
|
|
result := make([]string, 0, len(seen))
|
|
for _, id := range AllScannerIDs {
|
|
if _, ok := seen[id]; ok {
|
|
result = append(result, id)
|
|
}
|
|
}
|
|
return result
|
|
}
|