gitlink-cli/shortcuts/workflow/rules/license.go

218 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] + "..."
}