Files
dingtalk-workspace-cli/internal/safety/content_scan.go
2026-07-15 11:41:30 +08:00

166 lines
3.9 KiB
Go

// Copyright 2026 Alibaba Group
// 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 safety
import (
"regexp"
"sort"
"strconv"
"strings"
)
type Finding struct {
Path string `json:"path"`
Pattern string `json:"pattern"`
Severity string `json:"severity"`
Snippet string `json:"snippet,omitempty"`
}
type Report struct {
Scanned bool `json:"scanned"`
Findings []Finding `json:"findings,omitempty"`
}
type Scanner interface {
ScanPayload(payload any) Report
}
type ContentScanner struct {
patterns []scanPattern
maxFindings int
}
type scanPattern struct {
Name string
Severity string
Expr *regexp.Regexp
}
func NewContentScanner() *ContentScanner {
return &ContentScanner{
patterns: []scanPattern{
{
Name: "ignore_previous_instructions",
Severity: "high",
Expr: regexp.MustCompile(`(?i)ignore\s+(all\s+)?(previous|prior)\s+instructions?`),
},
{
Name: "reveal_system_prompt",
Severity: "high",
Expr: regexp.MustCompile(`(?i)(reveal|show|print).*(system\s+prompt|developer\s+message|hidden\s+instructions?)`),
},
{
Name: "policy_bypass",
Severity: "medium",
Expr: regexp.MustCompile(`(?i)(bypass|disable|override).*(safety|guardrails?|restrictions?)`),
},
{
Name: "cn_ignore_instruction",
Severity: "high",
Expr: regexp.MustCompile(`(?i)(忽略|无视).*(之前|上述).*(指令|说明)`),
},
{
Name: "cn_reveal_prompt",
Severity: "high",
Expr: regexp.MustCompile(`(?i)(泄露|暴露|显示).*(系统提示|系统指令|开发者消息)`),
},
},
maxFindings: 20,
}
}
func (s *ContentScanner) ScanPayload(payload any) Report {
if s == nil {
return Report{Scanned: false}
}
findings := make([]Finding, 0, 4)
s.walkPayload("$", payload, &findings)
return Report{
Scanned: true,
Findings: findings,
}
}
func (s *ContentScanner) walkPayload(path string, value any, findings *[]Finding) {
if s == nil || findings == nil {
return
}
if len(*findings) >= s.maxFindings {
return
}
switch typed := value.(type) {
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
childPath := path + "." + key
s.walkPayload(childPath, typed[key], findings)
if len(*findings) >= s.maxFindings {
return
}
}
case []any:
for idx, item := range typed {
childPath := path + "[" + strconv.Itoa(idx) + "]"
s.walkPayload(childPath, item, findings)
if len(*findings) >= s.maxFindings {
return
}
}
case string:
s.scanString(path, typed, findings)
}
}
func (s *ContentScanner) scanString(path, value string, findings *[]Finding) {
if s == nil || findings == nil {
return
}
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return
}
for _, pattern := range s.patterns {
if !pattern.Expr.MatchString(trimmed) {
continue
}
*findings = append(*findings, Finding{
Path: path,
Pattern: pattern.Name,
Severity: pattern.Severity,
Snippet: sanitizeSnippet(trimmed),
})
if len(*findings) >= s.maxFindings {
return
}
}
}
func sanitizeSnippet(value string) string {
value = strings.ReplaceAll(value, "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
value = strings.Join(strings.Fields(value), " ")
const limit = 160
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit-3]) + "..."
}