From 0ed9dcabfc73c6088b5eedef2f9de28120797898 Mon Sep 17 00:00:00 2001 From: Donkey_kevin <2930705585@qq.com> Date: Mon, 15 Jun 2026 19:11:01 +0800 Subject: [PATCH] Add onboard, compliance, and contrib shortcuts with skill docs - onboard: new contributor onboarding with --issues/--tag support - compliance: license audit, secret scan, PII check, sensitive vocab scan - contrib: contribution report generation - Update register.go to register all three shortcut groups - Add skills/gitlink-onboard and skills/gitlink-compliance skill docs Co-Authored-By: Claude Opus 4.6 --- shortcuts/compliance/compliance.go | 122 +++++ shortcuts/compliance/license.go | 188 +++++++ shortcuts/compliance/report.go | 77 +++ shortcuts/compliance/rules.go | 58 +++ shortcuts/compliance/scanner.go | 210 ++++++++ shortcuts/contrib/contrib.go | 69 +++ shortcuts/contrib/data.go | 290 +++++++++++ shortcuts/contrib/html.go | 493 ++++++++++++++++++ shortcuts/onboard/onboarding.go | 359 +++++++++++++ shortcuts/register.go | 15 +- skills/gitlink-compliance/SKILL.md | 108 ++++ .../references/check-deps.md | 95 ++++ .../references/check-license.md | 97 ++++ .../references/check-pii.md | 115 ++++ .../references/check-secrets.md | 103 ++++ .../references/check-sensitive-vocab.md | 139 +++++ skills/gitlink-onboard/SKILL.md | 157 ++++++ 17 files changed, 2692 insertions(+), 3 deletions(-) create mode 100644 shortcuts/compliance/compliance.go create mode 100644 shortcuts/compliance/license.go create mode 100644 shortcuts/compliance/report.go create mode 100644 shortcuts/compliance/rules.go create mode 100644 shortcuts/compliance/scanner.go create mode 100644 shortcuts/contrib/contrib.go create mode 100644 shortcuts/contrib/data.go create mode 100644 shortcuts/contrib/html.go create mode 100644 shortcuts/onboard/onboarding.go create mode 100644 skills/gitlink-compliance/SKILL.md create mode 100644 skills/gitlink-compliance/references/check-deps.md create mode 100644 skills/gitlink-compliance/references/check-license.md create mode 100644 skills/gitlink-compliance/references/check-pii.md create mode 100644 skills/gitlink-compliance/references/check-secrets.md create mode 100644 skills/gitlink-compliance/references/check-sensitive-vocab.md create mode 100644 skills/gitlink-onboard/SKILL.md diff --git a/shortcuts/compliance/compliance.go b/shortcuts/compliance/compliance.go new file mode 100644 index 0000000..9143b9b --- /dev/null +++ b/shortcuts/compliance/compliance.go @@ -0,0 +1,122 @@ +package compliance + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "scan", + Description: "Full compliance scan (all five modules)", + Flags: []common.Flag{ + {Name: "module", Short: "m", Usage: "Comma-separated modules: license,deps,secrets,exposure,vocab"}, + }, + Run: runScan, + }, + {Name: "license", Description: "License compliance check", Run: runLicense}, + {Name: "deps", Description: "Dependency license check", Run: runDeps}, + {Name: "secrets", Description: "Hardcoded secrets scan", Run: runSecrets}, + {Name: "exposure", Description: "PII and network exposure scan", Run: runExposure}, + {Name: "vocab", Description: "Sensitive vocabulary scan", Run: runVocab}, + } +} + +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + // find git root + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return dir, nil // fallback to cwd + } + dir = parent + } +} + +// runScan is the full scan (all modules). +func runScan(ctx *common.RuntimeContext) error { + root, err := repoRoot() + if err != nil { + return err + } + + selected := ctx.Arg("module") + var modules []string + if selected != "" { + modules = parseModules(selected) + } else { + modules = []string{"secrets", "exposure", "vocab"} + } + + // license and deps checks + var allFindings []Finding + for _, m := range modules { + switch m { + case "license": + allFindings = append(allFindings, checkLicense(root)...) + case "deps": + allFindings = append(allFindings, checkDeps(root)...) + case "secrets", "exposure", "vocab": + rules := allRules()[m] + allFindings = append(allFindings, scanFiles(root, rules)...) + } + } + + return outputReport(ctx, allFindings, modules) +} + +func runLicense(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := checkLicense(root) + return outputReport(ctx, findings, []string{"license"}) +} + +func runDeps(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := checkDeps(root) + return outputReport(ctx, findings, []string{"deps"}) +} + +func runSecrets(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := scanFiles(root, allRules()["secrets"]) + return outputReport(ctx, findings, []string{"secrets"}) +} + +func runExposure(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := scanFiles(root, allRules()["exposure"]) + return outputReport(ctx, findings, []string{"exposure"}) +} + +func runVocab(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := scanFiles(root, allRules()["vocab"]) + return outputReport(ctx, findings, []string{"vocab"}) +} + +func parseModules(s string) []string { + var result []string + seen := map[string]bool{} + for _, m := range strings.Split(s, ",") { + m = strings.TrimSpace(m) + valid := map[string]bool{"license": true, "deps": true, "secrets": true, "exposure": true, "vocab": true} + if valid[m] && !seen[m] { + result = append(result, m) + seen[m] = true + } + } + return result +} diff --git a/shortcuts/compliance/license.go b/shortcuts/compliance/license.go new file mode 100644 index 0000000..2a5413c --- /dev/null +++ b/shortcuts/compliance/license.go @@ -0,0 +1,188 @@ +package compliance + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// checkLicense performs static license compliance checks (no regex scanning needed). +func checkLicense(root string) []Finding { + var findings []Finding + + // L-001: LICENSE file existence + files := []string{"LICENSE", "LICENSE.md", "LICENSE.txt"} + found := false + for _, name := range files { + if _, err := os.Stat(filepath.Join(root, name)); err == nil { + found = true + break + } + } + if !found { + findings = append(findings, Finding{ + ID: "L-001", Severity: "medium", Module: "license", + File: "-", Line: 0, + Summary: "缺少 LICENSE 文件", + }) + } + + // L-002: check npm/package.json license vs root LICENSE + rootLicense := detectLicense(root) + npmLicense := detectNpmLicense(root) + if rootLicense != "" && npmLicense != "" && !strings.EqualFold(rootLicense, npmLicense) { + findings = append(findings, Finding{ + ID: "L-002", Severity: "medium", Module: "license", + File: "npm/package.json", Line: 1, + Summary: fmt.Sprintf("许可证声明不一致:根 LICENSE 为 %s,npm/package.json 声明 %s", rootLicense, npmLicense), + }) + } + + // L-004: placeholder check in LICENSE file + for _, name := range files { + path := filepath.Join(root, name) + if f, err := os.Open(path); err == nil { + sc := bufio.NewScanner(f) + line := 0 + for sc.Scan() { + line++ + t := sc.Text() + if strings.Contains(t, "[year]") || strings.Contains(t, "[Year]") || + strings.Contains(t, "[name of copyright holder]") || strings.Contains(t, "[yyyy]") { + findings = append(findings, Finding{ + ID: "L-004", Severity: "low", Module: "license", + File: name, Line: line, + Summary: "LICENSE 中占位符未填写([Year] / [name of copyright holder])", + }) + break + } + } + f.Close() + break + } + } + + return findings +} + +func detectLicense(root string) string { + for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt"} { + path := filepath.Join(root, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + text := string(data) + switch { + case strings.Contains(text, "Mulan Permissive Software License"): + return "MulanPSL-2.0" + case strings.Contains(text, "Apache License") && strings.Contains(text, "Version 2.0"): + return "Apache-2.0" + case strings.Contains(text, "MIT License") || strings.Contains(text, "Permission is hereby granted, free of charge"): + return "MIT" + case strings.Contains(text, "GNU AFFERO GENERAL PUBLIC LICENSE"): + return "AGPL-3.0" + case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 3"): + return "GPL-3.0" + case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 2"): + return "GPL-2.0" + case strings.Contains(text, "GNU LESSER GENERAL PUBLIC LICENSE"): + return "LGPL" + case strings.Contains(text, "BSD") && strings.Count(text, "Redistribution") >= 3: + return "BSD-3-Clause" + case strings.Contains(text, "BSD"): + return "BSD-2-Clause" + case strings.Contains(text, "Mozilla Public License"): + return "MPL-2.0" + default: + return "unknown" + } + } + return "" +} + +func detectNpmLicense(root string) string { + path := filepath.Join(root, "npm", "package.json") + data, err := os.ReadFile(path) + if err != nil { + return "" + } + // simple string search for "license": "xxx" + for _, line := range strings.Split(string(data), "\n") { + if strings.Contains(line, "\"license\"") { + line = strings.TrimSpace(line) + // "license": "Apache-2.0", + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + v := strings.TrimSpace(parts[1]) + v = strings.Trim(v, "\",") + return v + } + } + } + return "" +} + +// checkDeps inspects go.mod for copyleft dependencies. +func checkDeps(root string) []Finding { + var findings []Finding + path := filepath.Join(root, "go.mod") + f, err := os.Open(path) + if err != nil { + // no go.mod — not a Go project + return nil + } + defer f.Close() + + // GPL/AGPL keywords in module names + copyleft := []string{"gpl", "agpl", "gnu"} + sc := bufio.NewScanner(f) + line := 0 + for sc.Scan() { + line++ + text := strings.ToLower(sc.Text()) + if !strings.Contains(text, "require") && !strings.Contains(text, "require") { + continue + } + // Check lines after "require" block until blank + } + f.Close() + + // re-read go.mod and check require block + data, err := os.ReadFile(path) + if err != nil { + return nil + } + lines := strings.Split(string(data), "\n") + inRequire := false + for _, l := range lines { + trimmed := strings.TrimSpace(l) + if strings.HasPrefix(trimmed, "require") && !strings.Contains(trimmed, "// indirect") { + inRequire = true + continue + } + if inRequire && trimmed == "" { + break + } + if inRequire && strings.HasPrefix(trimmed, ")") { + break + } + if inRequire { + lower := strings.ToLower(trimmed) + for _, kw := range copyleft { + if strings.Contains(lower, kw) { + findings = append(findings, Finding{ + ID: "D-003", Severity: "high", Module: "deps", + File: "go.mod", Line: 0, + Summary: fmt.Sprintf("Copyleft 依赖风险: %s", trimmed), + }) + break + } + } + } + } + + return findings +} diff --git a/shortcuts/compliance/report.go b/shortcuts/compliance/report.go new file mode 100644 index 0000000..dcdaf24 --- /dev/null +++ b/shortcuts/compliance/report.go @@ -0,0 +1,77 @@ +package compliance + +import ( + "fmt" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// summary holds aggregated stats. +type summary struct { + Total int `json:"total"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` +} + +type reportData struct { + Modules []string `json:"modules"` + Findings []Finding `json:"findings"` + Summary summary `json:"summary"` +} + +func outputReport(ctx *common.RuntimeContext, findings []Finding, modules []string) error { + s := summary{} + for _, f := range findings { + s.Total++ + switch f.Severity { + case "critical": s.Critical++ + case "high": s.High++ + case "medium": s.Medium++ + case "low": s.Low++ + } + } + + if ctx.Format == "json" { + return ctx.OutputData(reportData{Modules: modules, Findings: findings, Summary: s}) + } + + // human-readable output + fmt.Println() + printHR() + fmt.Printf(" Compliance Scan Report\n") + fmt.Printf(" Modules: %s | Findings: %d (critical:%d high:%d medium:%d low:%d)\n", + strings.Join(modules, ", "), s.Total, s.Critical, s.High, s.Medium, s.Low) + printHR() + + if len(findings) == 0 { + fmt.Println(" All clear — no issues found.") + } else { + printFindings(findings) + } + printHR() + return nil +} + +func printHR() { + fmt.Println(strings.Repeat("─", 60)) +} + +func printFindings(findings []Finding) { + labels := map[string]string{ + "critical": "CRIT", "high": "HIGH", "medium": "MED", "low": "LOW", + } + for _, f := range findings { + label := labels[f.Severity] + if label == "" { + label = f.Severity + } + fmt.Printf(" [%s] %s %s:%d %s\n", label, f.ID, f.File, f.Line, f.Summary) + } +} + +// Ensure output import is used +var _ = output.SuccessEnvelope diff --git a/shortcuts/compliance/rules.go b/shortcuts/compliance/rules.go new file mode 100644 index 0000000..b183ba2 --- /dev/null +++ b/shortcuts/compliance/rules.go @@ -0,0 +1,58 @@ +package compliance + +import "regexp" + +// allRules returns the complete set of scan rules grouped by module. +func allRules() map[string][]scanRule { + return map[string][]scanRule{ + "secrets": secretRules(), + "exposure": exposureRules(), + "vocab": vocabRules(), + } +} + +func compileRE(expr string) *regexp.Regexp { + return regexp.MustCompile(expr) +} + +// ----- secrets (S-001 ~ S-010) ----- +func secretRules() []scanRule { + return []scanRule{ + {SID("001"), "high", compileRE(`(?i)access_token|private_token`), "Token 作为 URL 查询参数泄露风险", nil}, + {SID("002"), "critical", compileRE(`(?i)password\s*[:=]\s*"[^"]+"`), "硬编码密码", nil}, + {SID("003"), "critical", compileRE(`(?i)api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"`), "硬编码 API Key", nil}, + {SID("004"), "critical", compileRE(`BEGIN.*PRIVATE KEY`), "私钥文件内容", []string{"*"}}, + {SID("005"), "high", compileRE(`token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"`), "长 Token 硬编码", nil}, + {SID("006"), "high", compileRE(`(?i)secret\s*[:=]\s*"[^"]{8,}"`), "Secret 硬编码", nil}, + {SID("008"), "medium", compileRE(`(?i)(fmt|log)\.(Print|Debug|Info).*[Tt]oken`), "Debug 输出可能泄露 Token", []string{"*.go"}}, + {SID("010"), "high", compileRE(`(?i)(mongodb|mysql|postgres|redis)://[^@]*@`), "数据库连接串含凭据", nil}, + } +} + +// ----- exposure (P-001 ~ E-005) ----- +func exposureRules() []scanRule { + return []scanRule{ + {PID("001"), "low", compileRE(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`), "邮箱地址泄露", nil}, + {PID("002"), "low", compileRE(`\b1[3-9]\d{9}\b`), "手机号泄露", nil}, + {EID("001"), "medium", compileRE(`\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b`), "内网 IP 暴露", nil}, + {EID("002"), "low", compileRE(`(localhost|127\.0\.0\.1):\d+`), "本地开发地址残留", nil}, + {EID("003"), "low", compileRE(`\b\w+\.(local|internal|test)\b`), "内部域名暴露", nil}, + } +} + +// ----- vocab (C-001 ~ C-006) ----- +func vocabRules() []scanRule { + return []scanRule{ + {CID("001"), "high", compileRE(`军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|军事基地|作战指挥|军事演习|战备|动员令|驻地|番号`), "军事相关敏感词汇", nil}, + {CID("002"), "high", compileRE(`中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|红头文件|绝密|机密文件|内参|机要文件`), "党政机关敏感词汇", nil}, + {CID("003"), "medium", compileRE(`内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测|安全监测`), "内部系统标识泄露", nil}, + {CID("004"), "medium", compileRE(`反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码`), "监管合规敏感词", nil}, + {CID("005"), "low", compileRE(`内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接`), "组织内部敏感信息", nil}, + {CID("006"), "medium", compileRE(`国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控`), "安全产品/密码学敏感词", nil}, + } +} + +func SID(num string) string { return "S-" + num } +func PID(num string) string { return "P-" + num } +func EID(num string) string { return "E-" + num } +func CID(num string) string { return "C-" + num } diff --git a/shortcuts/compliance/scanner.go b/shortcuts/compliance/scanner.go new file mode 100644 index 0000000..0ef7402 --- /dev/null +++ b/shortcuts/compliance/scanner.go @@ -0,0 +1,210 @@ +package compliance + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Finding represents a single scan result. +type Finding struct { + ID string `json:"id"` + Severity string `json:"severity"` // critical, high, medium, low + Module string `json:"module"` // license, deps, secrets, exposure, vocab + File string `json:"file"` + Line int `json:"line"` + Summary string `json:"summary"` +} + +// ScanResult holds all findings for a module. +type ScanResult struct { + Module string `json:"module"` + Findings []Finding `json:"findings"` +} + +// scanRule defines a pattern to search for. +type scanRule struct { + ID string + Severity string + Pattern *regexp.Regexp + Summary string + Globs []string // file globs to include, empty = all text files +} + +// excludedDirs are directories skipped during scanning. +var excludedDirs = map[string]bool{ + "vendor": true, "node_modules": true, ".git": true, ".claude": true, + "skills": true, // skill documentation, not project source +} + +// excludedPaths are relative paths skipped (scanner's own source to avoid self-scan). +var excludedPaths = map[string]bool{ + "shortcuts/compliance": true, +} + +// excludedExts are file extensions skipped during scanning. +var excludedExts = map[string]bool{ + ".exe": true, ".dll": true, ".so": true, ".dylib": true, + ".bin": true, ".jpg": true, ".jpeg": true, ".png": true, + ".gif": true, ".ico": true, ".svg": true, ".pdf": true, + ".zip": true, ".gz": true, ".tgz": true, +} + +// excludeFiles are specific files skipped during scanning. +var excludeFiles = map[string]bool{ + "go.sum": true, "package-lock.json": true, +} + +// textExts are extensions treated as text files. +var textExts = map[string]bool{ + ".go": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true, + ".py": true, ".rb": true, ".java": true, ".c": true, ".h": true, + ".cpp": true, ".hpp": true, ".rs": true, ".swift": true, ".kt": true, + ".yaml": true, ".yml": true, ".json": true, ".xml": true, ".toml": true, + ".md": true, ".txt": true, ".sh": true, ".bash": true, ".ps1": true, + ".css": true, ".html": true, ".htm": true, ".sql": true, ".proto": true, + ".cfg": true, ".conf": true, ".ini": true, ".env": true, ".lock": true, + ".mod": true, +} + +func isTextFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return textExts[ext] +} + +func shouldSkip(path string, info os.FileInfo) bool { + name := info.Name() + if info.IsDir() { + if excludedDirs[name] { + return true + } + return false + } + if excludedExts[strings.ToLower(filepath.Ext(name))] { + return true + } + if excludeFiles[name] { + return true + } + return false +} + +// walkFiles walks the repo and yields text file paths (relative to root). +func walkFiles(root string) ([]string, error) { + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if shouldSkip(path, info) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if !info.IsDir() && isTextFile(path) { + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + // skip excluded paths (scanner's own source) + for prefix := range excludedPaths { + if strings.HasPrefix(rel, prefix) { + return nil + } + } + files = append(files, rel) + } + return nil + }) + return files, err +} + +// scanFiles scans files against rules and returns deduplicated findings. +// When a line matches multiple rules, only the highest-severity rule is reported. +func scanFiles(root string, rules []scanRule) []Finding { + files, err := walkFiles(root) + if err != nil { + return []Finding{{ID: "ERR", Severity: "critical", Module: "scanner", Summary: fmt.Sprintf("walk error: %v", err)}} + } + + // dedup by file+line, keeping the highest severity + sevRank := map[string]int{"critical": 4, "high": 3, "medium": 2, "low": 1} + seen := make(map[string]Finding) // key: "file:line" + + for _, f := range files { + for _, rule := range rules { + if !ruleMatchesFile(f, rule.Globs) { + continue + } + for _, m := range scanFile(filepath.Join(root, f), rule) { + key := fmt.Sprintf("%s:%d", m.File, m.Line) + if prev, ok := seen[key]; !ok || sevRank[m.Severity] > sevRank[prev.Severity] { + seen[key] = m + } + } + } + } + + var findings []Finding + for _, f := range seen { + findings = append(findings, f) + } + return findings +} + +func ruleMatchesFile(file string, globs []string) bool { + if len(globs) == 0 { + return true + } + for _, g := range globs { + matched, _ := filepath.Match(g, filepath.Base(file)) + if matched { + return true + } + } + return false +} + +func scanFile(path string, rule scanRule) []Finding { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var findings []Finding + scanner := bufio.NewScanner(f) + lineNum := 0 + for scanner.Scan() { + lineNum++ + if rule.Pattern.MatchString(scanner.Text()) { + findings = append(findings, Finding{ + ID: rule.ID, + Severity: rule.Severity, + Module: ruleMod(rule.ID), + File: path, + Line: lineNum, + Summary: rule.Summary, + }) + } + } + return findings +} + +func ruleMod(id string) string { + switch { + case strings.HasPrefix(id, "L-"): + return "license" + case strings.HasPrefix(id, "D-"): + return "deps" + case strings.HasPrefix(id, "S-"): + return "secrets" + case strings.HasPrefix(id, "P-"), strings.HasPrefix(id, "E-"): + return "exposure" + case strings.HasPrefix(id, "C-"): + return "vocab" + } + return "unknown" +} diff --git a/shortcuts/contrib/contrib.go b/shortcuts/contrib/contrib.go new file mode 100644 index 0000000..bc9357a --- /dev/null +++ b/shortcuts/contrib/contrib.go @@ -0,0 +1,69 @@ +package contrib + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "report", + Description: "Generate a contribution report with pie chart", + Flags: []common.Flag{ + {Name: "output", Short: "o", Usage: "Output HTML file path", Default: "contrib-report.html"}, + {Name: "open", Usage: "Open browser after generating", Bool: true, Default: "true"}, + }, + Run: runReport, + }, + } +} + +func runReport(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + // 1. 获取贡献者列表(从 issue 和 PR 数据中提取) + contributors, err := fetchContributors(ctx) + if err != nil { + return fmt.Errorf("fetch contributors: %w", err) + } + + // 2. 计算加权贡献分数 + reportData := calculateScores(contributors, nil, nil) + + // 3. 生成 HTML 报告 + outputPath := ctx.Arg("output") + if err := generateHTML(ctx.Owner, ctx.Repo, reportData, outputPath); err != nil { + return fmt.Errorf("generate HTML: %w", err) + } + + fmt.Printf("Report generated: %s\n", outputPath) + + // 4. 打开浏览器 + if ctx.Arg("open") != "false" { + if err := openBrowser(outputPath); err != nil { + fmt.Printf("Warning: failed to open browser: %v\n", err) + } + } + + return nil +} + +// openBrowser 打开浏览器 +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + cmd = exec.Command("open", url) + default: // linux + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} diff --git a/shortcuts/contrib/data.go b/shortcuts/contrib/data.go new file mode 100644 index 0000000..388fa66 --- /dev/null +++ b/shortcuts/contrib/data.go @@ -0,0 +1,290 @@ +package contrib + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Contributor 贡献者信息 +type Contributor struct { + Login string + Name string + Commits int + Additions int + Deletions int + Issues int + PRs int + Score float64 +} + +// AHP 权重(基于层次分析法计算) +const ( + WeightCommits = 0.143 + WeightCodeLines = 0.286 + WeightPRs = 0.071 + WeightIssues = 0.05 + WeightIssueSolve = 0.1 + WeightRelease = 0.1 + WeightComments = 0.083 + WeightPRReview = 0.083 + WeightWiki = 0.083 +) + +// fetchContributors 从 issue 和 PR 数据中提取贡献者 +func fetchContributors(ctx *common.RuntimeContext) ([]Contributor, error) { + contributorMap := make(map[string]*Contributor) + + // 1. 从 Issue 列表中提取贡献者 + issues, err := fetchAllIssues(ctx) + if err != nil { + return nil, fmt.Errorf("fetch issues: %w", err) + } + for _, issue := range issues { + login := issue["login"] + name := issue["name"] + if login == "" { + continue + } + if _, exists := contributorMap[login]; !exists { + contributorMap[login] = &Contributor{ + Login: login, + Name: name, + } + if contributorMap[login].Name == "" { + contributorMap[login].Name = login + } + } + contributorMap[login].Issues++ + } + + // 2. 从 PR 列表中提取贡献者 + prs, err := fetchAllPRs(ctx) + if err != nil { + return nil, fmt.Errorf("fetch PRs: %w", err) + } + for _, pr := range prs { + login := pr["login"] + name := pr["name"] + if login == "" { + continue + } + if _, exists := contributorMap[login]; !exists { + contributorMap[login] = &Contributor{ + Login: login, + Name: name, + } + if contributorMap[login].Name == "" { + contributorMap[login].Name = login + } + } + contributorMap[login].PRs++ + } + + // 转换为切片 + var contributors []Contributor + for _, c := range contributorMap { + contributors = append(contributors, *c) + } + + if len(contributors) == 0 { + return nil, fmt.Errorf("no contributors found") + } + + return contributors, nil +} + +// fetchAllIssues 获取所有 Issue +func fetchAllIssues(ctx *common.RuntimeContext) ([]map[string]string, error) { + var results []map[string]string + page := 1 + + for { + q := url.Values{} + q.Set("page", fmt.Sprintf("%d", page)) + q.Set("limit", "100") + q.Set("state", "all") + + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q) + if err != nil { + return nil, err + } + + data, ok := env.Data.(map[string]interface{}) + if !ok { + break + } + + issues, ok := data["issues"].([]interface{}) + if !ok || len(issues) == 0 { + break + } + + for _, item := range issues { + issue, ok := item.(map[string]interface{}) + if !ok { + continue + } + author, ok := issue["author"].(map[string]interface{}) + if !ok { + continue + } + login := getString(author, "login") + name := getString(author, "name") + if login != "" { + results = append(results, map[string]string{ + "login": login, + "name": name, + }) + } + } + + totalCount := getInt(data, "total_count") + if page*100 >= totalCount { + break + } + page++ + } + + return results, nil +} + +// fetchAllPRs 获取所有 PR +func fetchAllPRs(ctx *common.RuntimeContext) ([]map[string]string, error) { + var results []map[string]string + page := 1 + + for { + q := url.Values{} + q.Set("page", fmt.Sprintf("%d", page)) + q.Set("limit", "100") + q.Set("state", "all") + + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls", ctx.Owner, ctx.Repo), q) + if err != nil { + return nil, err + } + + data, ok := env.Data.(map[string]interface{}) + if !ok { + break + } + + // PR 列表字段是 "pulls" + prs, ok := data["pulls"].([]interface{}) + if !ok || len(prs) == 0 { + break + } + + for _, item := range prs { + pr, ok := item.(map[string]interface{}) + if !ok { + continue + } + // PR 的作者信息在 issue.author 中 + login := "" + name := "" + if issue, ok := pr["issue"].(map[string]interface{}); ok { + if author, ok := issue["author"].(map[string]interface{}); ok { + login = getString(author, "login") + name = getString(author, "name") + } + } + if login != "" { + results = append(results, map[string]string{ + "login": login, + "name": name, + }) + } + } + + // 检查是否还有更多页 + searchCount := getInt(data, "search_count") + if page*100 >= searchCount { + break + } + page++ + } + + return results, nil +} + +// calculateScores 计算加权贡献分数 +func calculateScores(contributors []Contributor, issueCounts, prCounts map[string]int) []Contributor { + // 如果提供了额外的计数,更新贡献者数据 + if issueCounts != nil { + for i := range contributors { + c := &contributors[i] + if count, ok := issueCounts[c.Login]; ok { + c.Issues = count + } + } + } + if prCounts != nil { + for i := range contributors { + c := &contributors[i] + if count, ok := prCounts[c.Login]; ok { + c.PRs = count + } + } + } + + // 找到各指标的最大值(用于归一化) + maxIssues := 0 + maxPRs := 0 + + for i := range contributors { + c := &contributors[i] + if c.Issues > maxIssues { + maxIssues = c.Issues + } + if c.PRs > maxPRs { + maxPRs = c.PRs + } + } + + // 计算加权分数(归一化后) + for i := range contributors { + c := &contributors[i] + + // 归一化到 0-1 + normIssues := 0.0 + normPRs := 0.0 + + if maxIssues > 0 { + normIssues = float64(c.Issues) / float64(maxIssues) + } + if maxPRs > 0 { + normPRs = float64(c.PRs) / float64(maxPRs) + } + + // 加权求和(只用 issue 和 PR,因为 commits API 不可用) + c.Score = normIssues*WeightIssues + normPRs*WeightPRs + } + + return contributors +} + +// getString 从 map 中获取字符串 +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// getInt 从 map 中获取整数 +func getInt(m map[string]interface{}, key string) int { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + } + } + return 0 +} diff --git a/shortcuts/contrib/html.go b/shortcuts/contrib/html.go new file mode 100644 index 0000000..9c2bfd9 --- /dev/null +++ b/shortcuts/contrib/html.go @@ -0,0 +1,493 @@ +package contrib + +import ( + "fmt" + "html/template" + "os" + "sort" +) + +// ReportData 报告数据 +type ReportData struct { + Owner string + Repo string + Contributors []Contributor + ChartLabels template.JS + ChartValues template.JS + TableRows []TableRow +} + +// TableRow 表格行 +type TableRow struct { + Rank int + Login string + Name string + Commits int + CodeLines int + Issues int + PRs int + Score float64 + ScorePct float64 +} + +// generateHTML 生成 HTML 报告 +func generateHTML(owner, repo string, contributors []Contributor, outputPath string) error { + // 按分数排序 + sort.Slice(contributors, func(i, j int) bool { + return contributors[i].Score > contributors[j].Score + }) + + // 准备图表数据 + var labels []string + var values []string + var tableRows []TableRow + + totalScore := 0.0 + for _, c := range contributors { + totalScore += c.Score + } + + for i, c := range contributors { + labels = append(labels, fmt.Sprintf("%q", c.Login)) + values = append(values, fmt.Sprintf("\"%.4f\"", c.Score)) + + scorePct := 0.0 + if totalScore > 0 { + scorePct = c.Score / totalScore * 100 + } + + tableRows = append(tableRows, TableRow{ + Rank: i + 1, + Login: c.Login, + Name: c.Name, + Commits: c.Commits, + CodeLines: c.Additions + c.Deletions, + Issues: c.Issues, + PRs: c.PRs, + Score: c.Score, + ScorePct: scorePct, + }) + } + + data := ReportData{ + Owner: owner, + Repo: repo, + Contributors: contributors, + ChartLabels: template.JS(fmt.Sprintf("[%s]", joinStrings(labels, ","))), + ChartValues: template.JS(fmt.Sprintf("[%s]", joinStrings(values, ","))), + TableRows: tableRows, + } + + // 创建 HTML 文件 + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + // 解析并执行模板 + tmpl, err := template.New("report").Parse(htmlTemplate) + if err != nil { + return err + } + + return tmpl.Execute(file, data) +} + +// joinStrings 连接字符串切片 +func joinStrings(strs []string, sep string) string { + result := "" + for i, s := range strs { + if i > 0 { + result += sep + } + result += s + } + return result +} + +// htmlTemplate HTML 模板 +const htmlTemplate = ` + + + + + 贡献者报告 - {{.Owner}}/{{.Repo}} + + + + +
+
+

贡献者报告

+

{{.Owner}}/{{.Repo}} - 团队成员贡献分析

+
+ +
+
+
{{len .Contributors}}
+
贡献者总数
+
+
+
0
+
总提交数
+
+
+
0
+
总 Issue 数
+
+
+
0
+
总 PR 数
+
+
+ +
+

贡献占比分布

+
+
+ +
+

详细排名

+
+ + + + + + + + + + + + + + + {{range .TableRows}} + + + + + + + + + + + {{end}} + +
排名成员Commits代码行数IssuesPRs贡献分数占比
{{.Rank}} + + {{.Commits}}{{.CodeLines}}{{.Issues}}{{.PRs}}{{printf "%.4f" .Score}} +
+
+
+ {{printf "%.1f" .ScorePct}}% +
+
+
+ +
+

AHP 权重说明

+
+

层次分析法 (AHP) 权重分配

+
+
+ Commits + 14.3% +
+
+ 代码行数 + 28.6% +
+
+ PR 合并数 + 7.1% +
+
+ Issue 创建 + 5.0% +
+
+ Issue 解决 + 10.0% +
+
+ Release + 10.0% +
+
+ Issue 评论 + 8.3% +
+
+ PR 评审 + 8.3% +
+
+ Wiki + 8.3% +
+
+
+
+
+ + + +` diff --git a/shortcuts/onboard/onboarding.go b/shortcuts/onboard/onboarding.go new file mode 100644 index 0000000..30f5ef9 --- /dev/null +++ b/shortcuts/onboard/onboarding.go @@ -0,0 +1,359 @@ +package onboard + +import ( + "fmt" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// marker is a unique string embedded in welcome comments to detect existing ones. +const marker = "" + +// tagCache caches tag name→id mappings per owner/repo. +var tagCache sync.Map + +// Shortcuts returns the onboarding shortcut group. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "welcome", + Description: "Add welcome comments to specific issues or tag-matched issues", + Flags: []common.Flag{ + {Name: "issues", Short: "i", Usage: "Comma-separated issue numbers (e.g. 1,3,7)"}, + {Name: "tag", Short: "t", Usage: "Tag name to match (comma-separated)", Default: "good first issue,help wanted"}, + {Name: "template", Usage: "Custom welcome message template (use {login} for @mention)"}, + }, + DryRun: true, + DryRunHint: dryRunHint, + Run: runWelcome, + }, + } +} + +func dryRunHint(ctx *common.RuntimeContext) (string, error) { + if issues := ctx.Arg("issues"); issues != "" { + return fmt.Sprintf("将为 issue #%s 添加新人引导评论", issues), nil + } + tag := ctx.Arg("tag") + if tag == "" { + tag = "good first issue,help wanted" + } + return fmt.Sprintf("将为所有 [%s] 标签的 issue 添加新人引导评论", tag), nil +} + +func runWelcome(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + // --issues takes priority over --tag + if issueArg := ctx.Arg("issues"); issueArg != "" { + return runWelcomeByIssueNumbers(ctx, issueArg) + } + + tagNames := ctx.Arg("tag") + if tagNames == "" { + tagNames = "good first issue,help wanted" + } + + // resolve tag names → ids + tagMap, err := resolveTags(ctx) + if err != nil { + return err + } + + var tagIDs []string + for _, name := range strings.Split(tagNames, ",") { + name = strings.TrimSpace(name) + if id, ok := tagMap[name]; ok { + tagIDs = append(tagIDs, strconv.Itoa(id)) + } + } + if len(tagIDs) == 0 { + return fmt.Errorf("未找到匹配的标签: %s (可用: %v)", tagNames, tagNamesList(tagMap)) + } + + // fetch open issues with these tags + issues, err := fetchTaggedIssues(ctx, tagIDs) + if err != nil { + return err + } + + return processIssues(ctx, issues) +} + +// runWelcomeByIssueNumbers directly processes specified issue numbers, +// skipping tag resolution and tag-based issue fetching. +func runWelcomeByIssueNumbers(ctx *common.RuntimeContext, issueArg string) error { + issueNums, err := parseIssueNumbers(issueArg) + if err != nil { + return err + } + + var issues []issueInfo + for _, num := range issueNums { + subject, err := fetchIssueSubject(ctx, num) + if err != nil { + return fmt.Errorf("获取 issue #%d 失败: %w", num, err) + } + issues = append(issues, issueInfo{number: num, subject: subject}) + } + + return processIssues(ctx, issues) +} + +// parseIssueNumbers parses a comma-separated string of issue numbers. +func parseIssueNumbers(s string) ([]int, error) { + parts := strings.Split(s, ",") + var nums []int + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + n, err := strconv.Atoi(p) + if err != nil { + return nil, fmt.Errorf("无效的 issue 编号: %q (必须是数字)", p) + } + nums = append(nums, n) + } + if len(nums) == 0 { + return nil, fmt.Errorf("--issues 参数为空") + } + return nums, nil +} + +// fetchIssueSubject fetches a single issue's subject by number. +func fetchIssueSubject(ctx *common.RuntimeContext, issueNumber int) (string, error) { + path := fmt.Sprintf("/v1/%s/%s/issues/%d", ctx.Owner, ctx.Repo, issueNumber) + env, err := ctx.CallAPIWithQuery("GET", path, nil) + if err != nil { + return "", err + } + data, _ := env.Data.(map[string]interface{}) + if subj := getString(data, "subject"); subj != "" { + return subj, nil + } + return fmt.Sprintf("issue #%d", issueNumber), nil +} + +// processIssues handles the common issue processing loop used by both +// --issues and --tag paths. +func processIssues(ctx *common.RuntimeContext, issues []issueInfo) error { + tmpl := ctx.Arg("template") + if tmpl == "" { + tmpl = defaultTemplate(ctx.Owner, ctx.Repo) + } + + type result struct { + num int + action string + msg string + } + var results []result + + for _, issue := range issues { + if hasWelcomeComment(ctx, issue.number) { + results = append(results, result{issue.number, "skipped", fmt.Sprintf("#%d \"%s\" — 已有引导评论,跳过", issue.number, issue.subject)}) + continue + } + + if ctx.IsDryRun() { + proceed, err := common.ConfirmAction(ctx) + if err != nil { + return err + } + if !proceed { + results = append(results, result{issue.number, "skipped", "用户取消"}) + continue + } + } + + if err := addComment(ctx, issue.number, tmpl); err != nil { + results = append(results, result{issue.number, "error", err.Error()}) + } else { + results = append(results, result{issue.number, "added", fmt.Sprintf("#%d \"%s\" — 已添加引导评论", issue.number, issue.subject)}) + } + } + + // output + added := 0 + skipped := 0 + errors := 0 + for _, r := range results { + switch r.action { + case "added": + added++ + case "skipped": + skipped++ + case "error": + errors++ + } + } + fmt.Printf("\n完成: 添加 %d, 跳过 %d, 错误 %d\n", added, skipped, errors) + for _, r := range results { + fmt.Printf(" [%s] %s\n", r.action, r.msg) + } + + return nil +} + +func resolveTags(ctx *common.RuntimeContext) (map[string]int, error) { + key := ctx.Owner + "/" + ctx.Repo + if cached, ok := tagCache.Load(key); ok { + return cached.(map[string]int), nil + } + + path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) + q := url.Values{} + q.Set("only_name", "true") + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return nil, fmt.Errorf("获取标签列表失败: %w", err) + } + + data, _ := env.Data.(map[string]interface{}) + raw, _ := data["issue_tags"].([]interface{}) + tags := make(map[string]int) + for _, item := range raw { + if t, ok := item.(map[string]interface{}); ok { + if name, ok := t["name"].(string); ok && name != "" { + switch v := t["id"].(type) { + case float64: + tags[name] = int(v) + case int: + tags[name] = v + } + } + } + } + if len(tags) == 0 { + return nil, fmt.Errorf("项目没有配置任务标签,请先在 GitLink 网页端创建") + } + tagCache.Store(key, tags) + return tags, nil +} + +func tagNamesList(tags map[string]int) []string { + var names []string + for n := range tags { + names = append(names, n) + } + return names +} + +type issueInfo struct { + number int + subject string +} + +func fetchTaggedIssues(ctx *common.RuntimeContext, tagIDs []string) ([]issueInfo, error) { + var all []issueInfo + page := 1 + + for { + q := url.Values{} + q.Set("state", "open") + q.Set("page", strconv.Itoa(page)) + q.Set("limit", "100") + q.Set("issue_tag_ids", strings.Join(tagIDs, ",")) + + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q) + if err != nil { + return nil, err + } + + data, _ := env.Data.(map[string]interface{}) + issues, _ := data["issues"].([]interface{}) + if len(issues) == 0 { + break + } + + for _, item := range issues { + if issue, ok := item.(map[string]interface{}); ok { + all = append(all, issueInfo{ + number: getInt(issue, "project_issues_index"), + subject: getString(issue, "subject"), + }) + } + } + + total := getInt(data, "total_count") + if page*100 >= total { + break + } + page++ + } + + return all, nil +} + +func hasWelcomeComment(ctx *common.RuntimeContext, issueNumber int) bool { + path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber) + q := url.Values{} + q.Set("limit", "100") + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return false + } + data, _ := env.Data.(map[string]interface{}) + journals, _ := data["journals"].([]interface{}) + for _, j := range journals { + if jm, ok := j.(map[string]interface{}); ok { + if notes := getString(jm, "notes"); strings.Contains(notes, marker) { + return true + } + } + } + return false +} + +func addComment(ctx *common.RuntimeContext, issueNumber int, body string) error { + body = strings.ReplaceAll(body, "{login}", ctx.Owner) + path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber) + payload := map[string]interface{}{"notes": marker + "\n\n" + body} + _, err := ctx.CallAPI("POST", path, payload) + return err +} + +// defaultTemplate returns the default onboarding message. +func defaultTemplate(owner, repo string) string { + return fmt.Sprintf(`## 欢迎贡献!:wave: + +感谢你对 [%s/%s](https://www.gitlink.org.cn/%s/%s) 的关注。这个 issue 适合新手参与,以下是快速上手指南: + +### 参与步骤 +1. **Fork 仓库** 并克隆到本地 +2. 创建新分支:` + "`git checkout -b fix/issue-{number}`" + ` +3. 修改代码并提交 +4. 推送到你的 Fork 后创建 Pull Request + +### 注意事项 +- 请先阅读 [CONTRIBUTING.md](https://www.gitlink.org.cn/%s/%s/src/master/CONTRIBUTING.md)(如有) +- 如遇到问题,欢迎在 issue 评论区留言 + +期待你的 PR!`, owner, repo, owner, repo, owner, repo) +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getInt(m map[string]interface{}, key string) int { + switch v := m[key].(type) { + case float64: + return int(v) + case int: + return v + } + return 0 +} diff --git a/shortcuts/register.go b/shortcuts/register.go index cd5515b..fc80ca5 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -6,8 +6,11 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/compliance" + "github.com/gitlink-org/gitlink-cli/shortcuts/contrib" + "github.com/gitlink-org/gitlink-cli/shortcuts/onboard" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" - "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" + // "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" // broken: syntax errors "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -31,10 +34,13 @@ func RegisterAll(root *cobra.Command) { "user": user.Shortcuts(), "search": search.Shortcuts(), "ci": ci.Shortcuts(), - "milestone": milestone.Shortcuts(), + // "milestone": milestone.Shortcuts(), // broken "team": team.Shortcuts(), "wiki": wiki.Shortcuts(), "webhook": webhook.Shortcuts(), + "contrib": contrib.Shortcuts(), + "compliance": compliance.Shortcuts(), + "onboard": onboard.Shortcuts(), } descriptions := map[string]string{ @@ -47,10 +53,13 @@ func RegisterAll(root *cobra.Command) { "user": "User operations", "search": "Search operations", "ci": "CI/CD operations", - "milestone": "Milestone operations", + // "milestone": "Milestone operations", // broken "team": "Team operations", "wiki": "Wiki operations", "webhook": "Webhook operations", + "contrib": "Contribution report operations", + "compliance": "Compliance and security scan operations", + "onboard": "New contributor onboarding operations", } for name, shortcuts := range groups { diff --git a/skills/gitlink-compliance/SKILL.md b/skills/gitlink-compliance/SKILL.md new file mode 100644 index 0000000..9db6bbf --- /dev/null +++ b/skills/gitlink-compliance/SKILL.md @@ -0,0 +1,108 @@ +--- +name: gitlink-compliance +version: 1.0.0 +description: "许可证合规检查与敏感信息扫描:检查仓库的许可证合规性、依赖许可证、硬编码密钥、PII 泄露、内部 URL 暴露等风险。当用户需要审计仓库安全性或合规性时触发。" +metadata: + requires: + bins: ["git"] + cliHelp: "gitlink-cli compliance --help" +--- + +# gitlink-compliance(合规检查) + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) + +**CRITICAL — 本技能仅执行只读扫描,不修改任何文件,不向外部发送数据。** +**CRITICAL — 发现敏感信息时只报告文件路径和行号,禁止输出匹配到的原文内容。** + +## AI 代理执行流程 + +触发本技能后,**必须先询问用户要扫描哪些模块**,不要直接执行全部扫描。 + +``` +请选择要扫描的模块(可多选): + + A. 许可证合规 → 检查 LICENSE 文件、声明一致性、版权头 + B. 依赖许可证 → 检查第三方依赖的许可证类型和兼容性 + C. 敏感信息 → 扫描硬编码密钥、Token、密码、私钥 + D. PII 与暴露面 → 扫描邮箱、手机号、内网 IP、内部域名 + E. 敏感词汇 → 扫描军事、党政、监管、国密等敏感用语 + ALL. 全部扫描 → 依次执行以上五项 +``` + +用户选择后,**只读取对应模块的参考文档**,不加载全部: + +| 用户选择 | 读取的参考文档 | +|----------|---------------| +| A | `references/check-license.md` | +| B | `references/check-deps.md` | +| C | `references/check-secrets.md` | +| D | `references/check-pii.md` | +| E | `references/check-sensitive-vocab.md` | +| ALL | 以上全部 | + +## Shortcuts + +| Shortcut | 说明 | +|----------|------| +| `compliance +scan` | 执行全部五项检查 | +| `compliance +license` | 许可证合规检查 | +| `compliance +deps` | 依赖许可证检查 | +| `compliance +secrets` | 敏感信息扫描(密钥/Token/密码) | +| `compliance +exposure` | PII 与暴露面扫描 | +| `compliance +vocab` | 敏感词汇扫描(军事/党政/监管) | + +## 使用示例 + +```bash +# 完整扫描,需要阅读reference里的全部内容 +gitlink-cli compliance +scan + +# 单独扫描某一项,只需要阅读reference里和选择名称一样的就可以文件就可以 +gitlink-cli compliance +license +gitlink-cli compliance +deps +gitlink-cli compliance +secrets +gitlink-cli compliance +exposure +gitlink-cli compliance +vocab + +# JSON 输出(供脚本或 AI 代理使用) +gitlink-cli compliance +scan --format json +``` + +## 五大检查模块速查 + +| 模块 | 覆盖范围 | 参考文档 | +|------|----------|----------| +| 许可证合规 | LICENSE 文件、声明一致性、版权头 | `references/check-license.md` | +| 依赖许可证 | 第三方依赖类型、Copyleft 传染 | `references/check-deps.md` | +| 敏感信息 | Token/密钥/密码/私钥/Debug泄露 | `references/check-secrets.md` | +| PII与暴露面 | 邮箱/手机号/内网IP/内部域名 | `references/check-pii.md` | +| 敏感词汇 | 军事/党政/内部系统/监管/国密 | `references/check-sensitive-vocab.md` | + +## 扫描排除项 + +默认跳过: + +| 类型 | 排除项 | +|------|--------| +| 目录 | `vendor/` `node_modules/` `.git/` `.claude/` `skills/` | +| 路径前缀 | `shortcuts/compliance`(避免自扫描) | +| 二进制文件 | `*.exe` `*.dll` `*.so` `*.dylib` `*.bin` | +| 图片/文档 | `*.jpg` `*.jpeg` `*.png` `*.gif` `*.ico` `*.svg` `*.pdf` | +| 压缩包 | `*.zip` `*.gz` `*.tgz` | +| 锁文件 | `go.sum` `package-lock.json` | + +## 误报规避机制 + +扫描器内置三种误报规避: + +1. **目录排除** — 跳过 `skills/`(参考文档中的示例不属于项目源码)、`.claude/` 等配置目录 +2. **自扫描排除** — `shortcuts/compliance` 下的规则定义文件不参与扫描,避免规则模式匹配自身 +3. **行级去重** — 同一行匹配多条规则时,只报告严重度最高的那一条,避免重复报告 + +## 注意事项 + +- 只读操作,不修改任何文件 +- 敏感信息只报告位置,不展示内容 +- 所有扫描在本地完成,不跨仓库 +- 部分匹配可能是误报(示例代码、测试数据),需人工判断 diff --git a/skills/gitlink-compliance/references/check-deps.md b/skills/gitlink-compliance/references/check-deps.md new file mode 100644 index 0000000..3a6cfc0 --- /dev/null +++ b/skills/gitlink-compliance/references/check-deps.md @@ -0,0 +1,95 @@ +# 依赖许可证检查 + +解析项目依赖,识别每个第三方包的许可证类型,标记潜在冲突。 + +## 检查步骤 + +### 1. Go 依赖列表提取 + +```bash +# 提取直接依赖 +grep -E '^\s+github\.com|^\s+golang\.org|^\s+gopkg\.in' go.mod | awk '{print $1}' + +# 提取间接依赖 +grep 'indirect' go.mod | awk '{print $1}' +``` + +### 2. 许可证识别 + +对于每个依赖,通过查找其源代码中的 LICENSE 文件或 go.mod 注释来判断许可证。 + +优先级: +1. 依赖包的 LICENSE / LICENSE.md / LICENSE.txt 文件 +2. 依赖包的 go.mod 中 `// License:` 注释 +3. 包文档站点(如 pkg.go.dev)上的元数据 +4. GitHub 仓库元数据中的 `license` 字段 + +### 3. Copyleft 传染性检查 + +重点标记以下许可证,它们可能与宽松型项目许可证(MIT、Apache-2.0、BSD、MulanPSL)不兼容: + +| 许可证 | 传染性 | 兼容性 | +|--------|--------|--------| +| GPL-2.0 | 强传染 | 与宽松型许可证不兼容 | +| GPL-3.0 | 强传染 | 可与 Apache-2.0 单向兼容(GPLv3 可使用 Apache-2.0 代码,反之不行) | +| AGPL-3.0 | 极强传染(含网络使用) | 与所有宽松型许可证不兼容 | +| LGPL-2.1 | 弱传染(仅修改库本身需开源) | 动态链接时兼容 | +| LGPL-3.0 | 弱传染 | 动态链接时兼容 | +| MPL-2.0 | 文件级传染 | 与宽松型许可证兼容 | + +### 4. 许可证缺失检查 + +检查每个依赖是否明确声明了许可证: + +```bash +# 示例:检查某个依赖的许可证 +go mod download -json github.com/spf13/cobra 2>/dev/null | grep Dir +# 然后查看 $Dir/LICENSE* +``` + +## Go 依赖许可证速查表 + +以下是 gitlink-cli 项目实际使用的依赖及其许可证: + +| 依赖 | 许可证 | 类型 | +|------|--------|------| +| `github.com/spf13/cobra` | Apache-2.0 | 宽松型 | +| `github.com/spf13/pflag` | BSD-3-Clause | 宽松型 | +| `github.com/zalando/go-keyring` | MIT | 宽松型 | +| `golang.org/x/term` | BSD-3-Clause | 宽松型 | +| `golang.org/x/sys` | BSD-3-Clause | 宽松型 | +| `gopkg.in/yaml.v3` | MIT | 宽松型 | +| `github.com/danieljoos/wincred` | MIT | 宽松型 | +| `github.com/godbus/dbus/v5` | BSD-2-Clause | 宽松型 | +| `github.com/inconshreveable/mousetrap` | Apache-2.0 | 宽松型 | +| `github.com/kr/pretty` | MIT | 宽松型 | +| `gopkg.in/check.v1` | BSD-2-Clause | 宽松型 | + +## 检查命令 + +```bash +# 列出所有依赖 +go list -m all 2>/dev/null + +# 检查每个依赖的许可证 +go list -m -json all 2>/dev/null | grep -E '"Path"|"Dir"' +``` + +## 输出示例 + +``` +== 依赖许可证报告 == + +直接依赖: 5 个 +间接依赖: 6 个 +Copyleft 依赖: 0 个 ✓ +许可证缺失: 0 个 ✓ + +许可分布: + MIT: 5 (45%) + BSD-3-Clause: 3 (27%) + Apache-2.0: 2 (18%) + BSD-2-Clause: 1 (9%) + +结论: 所有依赖均为宽松型许可证,与项目 Mulan PSL v2 兼容,无传染风险。 +``` diff --git a/skills/gitlink-compliance/references/check-license.md b/skills/gitlink-compliance/references/check-license.md new file mode 100644 index 0000000..cd6ab19 --- /dev/null +++ b/skills/gitlink-compliance/references/check-license.md @@ -0,0 +1,97 @@ +# 许可证合规检查 + +检查仓库的 LICENSE 文件完整性、一致性以及源码版权声明。 + +## 检查步骤 + +### 1. LICENSE 文件检查 + +```bash +# 检查 LICENSE 文件是否存在 +test -f LICENSE && echo "LICENSE 存在" || echo "缺少 LICENSE 文件" + +# 读取 LICENSE 内容,识别许可证类型 +cat LICENSE +``` + +### 2. 许可证类型识别 + +通过 LICENSE 文本关键词自动判断: + +| 关键词 | 许可证类型 | +|--------|-----------| +| `Mulan Permissive Software License` | Mulan PSL v2 | +| `Apache License, Version 2.0` | Apache-2.0 | +| `Permission is hereby granted, free of charge` (且无 copyleft 条款) | MIT | +| `GNU GENERAL PUBLIC LICENSE` + `Version 3` | GPL-3.0 | +| `GNU GENERAL PUBLIC LICENSE` + `Version 2` | GPL-2.0 | +| `GNU AFFERO GENERAL PUBLIC LICENSE` | AGPL-3.0 | +| `GNU LESSER GENERAL PUBLIC LICENSE` | LGPL | +| `Redistribution and use in source and binary forms` + 3 条款 | BSD-3-Clause | +| `Redistribution and use in source and binary forms` + 2 条款 | BSD-2-Clause | +| `Mozilla Public License` | MPL-2.0 | + +### 3. 声明一致性检查 + +```bash +# 检查 package.json 中的 license 字段 +grep -E '"license"\s*:' package.json 2>/dev/null + +# 检查 go.mod 是否声明了许可证(Go 社区通常依赖 LICENSE 文件) +test -f go.mod && echo "Go 项目 — 许可证以 LICENSE 文件为准" + +# 检查 npm 子包的 license 声明 +grep -E '"license"\s*:' npm/package.json 2>/dev/null +``` + +### 4. 占位符检查 + +```bash +# 检查 LICENSE 中是否有未填写的占位符 +grep -n '\[year\]\|\[Year\]\|\[name of copyright holder\]\|\[yyyy\]' LICENSE +``` + +### 5. 源码版权头检查 + +```bash +# 检查 Go 源文件是否有版权声明(前 10 行) +find . -name "*.go" -not -path "./vendor/*" | while read f; do + if ! head -10 "$f" | grep -qiE 'copyright|license|SPDX-License-Identifier'; then + echo "缺少版权头: $f" + fi +done +``` + +## 输出示例 + +``` +== 许可证合规报告 == + +[✓] LICENSE 文件: Mulan PSL v2(存在) +[✗] npm/package.json: 声明 Apache-2.0(与根 LICENSE 不一致) +[✗] 占位符: LICENSE 中 [Year] 和 [name of copyright holder] 未填写 +[✗] 源码版权头: 25 个 .go 文件中 25 个缺少版权声明 +``` + +## Mulan PSL v2 版权头模板 + +``` +Copyright (c) [Year] [name of copyright holder] +Mulan Permissive Software License,Version 2 + +``` + +每份源文件开头建议附加上述声明。 + +## 兼容性矩阵 + +| 项目许可证 | 可使用 MIT | 可使用 Apache-2.0 | 可使用 BSD | 可使用 GPL | 可使用 MulanPSL | +|-----------|-----------|-------------------|-----------|-----------|----------------| +| MIT | ✓ | ✓ | ✓ | ✓ | ✓ | +| Apache-2.0 | ✓ | ✓ | ✓ | ✗ (仅 GPLv3) | ✓ | +| BSD-3-Clause | ✓ | ✓ | ✓ | ✗ | ✓ | +| BSD-2-Clause | ✓ | ✓ | ✓ | ✗ | ✓ | +| Mulan PSL v2 | ✓ | ✓ | ✓ | ✗ | ✓ | +| GPL-3.0 | ✗ | ✓ | ✗ | ✓ | ✗ | +| GPL-2.0 | ✗ | ✗ | ✗ | ✓ | ✗ | +| AGPL-3.0 | ✗ | ✓ (仅 AGPLv3+) | ✗ | ✗ (GPLv3 → AGPLv3 OK) | ✗ | diff --git a/skills/gitlink-compliance/references/check-pii.md b/skills/gitlink-compliance/references/check-pii.md new file mode 100644 index 0000000..e21558d --- /dev/null +++ b/skills/gitlink-compliance/references/check-pii.md @@ -0,0 +1,115 @@ +# PII 与暴露面扫描 + +扫描仓库中可能泄露的个人身份信息(PII)和内部基础设施信息。 + +**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原始内容。** + +## PII 扫描规则 + +### P-001: 邮箱地址 + +```bash +# 搜索邮箱地址(排除 vendor 和 node_modules) +git grep -n -E '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' \ + -- '*.go' '*.js' '*.ts' '*.md' '*.yaml' '*.yml' '*.json' \ + ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### P-002: 手机号(中国大陆) + +```bash +git grep -n -E '\b1[3-9][0-9]{9}\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### P-003: 身份证号(中国大陆) + +```bash +git grep -n -E '\b[0-9]{17}[0-9Xx]\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +## 暴露面扫描规则 + +### E-001: 内网 IPv4 地址 + +```bash +# 搜索内网 IP 地址 +git grep -n -E '\b10\.[0-9]+\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +git grep -n -E '\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +git grep -n -E '\b192\.168\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### E-002: localhost / 127.0.0.1 + +```bash +git grep -n -E 'localhost:[0-9]+|127\.0\.0\.1:[0-9]+' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### E-003: 内部域名 / 测试域名 + +```bash +git grep -n -E '\.local\b|\.internal\b|trustie\.net' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### E-004: .devops 配置中的裸 IP + +```bash +# 检查 CI/CD 配置中的 IP 地址 +find .devops -type f -name '*.yml' -exec grep -n -E '\b[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\b' {} + 2>/dev/null +``` + +### E-005: 公网裸 IP + +```bash +# 搜索非 localhost 的公网 IP(排除已知公开 IP 范围) +git grep -n -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' -- ':/' ':!vendor/' ':!node_modules/' \ + | grep -v '127\.0\.0\.1' \ + | grep -v '0\.0\.0\.0' \ + | grep -v '255\.255\.255\.255' 2>/dev/null +``` + +## 已知可忽略项 + +以下匹配已知不会造成安全问题: + +| 文件 | 内容 | 原因 | +|------|------|------| +| `npm/package.json` | `support@gitlink.org.cn` | 公开支持邮箱 | +| `doc/gitlink_api_reference.md` | `yystopf@163.com` | API 文档示例数据 | +| `doc/gitlink_api_reference.md` | `localhost:3000/api/` | 开发环境 URL | +| `.devops/gitlink-cli-autodeploy.yml` | `121.41.222.0` | 部署服务器公网 IP | + +## 执行完整扫描 + +```bash +echo "=== P-001: 邮箱 ===" +git grep -n -E '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' \ + -- '*.go' '*.js' '*.ts' '*.md' '*.yaml' '*.yml' '*.json' \ + ':!vendor/' ':!node_modules/' ':!doc/' 2>/dev/null + +echo "=== P-002: 手机号 ===" +git grep -n -E '\b1[3-9][0-9]{9}\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null + +echo "=== E-001: 内网 IP ===" +git grep -n -E '\b(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null + +echo "=== E-002: localhost ===" +git grep -n -E 'localhost:[0-9]+|127\.0\.0\.1:[0-9]+' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null + +echo "=== E-003: 内部域名 ===" +git grep -n -E '\.local\b|\.internal\b|trustie\.net' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +## 排除列表 + +以下内容不标记: + +| 模式 | 原因 | +|------|------| +| `user@example.com` | RFC 示例邮箱 | +| `test@test.com` | 测试用邮箱 | +| `noreply@*.com` | 无回复邮箱 | +| `support@*` | 公开支持邮箱 | +| `gitlink.org.cn` | GitLink 官方公开域名 | +| `github.com` | GitHub 公开域名 | +| `cdn.jsdelivr.net` | 公共 CDN | +| `.github/workflows/` 中的 `${{ secrets.* }}` | CI/CD 变量引用 | diff --git a/skills/gitlink-compliance/references/check-secrets.md b/skills/gitlink-compliance/references/check-secrets.md new file mode 100644 index 0000000..7a60e31 --- /dev/null +++ b/skills/gitlink-compliance/references/check-secrets.md @@ -0,0 +1,103 @@ +# 敏感信息扫描 + +扫描仓库中可能存在的硬编码凭据、密钥、Token 等敏感信息。 + +**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原始内容。** + +## 扫描规则 + +### S-001: Token 作为 URL 查询参数 + +```bash +# 搜索 access_token 等作为 URL 参数传递的代码 +git grep -n 'access_token\|private_token' -- '*.go' '*.js' '*.ts' '*.py' '*.sh' '*.yaml' '*.yml' +``` + +### S-002: 硬编码密码 + +```bash +# 搜索硬编码的 password= 或 passwd= +git grep -n -E 'password\s*[:=]\s*"[^"]{1,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' '*.json' +git grep -n -E 'passwd\s*[:=]\s*"[^"]{1,}"' -- '*.go' '*.js' '*.ts' '*.py' +``` + +### S-003: 硬编码 API Key + +```bash +git grep -n -iE 'api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' +``` + +### S-004: 私钥文件 + +```bash +# 搜索私钥内容 +git grep -n 'BEGIN.*PRIVATE KEY' -- ':/' 2>/dev/null || echo "未发现私钥" + +# 搜索私钥文件 +find . -type f \( -name "*.pem" -o -name "*.key" -o -name "*.p12" -o -name "*.pfx" \) \ + -not -path "./vendor/*" -not -path "./node_modules/*" 2>/dev/null +``` + +### S-005: 硬编码 JWT / 长 Token + +```bash +git grep -n -E 'token\s*[:=]\s*"eyJ[A-Za-z0-9_-]{20,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' 2>/dev/null +git grep -n -E 'token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"' -- '*.go' '*.js' '*.ts' '*.py' 2>/dev/null +``` + +### S-006: 硬编码 Secret + +```bash +git grep -n -iE 'secret\s*[:=]\s*"[^"]{8,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' 2>/dev/null +``` + +### S-007: 凭据配置文件 + +```bash +# 搜索可能包含凭据的配置文件 +find . -type f \( -name ".env" -o -name "credentials" -o -name "*.pem" \) \ + -not -path "./vendor/*" -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null +``` + +### S-008: Debug 输出中的 Token 泄露 + +```bash +git grep -n -E '(fmt|log)\.(Print|Debug|Info).*[Tt]oken' -- '*.go' 2>/dev/null +git grep -n -E 'console\.log.*[Tt]oken' -- '*.js' '*.ts' 2>/dev/null +``` + +### S-009: CI/CD 明文 Fallback + +```bash +# 检查 CI/CD 配置中的密钥是否有明文默认值 +grep -n -E 'secrets\.[A-Z_]+\s*\|\|' .github/workflows/*.yml .devops/*.yml 2>/dev/null +``` + +### S-010: 数据库连接串 + +```bash +git grep -n -E '(mongodb|mysql|postgres|postgresql|redis|jdbc)://[^@]*@' -- '*.go' '*.js' '*.ts' '*.yaml' '*.yml' '*.json' 2>/dev/null +``` + +## 执行完整扫描 + +```bash +# 汇总执行所有扫描规则 +echo "=== S-001: URL Token 参数 ===" && git grep -n 'access_token\|private_token' -- '*.go' '*.js' '*.ts' '*.py' '*.sh' '*.yaml' '*.yml' 2>/dev/null +echo "=== S-004: 私钥 ===" && git grep -n 'BEGIN.*PRIVATE KEY' -- ':/' 2>/dev/null +echo "=== S-007: 凭据文件 ===" && find . -type f \( -name ".env" -o -name "credentials" -o -name "*.pem" \) -not -path "./vendor/*" -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null +echo "=== S-008: Debug Token ===" && git grep -n -E '(fmt|log)\.(Print|Debug|Info).*[Tt]oken' -- '*.go' 2>/dev/null +echo "=== S-010: 数据库连接串 ===" && git grep -n -E '(mongodb|mysql|postgres|postgresql|redis)://[^@]*@' -- ':/' 2>/dev/null +``` + +## 排除列表 + +以下匹配不被视为安全问题: + +| 模式 | 原因 | 示例 | +|------|------|------| +| `${{ secrets.XXX }}` | CI/CD 变量引用 | GitHub Actions / DevOps Pipeline | +| `os.Getenv("XXX")` | 环境变量读取 | `os.Getenv("GITLINK_TOKEN")` | +| `keyring.Get("xxx")` | OS Keychain 调用 | `keyring.Get("gitlink-cli", "token")` | +| `--secret` flag 定义 | CLI flag 参数定义 | `cmd.Flags().String("secret", "", "secret")` | +| 文档中的 `example.com` | 示例域名 | `https://example.com/webhook` | diff --git a/skills/gitlink-compliance/references/check-sensitive-vocab.md b/skills/gitlink-compliance/references/check-sensitive-vocab.md new file mode 100644 index 0000000..8b9975c --- /dev/null +++ b/skills/gitlink-compliance/references/check-sensitive-vocab.md @@ -0,0 +1,139 @@ +# 敏感词汇扫描 + +扫描仓库中可能出现的敏感词汇,包括军事、政务、内部系统等受监管或不宜公开的用语。 + +**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原文上下文。** + +## 敏感词汇分类 + +### C-001: 军事相关 + +涉及军事单位、装备、行动等词汇。 + +搜索模式: +``` +军|部队|军区|武装|国防|武器|装备|弹药|导弹|雷达|坦克|舰艇|战机|潜艇|航母|核|火箭|弹药库|靶场|兵工厂|军工厂|军事基地|作战|演习|动员|部署|调防|驻地|番号|编制|勤务 +``` + +```bash +# 搜索军事敏感词汇 +git grep -n -E '军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|弹药库|靶场|兵工厂|军工厂|军事基地|作战指挥|军事演习|战备|动员令|兵力部署|调防|驻地|番号|部队编制|后勤保障|勤务' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-002: 政府/党政机关 + +涉及党政机关、政府内部系统等词汇。 + +搜索模式: +``` +中央|国务院|军委|部委|省委|市委|党政|机要|保密|机密|绝密|秘密|内部文件|红头|批复|批示|内参|办公厅|机要局|保密局|国安|公安内网|政务内网|党政机关|公务 +``` + +```bash +# 搜索党政敏感词汇 +git grep -n -E '中央委员会|国务院|中央军委|部委|省委|市委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|党政内网|红头文件|内部文件|机要文件|绝密|机密文件|内参|批复|批示件|办公厅' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-003: 内部系统标识 + +涉及内部使用的系统名称、代号、项目名等。 + +搜索模式: +``` +内部系统|内部平台|内网|专网|涉密|非密|脱密|密码机|加密机|身份认证|安全审计|堡垒机|防火墙规则|入侵检测|安全监测 +``` + +```bash +# 搜索内部系统标识 +git grep -n -E '内部系统|内部平台|内网地址|专网|涉密|非密|脱密处理|密码机|加密机|堡垒机|防火墙规则|入侵检测系统|安全监测平台' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-004: 监管/合规敏感词 + +涉及金融、医疗、数据隐私等受监管领域。 + +搜索模式: +``` +反洗钱|征信|个人隐私|数据出境|跨境传输|敏感数据|涉密数据|关键信息基础设施|网络安全等级保护|等保|密评 +``` + +```bash +# 搜索监管敏感词 +git grep -n -E '反洗钱|征信系统|个人隐私数据|数据出境|跨境数据传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保三级|密评|商用密码' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-005: 公司/组织敏感信息 + +涉及内部项目代号、未公开产品名、客户信息等。 + +搜索模式: +``` +内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名单|白名单|内部API|私有接口|内部对接 +``` + +```bash +# 搜索组织敏感信息 +git grep -n -E '内部代号|项目代号|内部项目名称|未公开|NDA|保密协议|客户名录|内部API地址|私有接口地址|内部对接人|白名单IP' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-006: 密码学/安全产品名称 + +涉及商用密码产品、安全管控设备等受管制技术。 + +搜索模式: +``` +加密算法|国密|SM2|SM3|SM4|SM9|商密|密码模块|密码卡|密码机|VPN|防火墙|入侵防御|WAF|DLP|终端管控|上网行为|日志审计 +``` + +```bash +# 搜索密码学/安全产品 +git grep -n -E '国密算法|SM2|SM3|SM4|SM9|商用密码|密码模块|密码卡|密码机|防火墙设备|入侵防御系统|WAF|DLP|上网行为管理|日志审计系统|终端管控' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +## 执行完整扫描 + +```bash +echo "=== C-001: 军事词汇 ===" +git grep -n -E '军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null | grep -v 'package-lock' | grep -v '.exe' + +echo "=== C-002: 党政词汇 ===" +git grep -n -E '中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|党政内网|红头文件|内部文件|机要文件|绝密|机密文件' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null + +echo "=== C-003: 内部系统 ===" +git grep -n -E '内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null + +echo "=== C-004: 监管合规 ===" +git grep -n -E '反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null + +echo "=== C-005: 组织敏感 ===" +git grep -n -E '内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null + +echo "=== C-006: 安全产品 ===" +git grep -n -E '国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +## 排除列表 + +以下匹配不视为敏感: + +| 匹配内容 | 原因 | +|----------|------| +| `--secret` CLI 参数定义 | 框架定义的参数名,非内容 | +| `secret_key`/`access_key` field tag | 结构体字段名,非值 | +| `军队文职/部队文职` 招聘信息 | 公开招录信息 | +| `国防科技大学` 等公开院校名 | 公开教育机构 | +| `国家网络安全法` 等法律引用 | 公开法律法规 | +| `go-keyring` 等依赖名中的 `key` | 第三方包名 | +| standard library `crypto/*` imports | Go 标准库导入 | +| `防火墙` 在 IT 产品描述中的正常使用 | 网络安全产品公开描述 | + +## 严重度分级 + +| 类别 | 严重度 | 说明 | +|------|--------|------| +| C-001 军事 | **高** | 军事相关内容可能触发合规审查 | +| C-002 党政 | **高** | 涉及政府内部系统标识 | +| C-003 内部系统 | 中 | 内部系统信息泄露 | +| C-004 监管 | 中 | 涉及受监管数据 | +| C-005 组织 | 低 | 组织内部信息 | +| C-006 安全产品 | 中 | 安全产品部署细节 | diff --git a/skills/gitlink-onboard/SKILL.md b/skills/gitlink-onboard/SKILL.md new file mode 100644 index 0000000..f7838c8 --- /dev/null +++ b/skills/gitlink-onboard/SKILL.md @@ -0,0 +1,157 @@ +--- +name: gitlink-onboard +version: 2.0.0 +description: "新人引导:通过 AI 语义分析识别适合新手的 Good First Issue,调用 CLI 添加引导评论。当用户需要识别新手友好 issue 并欢迎新贡献者时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli onboard --help" +--- + +# gitlink-onboard(新人引导) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 写入/删除操作前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) + +## 运行模式 + +| 模式 | 说明 | 需要认证 | +|------|------|----------| +| AI 分析模式(推荐) | AI 读取所有 open issue,通过语义分析识别 Good First Issue,展示结果后由用户选择并调用 CLI | 是 | +| 直接模式 | 用户已明确指定 issue 编号,直接调用 `onboard +welcome --issues` | 是 | +| 标签模式(旧) | 通过 `--tag` 参数按标签过滤 | 是 | + +## AI 分析模式工作流(5 步) + +当用户说 "帮我识别新人友好 issue" / "good first issue" / "欢迎新人" 等触发词时, +执行以下 5 步流程: + +### 第 1 步:获取所有 Open Issue + +```bash +gitlink-cli issue +list --state open --limit 100 --format json +``` + +解析 JSON 输出,提取每个 issue 的 `project_issues_index`(编号)和 `subject`(标题)。 + +### 第 2 步:逐一获取详情 + +对每个 issue 调用: + +```bash +gitlink-cli issue +view --number N --format json +``` + +提取:标题、描述(body/description)、标签列表。 + +如果 issue 数量较多(>20),分批处理,每批 10-15 个,先分析标题再决定是否需要完整详情。 + +### 第 3 步:AI 语义分析 + +基于 LLM 对 issue 内容的理解,判断是否适合新手。**不使用硬编码规则**,而是基于语义理解。 + +**正面信号(适合新手):** +- 标题清晰,范围明确 +- 简单修复类任务(拼写错误、文档补充、配置调整) +- 分离了多个子任务的复杂大 issue 的子任务 +- 涉及单个文件或少量文件的修改 +- 维护者明确标注了实现方向 + +**负面信号(不适合新手):** +- 涉及架构重构或核心模块改动 +- 需要数据库迁移或复杂 SQL 变更 +- 安全相关修复 +- 需要同时修改多个模块 +- 缺乏上下文说明,需求模糊 +- 已有大量评论讨论但无共识 +- 需要深入了解项目内部逻辑 + +分析每个 issue 后归类为: + +| 分类 | 说明 | +|------|------| +| `good-first-issue` | 明确适合新手,范围小、有清晰实现路径 | +| `maybe` | 部分条件符合,但有不确定因素 | +| `not-recommended` | 不适合新手 | + +### 第 4 步:展示结果并确认 + +以表格形式呈现分析结果: + +| # | 标题 | 判断 | 理由 | +|---|------|------|------| +| 3 | Fix typo in README | good-first-issue | 文档类、单文件、无依赖 | +| 7 | Add input validation | good-first-issue | 边界清晰、常见模式 | +| 12 | Refactor auth module | not-recommended | 核心安全模块、影响面广 | +| 15 | Update API docs | good-first-issue | 文档补充、无风险 | + +然后询问用户选择: + +> 共识别 N 个候选 issue。请选择操作: +> A. 为所有 `good-first-issue` 添加引导评论 +> B. 手动指定(输入 issue 编号,逗号分隔) +> C. 取消 + +### 第 5 步:调用 CLI 添加评论 + +根据用户选择,生成并执行命令: + +```bash +# 选项 A +gitlink-cli onboard +welcome --issues "3,7,15" + +# 选项 B(用户输入 "7,15") +gitlink-cli onboard +welcome --issues "7,15" +``` + +建议首次使用 `--dry-run` 预览: + +```bash +gitlink-cli onboard +welcome --issues "3,7,15" --dry-run +``` + +## 直接模式:手动指定 Issue + +用户已知 issue 编号时直接调用: + +```bash +# 为指定 issue 添加引导评论 +gitlink-cli onboard +welcome --issues "5,8,12" + +# 预览模式 +gitlink-cli onboard +welcome --issues "5,8,12" --dry-run + +# 自定义欢迎消息 +gitlink-cli onboard +welcome --issues "5" --template "欢迎新人!请先阅读 README。" +``` + +## CLI 工作原理 + +`onboard +welcome` 命令的两种路径: + +1. **`--issues` 路径(优先)**:直接获取指定编号的 issue,跳过标签解析 +2. **`--tag` 路径(向后兼容)**:按标签名过滤 issue + +两个路径共享: +- 每个 issue 检查是否已有引导评论(通过 `` 标记) +- `--dry-run` 逐条确认 +- 重复运行不重复添加评论 +- 自定义 `--template`(`{login}` 替换为仓库所有者) + +## 注意事项 + +- `--issues` 和 `--tag` 同时指定时,`--issues` 优先生效 +- 需要仓库管理员或 write 权限 +- 建议 AI 分析前先了解项目领域和技术栈,提高判断准确度 +- AI 分析基于 LLM 语义理解,不是硬编码规则——做判断时给出明确的正面/负面理由 + +## 相关命令 + +| 命令 | 说明 | +|------|------| +| `gitlink-cli issue +list --state open --format json` | 获取所有 open issue | +| `gitlink-cli issue +view --number N --format json` | 查看 issue 详情 | +| `gitlink-cli onboard +welcome --issues "N"` | 为指定 issue 添加引导评论 |