forked from chroe/gitlink-cli
218 lines
6.5 KiB
Go
218 lines
6.5 KiB
Go
package rules
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||
)
|
||
|
||
// riskEntry describes a single license/security risk finding.
|
||
type riskEntry struct {
|
||
File string `json:"file"`
|
||
Risk string `json:"risk"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// LicenseCheckRule scans file lists and content for license compliance and sensitive data.
|
||
func LicenseCheckRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
||
files := extractList(upstream, "existing-files")
|
||
if len(files) == 0 {
|
||
files = extractList(upstream, "files")
|
||
}
|
||
|
||
var findings []riskEntry
|
||
hasLicense := false
|
||
licenseType := ""
|
||
|
||
for _, f := range files {
|
||
name := str(f, "name", "filename", "path", "file_name")
|
||
if name == "" {
|
||
continue
|
||
}
|
||
|
||
// License file detection.
|
||
if isLicenseFile(name) {
|
||
hasLicense = true
|
||
content := str(f, "content", "body", "text")
|
||
if content != "" {
|
||
licenseType = detectLicenseType(content)
|
||
}
|
||
}
|
||
|
||
// Sensitive file name detection.
|
||
for _, fp := range filePatterns {
|
||
if fp.re.MatchString(strings.ToLower(name)) {
|
||
findings = append(findings, riskEntry{
|
||
File: name,
|
||
Risk: fp.risk,
|
||
Message: fp.message,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Sensitive content detection.
|
||
content := str(f, "content", "body", "text")
|
||
if content != "" {
|
||
for _, cp := range contentPatterns {
|
||
if cp.re.MatchString(content) {
|
||
// Apply exclusion rules.
|
||
matches := cp.re.FindAllString(content, -1)
|
||
for _, match := range matches {
|
||
if isPlaceholder(match) {
|
||
continue
|
||
}
|
||
findings = append(findings, riskEntry{
|
||
File: name,
|
||
Risk: "high",
|
||
Message: cp.message + " → `" + truncate(match, 40) + "`",
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Compute scores.
|
||
licenseScore := 0.0
|
||
if hasLicense {
|
||
licenseScore = 100
|
||
if licenseType != "" {
|
||
licenseScore = 100
|
||
} else {
|
||
licenseScore = 70
|
||
}
|
||
}
|
||
|
||
sensitiveScore := 100.0
|
||
highCount := 0
|
||
for _, f := range findings {
|
||
if f.Risk == "high" {
|
||
highCount++
|
||
}
|
||
}
|
||
if highCount > 0 {
|
||
sensitiveScore = max(0, 100-float64(highCount)*20)
|
||
}
|
||
|
||
composite := licenseScore*0.35 + sensitiveScore*0.40 + 50*0.15 + 50*0.10
|
||
|
||
analysis := map[string]interface{}{
|
||
"has_license": hasLicense,
|
||
"license_type": licenseType,
|
||
"license_score": licenseScore,
|
||
"sensitive_score": sensitiveScore,
|
||
"composite_score": composite,
|
||
"grade": grade(composite),
|
||
"findings": findings,
|
||
"total_findings": len(findings),
|
||
}
|
||
return &workflow.AIResponse{Analysis: analysis, Actions: nil}, nil
|
||
}
|
||
|
||
// --- license file detection ---
|
||
|
||
func isLicenseFile(name string) bool {
|
||
lower := strings.ToLower(name)
|
||
for _, pattern := range []string{"license", "copying", "notice", "licence"} {
|
||
if strings.Contains(lower, pattern) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func detectLicenseType(content string) string {
|
||
for _, lp := range licensePatterns {
|
||
if lp.re.MatchString(content) {
|
||
return lp.name
|
||
}
|
||
}
|
||
return "Unknown"
|
||
}
|
||
|
||
var licensePatterns = []struct {
|
||
re *regexp.Regexp
|
||
name string
|
||
}{
|
||
{regexp.MustCompile(`(?i)MIT\s+License|Permission is hereby granted`), "MIT"},
|
||
{regexp.MustCompile(`(?i)Apache\s+License.*Version\s+2\.0|http://www\.apache\.org/licenses`), "Apache 2.0"},
|
||
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 3|GPL\s*v3`), "GPL v3"},
|
||
{regexp.MustCompile(`(?i)GNU GENERAL PUBLIC LICENSE.*Version 2|GPL\s*v2`), "GPL v2"},
|
||
{regexp.MustCompile(`(?i)BSD\s+(3-Clause|2-Clause|License)`), "BSD"},
|
||
{regexp.MustCompile(`(?i)Mulan\s+Permissive|木兰宽松许可证`), "Mulan PSL v2"},
|
||
{regexp.MustCompile(`(?i)Mozilla Public License|MPL`), "MPL"},
|
||
{regexp.MustCompile(`(?i)ISC\s+License`), "ISC"},
|
||
{regexp.MustCompile(`(?i)Creative Commons|CC-BY`), "Creative Commons"},
|
||
{regexp.MustCompile(`(?i)Unlicense|public\s+domain`), "Unlicense"},
|
||
}
|
||
|
||
// --- file pattern scanning ---
|
||
|
||
type fileRiskPattern struct {
|
||
re *regexp.Regexp
|
||
risk string
|
||
message string
|
||
}
|
||
|
||
var filePatterns = []fileRiskPattern{
|
||
{regexp.MustCompile(`\.pem$|\.key$|\.p12$|\.pfx$`), "high", "私钥/证书文件,确认是否应纳入版本控制"},
|
||
{regexp.MustCompile(`id_rsa|id_dsa|id_ecdsa|id_ed25519`), "high", "SSH 私钥文件,不应提交到仓库"},
|
||
{regexp.MustCompile(`^\.env$|\.env\.`), "high", "环境变量文件,可能包含敏感凭据"},
|
||
{regexp.MustCompile(`credentials\.|\.secret$|secret\.yml`), "high", "凭据文件,可能包含敏感信息"},
|
||
{regexp.MustCompile(`serviceAccount\.json|\.service-account\.json`), "high", "服务账号密钥文件"},
|
||
{regexp.MustCompile(`.*token.*|.*secret.*`), "medium", "文件名包含 token/secret,检查内容"},
|
||
{regexp.MustCompile(`coverage\.out$`), "low", "覆盖率输出文件,建议添加到 .gitignore"},
|
||
{regexp.MustCompile(`\.exe$|\.bin$|\.dll$|\.so$`), "low", "二进制文件,检查是否应纳入版本控制"},
|
||
{regexp.MustCompile(`\.log$|\.tmp$`), "low", "日志/临时文件,建议添加到 .gitignore"},
|
||
}
|
||
|
||
// --- content pattern scanning ---
|
||
|
||
type contentRiskPattern struct {
|
||
re *regexp.Regexp
|
||
message string
|
||
}
|
||
|
||
var contentPatterns = []contentRiskPattern{
|
||
{regexp.MustCompile(`(?i)(token|api[_-]?key|apikey|secret|password|passwd|authorization)\s*[:=]\s*['"][^\s'"]{8,}['"]`),
|
||
"检测到硬编码凭据赋值"},
|
||
{regexp.MustCompile(`-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----`),
|
||
"检测到私钥头部"},
|
||
{regexp.MustCompile(`(?i)GITLINK_TOKEN\s*[:=]\s*['"][^\s'"]+['"]`),
|
||
"检测到 GitLink Token"},
|
||
{regexp.MustCompile(`(?i)(mongodb|mysql|postgres|redis|jdbc)://[^\s'"]+@`),
|
||
"检测到数据库连接字符串"},
|
||
{regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
|
||
"检测到 AWS Access Key"},
|
||
{regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`),
|
||
"检测到 GitHub 个人访问令牌"},
|
||
{regexp.MustCompile(`(?i)eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}`),
|
||
"检测到 JWT 令牌格式"},
|
||
{regexp.MustCompile(`(?i)(\d{1,3}\.){3}\d{1,3}`),
|
||
"检测到硬编码 IP 地址"},
|
||
{regexp.MustCompile(`(?i)password\s*[:=]\s*['"]['"]`),
|
||
"检测到空密码"},
|
||
}
|
||
|
||
func isPlaceholder(s string) bool {
|
||
lower := strings.ToLower(s)
|
||
for _, p := range []string{"((variable))", "<token>", "your_token_here", "xxx", "replace_me", "<your", "placeholder"} {
|
||
if strings.Contains(lower, p) {
|
||
return true
|
||
}
|
||
}
|
||
// Skip if it looks like a format variable: {{.Var}} or ${VAR}.
|
||
if matched, _ := regexp.MatchString(`\{\{\.?\w+\}\}|\$\{\w+\}`, s); matched {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "..."
|
||
}
|