diff --git a/.mise.toml b/.mise.toml index 969da7b8..52d7cdac 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,5 +1,5 @@ [tools] node = "25" pnpm = "11.0.9" -go = "1.25.7" +go = "1.25.10" # Rust is managed by rustup via rust-toolchain.toml diff --git a/apps/desktop/rust-toolchain.toml b/apps/desktop/rust-toolchain.toml index 292fe499..1347ad47 100644 --- a/apps/desktop/rust-toolchain.toml +++ b/apps/desktop/rust-toolchain.toml @@ -1,2 +1,5 @@ [toolchain] -channel = "stable" +# Pinned to a specific stable version, not the floating "stable" channel. +# A compromised rustc release would land transparently otherwise. Bump +# deliberately, with a few days between Rust's release and our pin update. +channel = "1.95.0" diff --git a/scripts/check/CLAUDE.md b/scripts/check/CLAUDE.md index af3bff51..1232b928 100644 --- a/scripts/check/CLAUDE.md +++ b/scripts/check/CLAUDE.md @@ -147,7 +147,9 @@ The chosen `RUST_LOG` value is echoed at the top of the timestamped log so it's captured. When unset, the log starts with `=== RUST_LOG unset (default warn level) ===`. **Go tool auto-install:** `EnsureGoTool(name, installPath)` checks PATH first, then runs `go install` and returns the -full binary path. Used for staticcheck, nilaway, etc. +full binary path. Used for staticcheck, nilaway, etc. The `installPath` MUST pin a specific version (`@vX.Y.Z` or a +pseudo-version), never `@latest`: an `@latest` install is the Go-side equivalent of the wave-1-2 npm-registry-trojan +vector. Same rule applies to `cargo install` calls inside checks: pin both `--version` and `--locked`. **TTY detection:** `golang.org/x/term.IsTerminal` gates the live status line; CI logs stay clean. @@ -223,8 +225,9 @@ before tests. | Website | Astro | prettier, eslint, typecheck, build, html-validate, e2e | | Website | Docker | docker-build | | API server | TS | oxfmt, eslint, typecheck, tests | -| Scripts | Go | gofmt, go-vet, staticcheck, ineffassign, misspell, gocyclo, nilaway, deadcode, go-tests | +| Scripts | Go | gofmt, go-vet, staticcheck, ineffassign, misspell, gocyclo, nilaway, deadcode, go-tests, govulncheck | | Other | Metrics | file-length (warn-only), CLAUDE.md-reminder (warn-only), changelog-commit-links | +| Other | Security | workflows-hardening (SHA-pinning, no `pull_request_target`, job-scoped `id-token: write`) | ## Output format @@ -260,6 +263,33 @@ dependencies (gtk3-rs, unic-\*, fxhash, proc-macro-error, etc.) trigger unmainta `cargo-audit` still catches critical security vulnerabilities. License, bans, and sources checks in `cargo-deny` remain active. See comment in `src-tauri/deny.toml`. +**Decision**: every operational `cargo` command in checks passes `--locked`. **Why**: without it, cargo silently +re-resolves `Cargo.lock` whenever upstream metadata shifts (a yank, a new transitive dep version). For a 1028-crate +lockfile, that resolution window is wide and lets a freshly-published malicious version land mid-build. `--locked` +fails loudly if the lockfile would change. Applies to `cargo clippy`, `cargo nextest run` (in both `desktop-rust-tests` +and `desktop-rust-integration-tests`), and `cargo +nightly udeps`. Audit/deny/machete read `Cargo.lock` without +updating it, so `--locked` is moot for them, but the install of those tools still uses `--locked` to lock the tool's +own dep tree. + +**Decision**: every tool install pins `--version` and `--locked` (cargo) or `@vX.Y.Z` (Go). **Why**: an unpinned tool +install (`cargo install cargo-audit` or `EnsureGoTool(..., "@latest")`) means each fresh checkout pulls whatever's +latest. A wave-1-2-class compromise of any of these tool repositories would auto-propagate. Pinning is the Go-side +equivalent of the pnpm `minimum-release-age` defense (a fresh version can't land without a deliberate bump). + +**Decision**: `workflows-hardening` check enforces three GitHub Actions invariants and acts as a regression guard. +**Why**: cmdr's workflows are already correctly hardened (every third-party action is SHA-pinned with a comment, no +`pull_request_target` triggers, no workflow-scoped `id-token: write`). Without an automated guard, a future PR or a +Renovate misconfiguration could silently regress any of those without anyone noticing in review. The check fails on +tag/branch-pinned third-party actions, on `pull_request_target` triggers (wave-4's entry vector), and on +workflow-scoped `id-token: write` (must be job-scoped per the wave-4 OIDC-token-extraction lesson). Local actions +(`./...`) are exempt. + +**Decision**: `govulncheck` runs against every Go module. **Why**: cargo-audit covers Rust deps; nothing covered Go +until now. `govulncheck` is static-analysis-based, so it only flags vulns actually reachable from the code (low false +positive rate). Most of cmdr's Go modules are dep-free tooling scripts but still call into the Go stdlib, which gets +its own CVEs; the check found 7 real reachable stdlib vulns the first time it ran (fixed by bumping mise's Go pin). +Mirrors the cargo-audit role on the Rust side. + **Decision**: `cfg-gate` check to catch ungated macOS-only crate imports. **Why**: Rust code using macOS-only crates (from `[target.'cfg(target_os = "macos")'.dependencies]`) compiles fine on macOS but fails on Linux if the `use` isn't wrapped in `#[cfg(target_os = "macos")]`. CI catches this after push, but the check catches it locally and instantly. It diff --git a/scripts/check/checks/desktop-rust-cargo-audit.go b/scripts/check/checks/desktop-rust-cargo-audit.go index 4f103d0e..9d4f8b34 100644 --- a/scripts/check/checks/desktop-rust-cargo-audit.go +++ b/scripts/check/checks/desktop-rust-cargo-audit.go @@ -143,7 +143,7 @@ func parseAuditJSON(output string) (cargoAuditReport, error) { // RunCargoAudit checks for security vulnerabilities. func RunCargoAudit(ctx *CheckContext) (CheckResult, error) { if !CommandExists("cargo-audit") { - installCmd := exec.Command("cargo", "install", "cargo-audit") + installCmd := exec.Command("cargo", "install", "cargo-audit", "--version", "0.22.1", "--locked") if _, err := RunCommand(installCmd, true); err != nil { return CheckResult{}, fmt.Errorf("failed to install cargo-audit: %w", err) } diff --git a/scripts/check/checks/desktop-rust-cargo-deny.go b/scripts/check/checks/desktop-rust-cargo-deny.go index a81237d1..db9e8de9 100644 --- a/scripts/check/checks/desktop-rust-cargo-deny.go +++ b/scripts/check/checks/desktop-rust-cargo-deny.go @@ -19,7 +19,7 @@ func RunCargoDeny(ctx *CheckContext) (CheckResult, error) { // Check if cargo-deny is installed if !CommandExists("cargo-deny") { - installCmd := exec.Command("cargo", "install", "cargo-deny") + installCmd := exec.Command("cargo", "install", "cargo-deny", "--version", "0.19.6", "--locked") if _, err := RunCommand(installCmd, true); err != nil { return CheckResult{}, fmt.Errorf("failed to install cargo-deny: %w", err) } diff --git a/scripts/check/checks/desktop-rust-cargo-machete.go b/scripts/check/checks/desktop-rust-cargo-machete.go index 39d1e7d2..be26b3c0 100644 --- a/scripts/check/checks/desktop-rust-cargo-machete.go +++ b/scripts/check/checks/desktop-rust-cargo-machete.go @@ -19,7 +19,7 @@ func RunCargoMachete(ctx *CheckContext) (CheckResult, error) { rustDir := filepath.Join(ctx.RootDir, "apps", "desktop", "src-tauri") if !CommandExists("cargo-machete") { - installCmd := exec.Command("cargo", "install", "cargo-machete", "--locked") + installCmd := exec.Command("cargo", "install", "cargo-machete", "--version", "0.9.2", "--locked") if output, err := RunCommand(installCmd, true); err != nil { return CheckResult{}, fmt.Errorf("failed to install cargo-machete\n%s", indentOutput(output)) } diff --git a/scripts/check/checks/desktop-rust-cargo-udeps.go b/scripts/check/checks/desktop-rust-cargo-udeps.go index f376868a..09f0a75f 100644 --- a/scripts/check/checks/desktop-rust-cargo-udeps.go +++ b/scripts/check/checks/desktop-rust-cargo-udeps.go @@ -21,14 +21,14 @@ func RunCargoUdeps(ctx *CheckContext) (CheckResult, error) { // Check if cargo-udeps is installed if !CommandExists("cargo-udeps") { - installCmd := exec.Command("cargo", "install", "cargo-udeps", "--locked") + installCmd := exec.Command("cargo", "install", "cargo-udeps", "--version", "0.1.61", "--locked") if _, err := RunCommand(installCmd, true); err != nil { return CheckResult{}, fmt.Errorf("failed to install cargo-udeps: %w", err) } } // cargo-udeps requires nightly - cmd := exec.Command("cargo", "+nightly", "udeps", "--all-targets") + cmd := exec.Command("cargo", "+nightly", "udeps", "--locked", "--all-targets") cmd.Dir = rustDir output, err := RunCommand(cmd, true) if err != nil { diff --git a/scripts/check/checks/desktop-rust-clippy.go b/scripts/check/checks/desktop-rust-clippy.go index 768d06e8..11193ee1 100644 --- a/scripts/check/checks/desktop-rust-clippy.go +++ b/scripts/check/checks/desktop-rust-clippy.go @@ -28,7 +28,7 @@ func RunClippy(ctx *CheckContext) (CheckResult, error) { // the only build pass we do. --fix is reserved for the failure branch // because it ignores -D warnings, can rewrite source files, and re-running // it speculatively doubled wall time on every clean run. - cmd := exec.Command("cargo", "clippy", "--all-targets", "--", "-D", "warnings") + cmd := exec.Command("cargo", "clippy", "--locked", "--all-targets", "--", "-D", "warnings") cmd.Dir = rustDir output, err := RunCommand(cmd, true) if err != nil { @@ -37,7 +37,7 @@ func RunClippy(ctx *CheckContext) (CheckResult, error) { } // Locally: try to auto-fix, then re-check. - fixCmd := exec.Command("cargo", "clippy", "--all-targets", "--fix", "--allow-dirty", "--allow-staged") + fixCmd := exec.Command("cargo", "clippy", "--locked", "--all-targets", "--fix", "--allow-dirty", "--allow-staged") fixCmd.Dir = rustDir _, _ = RunCommand(fixCmd, true) diff --git a/scripts/check/checks/desktop-rust-integration-tests.go b/scripts/check/checks/desktop-rust-integration-tests.go index 26cba238..7d8a5aee 100644 --- a/scripts/check/checks/desktop-rust-integration-tests.go +++ b/scripts/check/checks/desktop-rust-integration-tests.go @@ -60,7 +60,7 @@ func RunRustIntegrationTests(ctx *CheckContext) (CheckResult, error) { // Make sure cargo-nextest is available (mirrors desktop-rust-tests.go). if !CommandExists("cargo-nextest") { - installCmd := exec.Command("cargo", "install", "cargo-nextest", "--locked") + installCmd := exec.Command("cargo", "install", "cargo-nextest", "--version", "0.9.136", "--locked") if _, err := RunCommand(installCmd, true); err != nil { return CheckResult{}, fmt.Errorf("failed to install cargo-nextest: %w", err) } @@ -72,6 +72,7 @@ func RunRustIntegrationTests(ctx *CheckContext) (CheckResult, error) { // tests are still skipped. cmd := exec.Command( "cargo", "nextest", "run", + "--locked", "--release", "--run-ignored", "only", "-E", "test(smb_integration_)", diff --git a/scripts/check/checks/desktop-rust-tests.go b/scripts/check/checks/desktop-rust-tests.go index 6b311437..d3428763 100644 --- a/scripts/check/checks/desktop-rust-tests.go +++ b/scripts/check/checks/desktop-rust-tests.go @@ -14,13 +14,13 @@ func RunRustTests(ctx *CheckContext) (CheckResult, error) { // Check if cargo-nextest is installed if !CommandExists("cargo-nextest") { - installCmd := exec.Command("cargo", "install", "cargo-nextest", "--locked") + installCmd := exec.Command("cargo", "install", "cargo-nextest", "--version", "0.9.136", "--locked") if _, err := RunCommand(installCmd, true); err != nil { return CheckResult{}, fmt.Errorf("failed to install cargo-nextest: %w", err) } } - cmd := exec.Command("cargo", "nextest", "run") + cmd := exec.Command("cargo", "nextest", "run", "--locked") cmd.Dir = rustDir output, err := RunCommand(cmd, true) if err != nil { diff --git a/scripts/check/checks/desktop-workflows-hardening.go b/scripts/check/checks/desktop-workflows-hardening.go new file mode 100644 index 00000000..31099214 --- /dev/null +++ b/scripts/check/checks/desktop-workflows-hardening.go @@ -0,0 +1,192 @@ +package checks + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// RunWorkflowsHardening enforces three GitHub Actions invariants that protect +// against the wave-4 (TanStack, May 2026) class of supply-chain attack: +// +// 1. Every third-party action `uses:` reference must be SHA-pinned (40 hex +// chars), not tag- or branch-pinned. Tag refs are mutable; a malicious +// re-tag would silently land. Local `./...` actions and reusable +// workflows that resolve to the same repo are exempt. +// +// 2. No workflow may use the `pull_request_target` trigger. It runs in the +// base repo's security context with access to the cache scope and +// GITHUB_TOKEN — the exact entry vector that let attacker code poison +// TanStack's pnpm store and steal the OIDC token. +// +// 3. `id-token: write` must be job-scoped, never workflow-scoped. A +// workflow-level grant means every job in the workflow can mint OIDC +// tokens; if any one of them runs attacker-controlled code, the token +// can be exfiltrated. Job-scoping isolates the privilege to the single +// publishing step. +// +// All three classes are silent in normal review: tag pins look identical to +// SHA pins, `pull_request_target` looks like a typo of `pull_request`, and +// permissions blocks are usually skimmed. The check makes them loud. +func RunWorkflowsHardening(ctx *CheckContext) (CheckResult, error) { + wfDir := filepath.Join(ctx.RootDir, ".github", "workflows") + entries, err := os.ReadDir(wfDir) + if err != nil { + if os.IsNotExist(err) { + return Skipped("no .github/workflows/"), nil + } + return CheckResult{}, fmt.Errorf("failed to read workflows dir: %w", err) + } + + var files []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") { + files = append(files, filepath.Join(wfDir, name)) + } + } + sort.Strings(files) + + var violations []string + scanned := 0 + for _, f := range files { + v, err := scanWorkflowFile(f, ctx.RootDir) + if err != nil { + return CheckResult{}, err + } + violations = append(violations, v...) + scanned++ + } + + if len(violations) > 0 { + return CheckResult{}, fmt.Errorf("workflow hardening violations\n%s", + indentOutput(strings.Join(violations, "\n"))) + } + + result := Success(fmt.Sprintf("%d %s, all hardened", scanned, Pluralize(scanned, "workflow", "workflows"))) + result.Total = scanned + return result, nil +} + +var ( + // uses: /@ (optionally followed by space + comment) + // Captures: 1=owner/repo[/subpath], 2=ref + usesRefRE = regexp.MustCompile(`^(\s*)(?:-\s+)?uses:\s+([^@\s]+)@([^\s#]+)`) + + // 40-char hex SHA — the only acceptable ref form for third-party actions. + sha40RE = regexp.MustCompile(`^[a-f0-9]{40}$`) + + // `id-token: write` line. We care about its indentation level relative + // to the containing job to tell workflow-scope from job-scope. + idTokenWriteRE = regexp.MustCompile(`^(\s*)id-token:\s*write\s*$`) + + // Top-level `on:` block introducer. + onIntroRE = regexp.MustCompile(`^on:\s*$`) + // Inline `on:` form like `on: [push, pull_request_target]` or `on: pull_request_target`. + onInlineRE = regexp.MustCompile(`^on:\s+(.+)$`) + // A trigger key under `on:`, like ` pull_request_target:` (any indent). + triggerKeyRE = regexp.MustCompile(`^(\s+)([a-z_]+):`) +) + +func scanWorkflowFile(path, repoRoot string) ([]string, error) { + rel, _ := filepath.Rel(repoRoot, path) + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", rel, err) + } + defer f.Close() + + var ( + violations []string + inOnBlock bool + onIndent int = -1 + lineNum int + ) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lineNum++ + line := scanner.Text() + trimmed := strings.TrimRight(line, " \t") + if trimmed == "" || strings.HasPrefix(strings.TrimSpace(trimmed), "#") { + continue + } + + // (1) tag-pinned actions + if m := usesRefRE.FindStringSubmatch(trimmed); m != nil { + repo, ref := m[2], m[3] + if !isExemptUsesRef(repo) && !sha40RE.MatchString(ref) { + violations = append(violations, + fmt.Sprintf("%s:%d: tag/branch-pinned action: %s@%s (SHA-pin: '@<40-hex> # %s')", + rel, lineNum, repo, ref, ref)) + } + } + + // (2) pull_request_target trigger + if onIntroRE.MatchString(trimmed) { + inOnBlock = true + onIndent = -1 + continue + } + if m := onInlineRE.FindStringSubmatch(trimmed); m != nil { + if strings.Contains(m[1], "pull_request_target") { + violations = append(violations, + fmt.Sprintf("%s:%d: pull_request_target trigger (wave-4 entry vector)", rel, lineNum)) + } + continue + } + if inOnBlock { + if m := triggerKeyRE.FindStringSubmatch(line); m != nil { + indent := len(m[1]) + if onIndent == -1 { + onIndent = indent + } + if indent == onIndent { + if m[2] == "pull_request_target" { + violations = append(violations, + fmt.Sprintf("%s:%d: pull_request_target trigger (wave-4 entry vector)", rel, lineNum)) + } + continue + } + } + // Any line at indent <= 0 (i.e. a new top-level key) ends the on: block. + if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && trimmed != "" { + inOnBlock = false + onIndent = -1 + } + } + + // (3) workflow-scoped id-token: write + // Workflow-level `permissions:` is at column 0 (top-level key), so + // its children sit at 2-space indent. Job-level `permissions:` lives + // under `jobs..`, so its children sit at ≥6-space indent. We + // flag anything with indent ≤ 4 as workflow-scoped. + if m := idTokenWriteRE.FindStringSubmatch(line); m != nil { + indent := len(m[1]) + if indent <= 4 { + violations = append(violations, + fmt.Sprintf("%s:%d: workflow-scoped 'id-token: write' (must be job-scoped)", rel, lineNum)) + } + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read %s: %w", rel, err) + } + return violations, nil +} + +// isExemptUsesRef returns true for `uses:` targets that don't need SHA pinning: +// local action paths (./...) and own-repo references (where the SHA pin would +// be pointless since the action and the workflow are versioned together). +func isExemptUsesRef(repo string) bool { + if strings.HasPrefix(repo, "./") || strings.HasPrefix(repo, "../") { + return true + } + return false +} diff --git a/scripts/check/checks/desktop-workflows-hardening_test.go b/scripts/check/checks/desktop-workflows-hardening_test.go new file mode 100644 index 00000000..9dc10c3c --- /dev/null +++ b/scripts/check/checks/desktop-workflows-hardening_test.go @@ -0,0 +1,199 @@ +package checks + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeWorkflow(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestScanWorkflowFile(t *testing.T) { + tmp := t.TempDir() + wfDir := filepath.Join(tmp, ".github", "workflows") + + cases := []struct { + name string + content string + want []string // substrings expected in each violation + }{ + { + name: "tag-pinned action flagged", + content: `name: x +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 +`, + want: []string{"tag/branch-pinned action: actions/checkout@v4"}, + }, + { + name: "SHA-pinned action accepted", + content: `name: x +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 +`, + want: nil, + }, + { + name: "branch ref flagged (same severity as tag)", + content: `name: x +jobs: + build: + steps: + - uses: dtolnay/rust-toolchain@master +`, + want: []string{"dtolnay/rust-toolchain@master"}, + }, + { + name: "local action exempt", + content: `name: x +jobs: + build: + steps: + - uses: ./.github/actions/my-action +`, + want: nil, + }, + { + name: "pull_request_target block trigger flagged", + content: `name: x +on: + pull_request_target: + paths: ['**'] +jobs: {} +`, + want: []string{"pull_request_target trigger"}, + }, + { + name: "pull_request_target inline trigger flagged", + content: `name: x +on: [push, pull_request_target] +jobs: {} +`, + want: []string{"pull_request_target trigger"}, + }, + { + name: "pull_request (without _target) accepted", + content: `name: x +on: + pull_request: + paths: ['**'] +jobs: {} +`, + want: nil, + }, + { + name: "workflow-scoped id-token: write flagged", + content: `name: x +permissions: + id-token: write + contents: read +jobs: {} +`, + want: []string{"workflow-scoped 'id-token: write'"}, + }, + { + name: "job-scoped id-token: write accepted", + content: `name: x +jobs: + publish: + permissions: + id-token: write + contents: read + runs-on: ubuntu-latest + steps: + - run: echo hi +`, + want: nil, + }, + { + name: "comments and blank lines ignored", + content: `# header +name: x + +# inline note +on: [push] + +jobs: + build: + steps: + # tag pin would be flagged below, this comment is not + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 +`, + want: nil, + }, + { + name: "multiple violations in one file", + content: `name: x +on: + pull_request_target: +permissions: + id-token: write +jobs: + build: + steps: + - uses: actions/cache@v5 +`, + want: []string{ + "pull_request_target trigger", + "workflow-scoped 'id-token: write'", + "actions/cache@v5", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(wfDir, "test.yml") + writeWorkflow(t, wfDir, "test.yml", tc.content) + defer os.Remove(path) + + got, err := scanWorkflowFile(path, tmp) + if err != nil { + t.Fatalf("scanWorkflowFile error: %v", err) + } + if len(got) != len(tc.want) { + t.Errorf("got %d violations, want %d:\n got: %v\n want: %v", len(got), len(tc.want), got, tc.want) + return + } + for i, w := range tc.want { + if !strings.Contains(got[i], w) { + t.Errorf("violation %d: got %q, want substring %q", i, got[i], w) + } + } + }) + } +} + +func TestIsExemptUsesRef(t *testing.T) { + cases := []struct { + ref string + want bool + }{ + {"./.github/actions/foo", true}, + {"../shared/bar", true}, + {"actions/checkout", false}, + {"github/codeql-action/init", false}, + } + for _, tc := range cases { + t.Run(tc.ref, func(t *testing.T) { + if got := isExemptUsesRef(tc.ref); got != tc.want { + t.Errorf("isExemptUsesRef(%q) = %v, want %v", tc.ref, got, tc.want) + } + }) + } +} diff --git a/scripts/check/checks/registry.go b/scripts/check/checks/registry.go index 418ac538..08be67c0 100644 --- a/scripts/check/checks/registry.go +++ b/scripts/check/checks/registry.go @@ -489,6 +489,15 @@ var AllChecks = []CheckDefinition{ IsFast: true, Run: RunGoTests, }, + { + ID: "scripts-go-govulncheck", + Nickname: "govulncheck", + DisplayName: "govulncheck", + App: AppScripts, + Tech: "🐹 Go", + DependsOn: nil, + Run: RunGovulncheck, + }, // Monorepo-wide metrics (informational, never fails) { @@ -519,6 +528,16 @@ var AllChecks = []CheckDefinition{ IsFast: true, Run: RunChangelogCommitLinks, }, + { + ID: "workflows-hardening", + Nickname: "workflows", + DisplayName: "workflows hardening", + App: AppOther, + Tech: "🔒 Security", + DependsOn: nil, + IsFast: true, + Run: RunWorkflowsHardening, + }, } // GetCheckByID returns a check definition by its ID or nickname. diff --git a/scripts/check/checks/scripts-go-deadcode.go b/scripts/check/checks/scripts-go-deadcode.go index 8376b604..8da4737e 100644 --- a/scripts/check/checks/scripts-go-deadcode.go +++ b/scripts/check/checks/scripts-go-deadcode.go @@ -10,7 +10,7 @@ import ( // RunDeadcode runs Go's deadcode tool to find unreachable functions. func RunDeadcode(ctx *CheckContext) (CheckResult, error) { // Ensure deadcode is installed - deadcodePath, err := EnsureGoTool("deadcode", "golang.org/x/tools/cmd/deadcode@latest") + deadcodePath, err := EnsureGoTool("deadcode", "golang.org/x/tools/cmd/deadcode@v0.45.0") if err != nil { return CheckResult{}, fmt.Errorf("failed to install deadcode: %w", err) } diff --git a/scripts/check/checks/scripts-go-gocyclo.go b/scripts/check/checks/scripts-go-gocyclo.go index c7f94e77..4408d926 100644 --- a/scripts/check/checks/scripts-go-gocyclo.go +++ b/scripts/check/checks/scripts-go-gocyclo.go @@ -12,7 +12,7 @@ const GocycloThreshold = 15 // RunGocyclo checks cyclomatic complexity of Go functions. func RunGocyclo(ctx *CheckContext) (CheckResult, error) { - gocycloBin, err := EnsureGoTool("gocyclo", "github.com/fzipp/gocyclo/cmd/gocyclo@latest") + gocycloBin, err := EnsureGoTool("gocyclo", "github.com/fzipp/gocyclo/cmd/gocyclo@v0.6.0") if err != nil { return CheckResult{}, err } diff --git a/scripts/check/checks/scripts-go-govulncheck.go b/scripts/check/checks/scripts-go-govulncheck.go new file mode 100644 index 00000000..a1aade47 --- /dev/null +++ b/scripts/check/checks/scripts-go-govulncheck.go @@ -0,0 +1,61 @@ +package checks + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" +) + +// RunGovulncheck scans every Go module in the repo for known vulnerabilities +// using golang.org/x/vuln/cmd/govulncheck. Static-analysis-based: only flags +// vulns reachable from your code, not every transitive dep that happens to +// have a CVE. Mirrors the role of cargo-audit on the Rust side. +// +// Most of cmdr's Go modules are dep-free tooling scripts; for those, +// govulncheck still checks reachable stdlib calls against the Go vuln DB. +func RunGovulncheck(ctx *CheckContext) (CheckResult, error) { + govulnBin, err := EnsureGoTool("govulncheck", "golang.org/x/vuln/cmd/govulncheck@v1.3.0") + if err != nil { + return CheckResult{}, err + } + + allModules, err := FindAllGoModules(ctx.RootDir) + if err != nil { + return CheckResult{}, fmt.Errorf("failed to find Go modules: %w", err) + } + + var allIssues []string + modCount := 0 + + for goDir, modules := range allModules { + baseDir := filepath.Join(ctx.RootDir, goDir) + for _, mod := range modules { + modDir := filepath.Join(baseDir, mod) + modLabel := filepath.Join(goDir, mod) + modCount++ + + cmd := exec.Command(govulnBin, "./...") + cmd.Dir = modDir + output, err := RunCommand(cmd, true) + if err != nil { + // govulncheck exits non-zero when vulnerabilities are found. + issueText := strings.TrimSpace(output) + if issueText == "" { + issueText = err.Error() + } + allIssues = append(allIssues, fmt.Sprintf("[%s]\n%s", modLabel, issueText)) + } + } + } + + if len(allIssues) > 0 { + return CheckResult{}, fmt.Errorf("govulncheck found vulnerabilities\n%s", + indentOutput(strings.Join(allIssues, "\n"))) + } + + result := Success(fmt.Sprintf("%d %s, no vulns", + modCount, Pluralize(modCount, "module", "modules"))) + result.Total = modCount + return result, nil +} diff --git a/scripts/check/checks/scripts-go-ineffassign.go b/scripts/check/checks/scripts-go-ineffassign.go index a6bc45bf..b7aa673e 100644 --- a/scripts/check/checks/scripts-go-ineffassign.go +++ b/scripts/check/checks/scripts-go-ineffassign.go @@ -9,7 +9,7 @@ import ( // RunIneffassign detects ineffectual assignments. func RunIneffassign(ctx *CheckContext) (CheckResult, error) { - ineffassignBin, err := EnsureGoTool("ineffassign", "github.com/gordonklaus/ineffassign@latest") + ineffassignBin, err := EnsureGoTool("ineffassign", "github.com/gordonklaus/ineffassign@v0.2.0") if err != nil { return CheckResult{}, err } diff --git a/scripts/check/checks/scripts-go-misspell.go b/scripts/check/checks/scripts-go-misspell.go index a841d0c9..099d5554 100644 --- a/scripts/check/checks/scripts-go-misspell.go +++ b/scripts/check/checks/scripts-go-misspell.go @@ -9,7 +9,7 @@ import ( // RunMisspell checks for spelling mistakes. func RunMisspell(ctx *CheckContext) (CheckResult, error) { - misspellBin, err := EnsureGoTool("misspell", "github.com/client9/misspell/cmd/misspell@latest") + misspellBin, err := EnsureGoTool("misspell", "github.com/client9/misspell/cmd/misspell@v0.3.4") if err != nil { return CheckResult{}, err } diff --git a/scripts/check/checks/scripts-go-nilaway.go b/scripts/check/checks/scripts-go-nilaway.go index 65d0ba0d..46c8a05c 100644 --- a/scripts/check/checks/scripts-go-nilaway.go +++ b/scripts/check/checks/scripts-go-nilaway.go @@ -9,7 +9,7 @@ import ( // RunNilaway detects potential nil pointer dereferences. func RunNilaway(ctx *CheckContext) (CheckResult, error) { - nilawayBin, err := EnsureGoTool("nilaway", "go.uber.org/nilaway/cmd/nilaway@latest") + nilawayBin, err := EnsureGoTool("nilaway", "go.uber.org/nilaway/cmd/nilaway@v0.0.0-20260515015210-fd187751154f") if err != nil { return CheckResult{}, err } diff --git a/scripts/check/checks/scripts-go-staticcheck.go b/scripts/check/checks/scripts-go-staticcheck.go index 58e03a9f..a90a49d4 100644 --- a/scripts/check/checks/scripts-go-staticcheck.go +++ b/scripts/check/checks/scripts-go-staticcheck.go @@ -9,7 +9,7 @@ import ( // RunStaticcheck runs staticcheck for static analysis. func RunStaticcheck(ctx *CheckContext) (CheckResult, error) { - staticcheckBin, err := EnsureGoTool("staticcheck", "honnef.co/go/tools/cmd/staticcheck@latest") + staticcheckBin, err := EnsureGoTool("staticcheck", "honnef.co/go/tools/cmd/staticcheck@v0.7.0") if err != nil { return CheckResult{}, err }