diff --git a/.github/workflows/gosec.yml b/.github/workflows/gosec.yml new file mode 100644 index 00000000..d5cc742a --- /dev/null +++ b/.github/workflows/gosec.yml @@ -0,0 +1,128 @@ +name: Gosec Security Scan + +# Port of the Lumera chain repo's `gosec.yml` (LumeraProtocol/lumera#213). +# +# Same tool, same policy: HIGH severity + HIGH confidence, suppressions must +# carry both a rule ID and a justification (`-nosec-require-rules`, +# `-nosec-require-justification`), results published to GitHub code scanning +# as SARIF. +# +# Difference from the chain repo: supernode has four Go modules instead of +# three. gosec's package loader walks a single module at a time, so each is +# scanned from its own working directory. A root `./...` run does NOT cover +# the nested modules. + +on: + pull_request: + paths-ignore: + - "**.md" + - "docs/**" + - ".gitignore" + push: + branches: [master] + +permissions: + contents: read + security-events: write + +jobs: + gosec: + name: gosec (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + # Report every module; one module's findings must not hide another's. + fail-fast: false + matrix: + include: + - name: supernode + module: . + - name: sncli + module: cmd/sncli + - name: sn-manager + module: sn-manager + - name: systemtests + module: tests/system + + steps: + - name: Checkout code + uses: actions/checkout@v6.0.1 + + # Installs Go from go.mod, libwebp-dev (required for the root module to + # type-check — without it gosec fails SSA construction rather than + # reporting a clean scan), and GOPRIVATE for the Lumera module. + - name: Setup Go and system deps + uses: ./.github/actions/setup-env + + - name: Download Go modules + working-directory: ${{ matrix.module }} + run: go mod download + + - name: Install gosec + run: go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0 + + # No `continue-on-error` here: this is a blocking gate. `-stdout + # -verbose text` keeps findings readable in the job log even when the + # SARIF upload is skipped (fork PRs). + - name: Run gosec + working-directory: ${{ matrix.module }} + run: | + gosec \ + -severity high \ + -confidence high \ + -nosec-require-rules \ + -nosec-require-justification \ + -fmt sarif \ + -out gosec.sarif \ + -stdout \ + -verbose text \ + ./... + + # gosec emits artifact URIs relative to the scanned module, so findings + # from nested modules would otherwise be attributed to paths that do not + # exist at the repository root. Prefix them with the module directory. + - name: Normalize SARIF paths + if: ${{ always() && matrix.module != '.' }} + env: + GOSEC_MODULE: ${{ matrix.module }} + GOSEC_SARIF: ${{ matrix.module }}/gosec.sarif + run: | + python3 - <<'PY' + import json + import os + from pathlib import PurePosixPath + from urllib.parse import urlparse + + sarif_path = os.environ["GOSEC_SARIF"] + module = os.environ["GOSEC_MODULE"].strip("/") + with open(sarif_path, encoding="utf-8") as sarif_file: + sarif = json.load(sarif_file) + + def prefix_artifact_uris(value): + if isinstance(value, dict): + artifact = value.get("artifactLocation") + if isinstance(artifact, dict): + uri = artifact.get("uri") + if isinstance(uri, str) and uri and not urlparse(uri).scheme and not uri.startswith("/"): + if not uri.startswith(module + "/"): + artifact["uri"] = str(PurePosixPath(module) / uri) + for child in value.values(): + prefix_artifact_uris(child) + elif isinstance(value, list): + for child in value: + prefix_artifact_uris(child) + + prefix_artifact_uris(sarif) + with open(sarif_path, "w", encoding="utf-8") as sarif_file: + json.dump(sarif, sarif_file, separators=(",", ":")) + PY + + # `security-events: write` is not granted to fork PRs, and dependabot + # runs with a read-only token, so skip only the upload for those. The + # scan itself still runs and still blocks. + - name: Upload SARIF + if: ${{ always() && (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')) }} + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: ${{ matrix.module }}/gosec.sarif + category: gosec-${{ matrix.name }} diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 00000000..765668a6 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,63 @@ +name: Go Vulnerability Scan + +# Port of the Lumera chain repo's `govulncheck.yml` (LumeraProtocol/lumera#212). +# +# Same tool, same goal: fail a PR when it introduces (or is built on) a +# *reachable* known vulnerability. govulncheck's default mode only reports +# vulnerabilities whose vulnerable symbols are actually called, so unused +# transitive dependencies do not create noise. +# +# Difference from the chain repo: supernode is a multi-module repository +# (root, cmd/sncli, sn-manager, tests/system). The chain repo scans a single +# module; here each module is scanned independently via a matrix so a +# vulnerability in sn-manager cannot hide behind a clean root scan. + +on: + pull_request: + paths-ignore: + - "**.md" + - "docs/**" + - ".gitignore" + push: + branches: [master] + +permissions: + contents: read + +jobs: + govulncheck: + name: govulncheck (${{ matrix.name }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + # Surface every module's result; do not let the first failure mask others. + fail-fast: false + matrix: + include: + - name: supernode + module: . + - name: sncli + module: cmd/sncli + - name: sn-manager + module: sn-manager + - name: systemtests + module: tests/system + + steps: + - name: Checkout code + uses: actions/checkout@v6.0.1 + + # Installs Go from go.mod, libwebp-dev (required to build the root + # module), and sets GOPRIVATE for the Lumera module. + - name: Setup Go and system deps + uses: ./.github/actions/setup-env + + # Pinned rather than @latest: an unpinned scanner turns an unrelated + # upstream release into a surprise CI break, and makes a red run + # non-reproducible after the fact. + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 + + - name: Run govulncheck + working-directory: ${{ matrix.module }} + run: govulncheck ./... diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml new file mode 100644 index 00000000..e4718cf0 --- /dev/null +++ b/.github/workflows/lint-pr.yml @@ -0,0 +1,93 @@ +name: lint-pr + +# Port of the Lumera chain repo's `lint-pr.yml` (LumeraProtocol/lumera#126). +# +# Runs golangci-lint through reviewdog with `filter_mode: added`, so findings +# are posted as inline review comments on exactly the lines a PR changed and +# the pre-existing baseline stays invisible. +# +# IMPORTANT DIFFERENCE FROM THE CHAIN REPO: +# The chain repo has a separate full-scan gate (`lint.yml`) that is the +# authoritative merge blocker, which lets its reviewdog layer be purely +# additive. Supernode has NO existing lint gate and a large legacy baseline, +# so a full-repo hard gate would be red on day one. This workflow is therefore +# the only lint enforcement, and it is intentionally scoped to the diff: +# new code must be clean, legacy debt is not retroactively blocking. +# +# Version pinning (see below) is not cosmetic — go.mod declares go 1.26.2 and +# golangci-lint must be BUILT with a Go >= that language version or it exits +# with a hard "the Go language version used to build golangci-lint is lower +# than the targeted Go version" error. v2.13.2 is built with go1.27.0. +# reviewdog/action-golangci-lint must be >= v2.8.0 to honour a v2.x pin. + +on: + pull_request: + paths-ignore: + - "**.md" + - "docs/**" + - ".gitignore" + +permissions: + contents: read + pull-requests: write # reviewdog needs this to post inline review comments (same-repo PRs only) + checks: write # for the check-run summary + +jobs: + golangci-lint-diff: + name: golangci-lint (diff-only, inline) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout code + uses: actions/checkout@v6.0.1 + with: + # reviewdog needs the merge-base to compute the PR diff. + fetch-depth: 0 + + # Installs Go from go.mod plus libwebp-dev, which the root module + # requires in order to type-check. + - name: Setup Go and system deps + uses: ./.github/actions/setup-env + + # Same-repo PRs: post findings as inline review comments. The default + # GITHUB_TOKEN has the `pull-requests: write` scope declared above. + # + # We deliberately do NOT set `level` here: reviewdog's `level` input + # REWRITES every finding's severity rather than defaulting it, so + # `level: warning` combined with `fail_level: error` would silently + # neuter the gate. Leaving it unset preserves native severities so + # govet/staticcheck errors actually trip `fail_level: error`. + - name: golangci-lint via reviewdog (same-repo PR — inline comments) + if: github.event.pull_request.head.repo.full_name == github.repository + uses: reviewdog/action-golangci-lint@v2.10.0 + with: + go_version_file: go.mod + golangci_lint_version: v2.13.2 + golangci_lint_flags: "--config=.golangci.yml --timeout=15m" + workdir: . + # Only annotate lines actually changed by the PR. + filter_mode: added + reporter: github-pr-review + fail_level: error + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Fork PRs: GITHUB_TOKEN is strictly read-only regardless of the + # workflow-level permissions block, so neither `github-pr-review` + # (needs pull-requests: write) nor `github-pr-check` (needs + # checks: write) can post results. The `local` reporter writes findings + # to the job log instead. `fail_level: error` still fails the job, so + # the check continues to block merge for external contributors. + - name: golangci-lint via reviewdog (fork PR — log-only) + if: github.event.pull_request.head.repo.full_name != github.repository + uses: reviewdog/action-golangci-lint@v2.10.0 + with: + go_version_file: go.mod + golangci_lint_version: v2.13.2 + golangci_lint_flags: "--config=.golangci.yml --timeout=15m" + workdir: . + filter_mode: added + reporter: local + fail_level: error + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..eed0b116 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,77 @@ +version: "2" + +# Linter policy for the supernode repo. +# +# Mirrors the Lumera chain repo's `.golangci.yml` (same linter set, same +# v2 schema) so both repos enforce a consistent bar. The exclusion list is +# supernode-specific: it covers generated protobuf/mock surfaces and test +# helpers rather than the chain's Cosmos SDK deprecations. +# +# Enforcement is diff-only via reviewdog (`.github/workflows/lint-pr.yml`), +# so the pre-existing baseline is not a merge blocker; only findings on +# lines a PR actually touches are reported. + +linters: + default: none + enable: + - errcheck + - staticcheck + - unused + - ineffassign + - govet + - nolintlint + + exclusions: + generated: strict + rules: + # Test files: setup helpers routinely ignore errors from fixture + # construction where a failure would surface as a test failure anyway. + - path: _test\.go + linters: + - errcheck + + # Generated gRPC/protobuf gateway + mock surfaces. + - path: (gen/|/mocks?/|\.pb\.go|\.pb\.gw\.go) + linters: + - errcheck + - unused + - staticcheck + + # SDK deprecated APIs — cannot be removed until upstream drops them. + - linters: + - staticcheck + text: "SA1019" + + # Cosmetic quick-fix suggestions (QF1003/QF1007/QF1008/QF1011). + - linters: + - staticcheck + text: "QF10(0[378]|11)" + + # S1001 (use copy), S1009 (nil check before len), S1011 (use append), + # S1021 (merge var) — cosmetic, not correctness issues. + - linters: + - staticcheck + text: "S100[19]|S101[1]|S1021" + + # ST1005 (error string capitalization), ST1019 (duplicate import), + # ST1023 (omit type from declaration) — style, not correctness. + - linters: + - staticcheck + text: "ST10(05|19|23)" + + # SA4031 (nil check on make result), SA9003 (empty branch) — + # intentional patterns in existing code. + - linters: + - staticcheck + text: "SA(4031|9003)" + + # SA1029 (built-in type as context key) — existing pattern. + - linters: + - staticcheck + text: "SA1029" + +formatters: + enable: + - gofmt + exclusions: + generated: strict diff --git a/cmd/sncli/cli/utils.go b/cmd/sncli/cli/utils.go index 15e893a3..801826fd 100644 --- a/cmd/sncli/cli/utils.go +++ b/cmd/sncli/cli/utils.go @@ -25,6 +25,8 @@ func NormalizePath(path string) string { func processConfigPath(path string) string { path = NormalizePath(path) // check if path defines directory + // #nosec G703 -- path is an explicit operator-supplied --config value for + // a local CLI, already normalized/cleaned above; only stat'ed here. if info, err := os.Stat(path); err == nil && info.IsDir() { path = filepath.Join(path, defaultConfigFileName) } diff --git a/sn-manager/cmd/helpers.go b/sn-manager/cmd/helpers.go index 3187e069..e52263b7 100644 --- a/sn-manager/cmd/helpers.go +++ b/sn-manager/cmd/helpers.go @@ -50,6 +50,8 @@ func ensureSupernodeInitialized() error { userHome = os.Getenv("HOME") } supernodeConfigPath := filepath.Join(userHome, ".supernode", "config.yml") + // #nosec G703 -- path is derived from the operator's own home directory + // (os.UserHomeDir/$HOME), not from untrusted input, and is only stat'ed. if _, err := os.Stat(supernodeConfigPath); os.IsNotExist(err) { return fmt.Errorf("SuperNode not initialized. Please run 'sn-manager init' first to configure your validator keys and network settings") } diff --git a/sn-manager/cmd/init.go b/sn-manager/cmd/init.go index cebbeb83..acba2482 100644 --- a/sn-manager/cmd/init.go +++ b/sn-manager/cmd/init.go @@ -237,6 +237,8 @@ func runInit(cmd *cobra.Command, args []string) error { userHome = os.Getenv("HOME") } supernodeConfigPath := filepath.Join(userHome, ".supernode", "config.yml") + // #nosec G703 -- path is derived from the operator's own home directory + // (os.UserHomeDir/$HOME), not from untrusted input, and is only stat'ed. if _, err := os.Stat(supernodeConfigPath); err == nil { fmt.Println("✓ SuperNode already initialized, skipping initialization") } else { diff --git a/supernode/transport/gateway/server.go b/supernode/transport/gateway/server.go index 55985335..a4e2e830 100644 --- a/supernode/transport/gateway/server.go +++ b/supernode/transport/gateway/server.go @@ -5,6 +5,11 @@ import ( "fmt" "net" "net/http" + // #nosec G108 -- importing net/http/pprof registers handlers on + // http.DefaultServeMux, but this server never serves DefaultServeMux. The + // pprof routes below are registered on a private mux and are only wired up + // when pprofEnabled (testnet chain-id or ENABLE_PPROF=true); the handlers + // additionally re-check that flag and return 403 otherwise. _ "net/http/pprof" "os" "strconv" diff --git a/tests/system/system.go b/tests/system/system.go index 5a1adbb9..1fddd3c7 100644 --- a/tests/system/system.go +++ b/tests/system/system.go @@ -205,6 +205,8 @@ func disableStateSyncConfig(configPath string) error { if info, err := os.Stat(configPath); err == nil { perm = info.Mode().Perm() } + // #nosec G703 -- test harness: configPath is a node home directory created + // by this system-test suite itself, not untrusted input. return os.WriteFile(configPath, []byte(strings.Join(lines, "\n")), perm) }