forked from Gitlink/gitlink-cli
Merge branch 'master' of https://www.gitlink.org.cn/zzx-coder/gitlink-cli
This commit is contained in:
commit
d804a01e8a
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>贡献者报告 - {{.Owner}}/{{.Repo}}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.header p {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.card h2 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 3px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
}
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th, td {
|
||||
padding: 15px 20px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
th {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
tr:hover {
|
||||
background: #f8f9ff;
|
||||
}
|
||||
.rank {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.rank-1 { color: #FFD700; }
|
||||
.rank-2 { color: #C0C0C0; }
|
||||
.rank-3 { color: #CD7F32; }
|
||||
.score-bar {
|
||||
background: #e9ecef;
|
||||
border-radius: 10px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.score-fill {
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.score-text {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.user-login {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.weight-info {
|
||||
background: #f8f9ff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.weight-info h3 {
|
||||
color: #667eea;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.weight-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.weight-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
.weight-label {
|
||||
color: #666;
|
||||
}
|
||||
.weight-value {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>贡献者报告</h1>
|
||||
<p>{{.Owner}}/{{.Repo}} - 团队成员贡献分析</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{len .Contributors}}</div>
|
||||
<div class="stat-label">贡献者总数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-commits">0</div>
|
||||
<div class="stat-label">总提交数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-issues">0</div>
|
||||
<div class="stat-label">总 Issue 数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="total-prs">0</div>
|
||||
<div class="stat-label">总 PR 数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>贡献占比分布</h2>
|
||||
<div id="pieChart" class="chart-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>详细排名</h2>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>成员</th>
|
||||
<th>Commits</th>
|
||||
<th>代码行数</th>
|
||||
<th>Issues</th>
|
||||
<th>PRs</th>
|
||||
<th>贡献分数</th>
|
||||
<th>占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .TableRows}}
|
||||
<tr>
|
||||
<td class="rank rank-{{.Rank}}">{{.Rank}}</td>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{slice .Login 0 1}}</div>
|
||||
<div>
|
||||
<div class="user-name">{{.Name}}</div>
|
||||
<div class="user-login">@{{.Login}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{.Commits}}</td>
|
||||
<td>{{.CodeLines}}</td>
|
||||
<td>{{.Issues}}</td>
|
||||
<td>{{.PRs}}</td>
|
||||
<td class="score-text">{{printf "%.4f" .Score}}</td>
|
||||
<td>
|
||||
<div class="score-bar">
|
||||
<div class="score-fill" style="width: {{printf "%.1f" .ScorePct}}%"></div>
|
||||
</div>
|
||||
<small>{{printf "%.1f" .ScorePct}}%</small>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>AHP 权重说明</h2>
|
||||
<div class="weight-info">
|
||||
<h3>层次分析法 (AHP) 权重分配</h3>
|
||||
<div class="weight-grid">
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Commits</span>
|
||||
<span class="weight-value">14.3%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">代码行数</span>
|
||||
<span class="weight-value">28.6%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">PR 合并数</span>
|
||||
<span class="weight-value">7.1%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Issue 创建</span>
|
||||
<span class="weight-value">5.0%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Issue 解决</span>
|
||||
<span class="weight-value">10.0%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Release</span>
|
||||
<span class="weight-value">10.0%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Issue 评论</span>
|
||||
<span class="weight-value">8.3%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">PR 评审</span>
|
||||
<span class="weight-value">8.3%</span>
|
||||
</div>
|
||||
<div class="weight-item">
|
||||
<span class="weight-label">Wiki</span>
|
||||
<span class="weight-value">8.3%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 计算总数
|
||||
let totalCommits = 0;
|
||||
let totalIssues = 0;
|
||||
let totalPRs = 0;
|
||||
{{range .Contributors}}
|
||||
totalCommits += {{.Commits}};
|
||||
totalIssues += {{.Issues}};
|
||||
totalPRs += {{.PRs}};
|
||||
{{end}}
|
||||
document.getElementById('total-commits').textContent = totalCommits;
|
||||
document.getElementById('total-issues').textContent = totalIssues;
|
||||
document.getElementById('total-prs').textContent = totalPRs;
|
||||
|
||||
// 初始化饼图
|
||||
var chart = echarts.init(document.getElementById('pieChart'));
|
||||
var option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'middle',
|
||||
textStyle: {
|
||||
fontSize: 14
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
name: '贡献占比',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['60%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}\n{d}%',
|
||||
fontSize: 12
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
data: [
|
||||
{{range .Contributors}}
|
||||
{
|
||||
value: {{printf "%.4f" .Score}},
|
||||
name: '{{.Login}}'
|
||||
},
|
||||
{{end}}
|
||||
]
|
||||
}]
|
||||
};
|
||||
chart.setOption(option);
|
||||
|
||||
// 响应式
|
||||
window.addEventListener('resize', function() {
|
||||
chart.resize();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
|
@ -0,0 +1,393 @@
|
|||
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 = "<!-- gitlink-cli:onboard -->"
|
||||
|
||||
// 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 ({login}, {number}, {subject}, {description})"},
|
||||
{Name: "force", Short: "f", Usage: "Force re-add even if already commented", Bool: true},
|
||||
},
|
||||
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 {
|
||||
info, err := fetchIssueDetail(ctx, num)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 issue #%d 失败: %w", num, err)
|
||||
}
|
||||
issues = append(issues, info)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// fetchIssueDetail fetches a single issue's subject and description by number.
|
||||
func fetchIssueDetail(ctx *common.RuntimeContext, issueNumber int) (issueInfo, 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 issueInfo{}, err
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
subj := getString(data, "subject")
|
||||
if subj == "" {
|
||||
subj = fmt.Sprintf("issue #%d", issueNumber)
|
||||
}
|
||||
desc := getString(data, "description")
|
||||
return issueInfo{number: issueNumber, subject: subj, description: desc}, 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 = ""
|
||||
}
|
||||
|
||||
type result struct {
|
||||
num int
|
||||
action string
|
||||
msg string
|
||||
}
|
||||
var results []result
|
||||
|
||||
for _, issue := range issues {
|
||||
force := ctx.Arg("force") == "true"
|
||||
if !force && hasWelcomeComment(ctx, issue.number) {
|
||||
results = append(results, result{issue.number, "skipped", fmt.Sprintf("#%d \"%s\" — 已有引导评论,跳过", issue.number, issue.subject)})
|
||||
continue
|
||||
}
|
||||
|
||||
// Render per-issue message with issue-specific variables.
|
||||
body := renderComment(ctx, issue, tmpl)
|
||||
|
||||
if ctx.IsDryRun() {
|
||||
fmt.Printf("\n--- 预览 #%d \"%s\" ---\n%s\n---\n", issue.number, issue.subject, body)
|
||||
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, body); 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
|
||||
description 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// renderComment renders the comment body for a specific issue.
|
||||
// It uses the --template if provided, otherwise generates an issue-aware default.
|
||||
func renderComment(ctx *common.RuntimeContext, issue issueInfo, customTmpl string) string {
|
||||
tmpl := customTmpl
|
||||
if tmpl == "" {
|
||||
tmpl = defaultTemplate(ctx.Owner, ctx.Repo, issue)
|
||||
}
|
||||
body := strings.NewReplacer(
|
||||
"{login}", ctx.Owner,
|
||||
"{number}", strconv.Itoa(issue.number),
|
||||
"{subject}", issue.subject,
|
||||
"{description}", issue.description,
|
||||
).Replace(tmpl)
|
||||
return body
|
||||
}
|
||||
|
||||
// defaultTemplate returns an issue-aware onboarding message.
|
||||
func defaultTemplate(owner, repo string, issue issueInfo) string {
|
||||
summary := issue.subject
|
||||
if len(issue.description) > 200 {
|
||||
summary = issue.description[:200] + "..."
|
||||
} else if issue.description != "" {
|
||||
summary = issue.description
|
||||
}
|
||||
return fmt.Sprintf(`## 欢迎贡献!:wave:
|
||||
|
||||
感谢你对 [%s/%s](https://www.gitlink.org.cn/%s/%s) 的关注。
|
||||
|
||||
### :bulb: 关于本 Issue:{subject}
|
||||
|
||||
%s
|
||||
|
||||
### :rocket: 参与步骤
|
||||
1. **Fork 仓库** 并克隆到本地
|
||||
2. 创建新分支:` + "`git checkout -b fix/issue-{number}`" + `
|
||||
3. 参照上方 issue 描述修改代码
|
||||
4. 推送到你的 Fork 后创建 Pull Request
|
||||
|
||||
### :memo: 注意事项
|
||||
- 请先阅读 [CONTRIBUTING.md](https://www.gitlink.org.cn/%s/%s/src/master/CONTRIBUTING.md)(如有)
|
||||
- 如有疑问,欢迎在评论区留言讨论
|
||||
|
||||
期待你的 PR!`, owner, repo, owner, repo, summary, 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. **行级去重** — 同一行匹配多条规则时,只报告严重度最高的那一条,避免重复报告
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 只读操作,不修改任何文件
|
||||
- 敏感信息只报告位置,不展示内容
|
||||
- 所有扫描在本地完成,不跨仓库
|
||||
- 部分匹配可能是误报(示例代码、测试数据),需人工判断
|
||||
|
|
@ -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 兼容,无传染风险。
|
||||
```
|
||||
|
|
@ -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) | ✗ |
|
||||
|
|
@ -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 变量引用 |
|
||||
|
|
@ -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` |
|
||||
|
|
@ -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 安全产品 | 中 | 安全产品部署细节 |
|
||||
|
|
@ -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 检查是否已有引导评论(通过 `<!-- gitlink-cli:onboard -->` 标记)
|
||||
- `--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 添加引导评论 |
|
||||
Loading…
Reference in New Issue