diff --git a/.github/workflows/go-quality.yml b/.github/workflows/go-quality.yml new file mode 100644 index 000000000..84675fe9d --- /dev/null +++ b/.github/workflows/go-quality.yml @@ -0,0 +1,37 @@ +name: Go Quality Checks + +on: + pull_request: + paths: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - '.golangci.yml' + - '.github/workflows/go-quality.yml' + +permissions: + contents: read + +jobs: + go-quality: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Go + uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0 + with: + go-version: '1.24' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 + with: + version: v2.8.0 + args: --timeout=5m + + - name: Run gosec + uses: securego/gosec@75533f497e6090cf7ae8e00a1419c97dca097807 # v2.23.0 + with: + args: -exclude=G101,G104,G112,G114,G115,G117,G204,G301,G304,G306,G703,G704,G706 ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..1abfd7fec --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,10 @@ +version: 2 + +run: + timeout: 5m + tests: false + +linters: + default: none + enable: + - govet diff --git a/cmd/console/main.go b/cmd/console/main.go index ec2a99e3d..3761cec71 100644 --- a/cmd/console/main.go +++ b/cmd/console/main.go @@ -76,6 +76,8 @@ func ensureDir(path string) { } } if dir != path && dir != "" { - os.MkdirAll(dir, 0755) + if err := os.MkdirAll(dir, 0750); err != nil { + log.Printf("failed to create directory %s: %v", dir, err) + } } } diff --git a/pkg/agent/config.go b/pkg/agent/config.go index 76005ce93..92590cc16 100644 --- a/pkg/agent/config.go +++ b/pkg/agent/config.go @@ -24,7 +24,7 @@ type AgentConfig struct { // AgentKeyConfig holds API key configuration for a provider type AgentKeyConfig struct { - APIKey string `yaml:"api_key"` + APIKey string `yaml:"api_key"` // #nosec G117 -- API key is expected in local config. Model string `yaml:"model,omitempty"` } diff --git a/pkg/agent/metrics_history.go b/pkg/agent/metrics_history.go index c66c2f496..e206ede5f 100644 --- a/pkg/agent/metrics_history.go +++ b/pkg/agent/metrics_history.go @@ -280,6 +280,7 @@ func (mh *MetricsHistory) saveToDisk() { func (mh *MetricsHistory) loadFromDisk() { filePath := filepath.Join(mh.dataDir, metricsHistoryFile) + // #nosec G304 -- metrics history path is controlled by the agent configuration. data, err := os.ReadFile(filePath) if err != nil { if !os.IsNotExist(err) { diff --git a/pkg/agent/server.go b/pkg/agent/server.go index 7497802c7..af00db77b 100644 --- a/pkg/agent/server.go +++ b/pkg/agent/server.go @@ -337,7 +337,12 @@ func (s *Server) Start() error { log.Println("Device tracker started") } - return http.ListenAndServe(addr, mux) + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + return server.ListenAndServe() } // handleHealth handles HTTP health checks @@ -1958,6 +1963,7 @@ func getTokenUsagePath() string { // loadTokenUsage loads token usage from disk on startup func (s *Server) loadTokenUsage() { path := getTokenUsagePath() + // #nosec G304 -- token usage path is resolved from a fixed location. data, err := os.ReadFile(path) if err != nil { return // File doesn't exist yet @@ -2022,7 +2028,7 @@ type KeysStatusResponse struct { // SetKeyRequest is the request body for POST /settings/keys type SetKeyRequest struct { Provider string `json:"provider"` - APIKey string `json:"apiKey"` + APIKey string `json:"apiKey"` // #nosec G117 -- API key is expected in this request payload. Model string `json:"model,omitempty"` } diff --git a/pkg/api/handlers/auth.go b/pkg/api/handlers/auth.go index 68be6b4b5..55ecb658c 100644 --- a/pkg/api/handlers/auth.go +++ b/pkg/api/handlers/auth.go @@ -54,7 +54,7 @@ func validateAndConsumeOAuthState(state string) bool { type AuthConfig struct { GitHubClientID string GitHubSecret string - JWTSecret string + JWTSecret string // #nosec G117 -- configuration contains a JWT secret value. FrontendURL string BackendURL string // Backend URL for OAuth callback (defaults to http://localhost:8080) DevUserLogin string diff --git a/pkg/api/server.go b/pkg/api/server.go index d43436b65..a8a8f56f7 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -38,7 +38,7 @@ type Config struct { DatabasePath string GitHubClientID string GitHubSecret string - JWTSecret string + JWTSecret string // #nosec G117 -- configuration contains a JWT secret value. FrontendURL string ClaudeAPIKey string KubestellarOpsPath string @@ -220,7 +220,11 @@ func startLoadingServer(addr string) *http.Server { w.Write([]byte(startupLoadingHTML)) }) - srv := &http.Server{Addr: addr, Handler: mux} + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } go func() { log.Printf("Loading page available on %s", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index e7cfc12e1..787330943 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -477,6 +477,7 @@ func NewMultiClusterClient(kubeconfig string) (*MultiClusterClient, error) { } // Try to detect if we're running in-cluster + // #nosec G703 -- kubeconfig path is provided explicitly via configuration. if _, err := os.Stat(kubeconfig); os.IsNotExist(err) { // No kubeconfig file, try in-cluster config if inClusterConfig, err := rest.InClusterConfig(); err == nil { diff --git a/pkg/k8s/dependencies.go b/pkg/k8s/dependencies.go index 649f2fc37..125a98277 100644 --- a/pkg/k8s/dependencies.go +++ b/pkg/k8s/dependencies.go @@ -377,7 +377,7 @@ func (m *MultiClusterClient) ResolveDependencies( // For Secrets: strip service-account-token type secrets (auto-generated, cluster-specific) if dep.Kind == DepSecret { secretType, _, _ := unstructured.NestedString(obj.Object, "type") - if secretType == "kubernetes.io/service-account-token" { + if secretType == "kubernetes.io/service-account-token" { // #nosec G101 -- this is a Kubernetes secret type identifier. bundle.Warnings = append(bundle.Warnings, fmt.Sprintf("Secret %s is a service-account-token (auto-generated, skipping)", dep.Name)) continue diff --git a/pkg/mcp/client.go b/pkg/mcp/client.go index e5206b1b5..cf4fdb049 100644 --- a/pkg/mcp/client.go +++ b/pkg/mcp/client.go @@ -116,6 +116,7 @@ type ContentItem struct { // NewClient creates a new MCP client for the given binary func NewClient(name, binaryPath string, args ...string) (*Client, error) { + // #nosec G204 -- binaryPath is a trusted executable path from configuration. cmd := exec.Command(binaryPath, args...) stdin, err := cmd.StdinPipe() @@ -230,7 +231,9 @@ func (c *Client) initialize(ctx context.Context) error { } // Send initialized notification - c.notify("notifications/initialized", nil) + if err := c.notify("notifications/initialized", nil); err != nil { + return fmt.Errorf("failed to send initialized notification: %w", err) + } return nil } diff --git a/pkg/notifications/email.go b/pkg/notifications/email.go index a7dde0bdd..0f4beec2e 100644 --- a/pkg/notifications/email.go +++ b/pkg/notifications/email.go @@ -13,7 +13,7 @@ type EmailNotifier struct { SMTPHost string SMTPPort int Username string - Password string + Password string // #nosec G117 -- configuration struct stores a password value. From string To []string UseTLS bool diff --git a/pkg/settings/crypto.go b/pkg/settings/crypto.go index e333969d6..918af3c45 100644 --- a/pkg/settings/crypto.go +++ b/pkg/settings/crypto.go @@ -22,6 +22,7 @@ const ( // If the file doesn't exist, it generates 32 random bytes and writes them hex-encoded. // Returns the raw 32-byte key. func ensureKeyFile(path string) ([]byte, error) { + // #nosec G304 -- path is provided by configuration and expected to be trusted. data, err := os.ReadFile(path) if err == nil { // Key file exists — decode hex @@ -71,7 +72,8 @@ func encrypt(key []byte, plaintext []byte) (*EncryptedField, error) { return nil, fmt.Errorf("failed to generate nonce: %w", err) } - // Seal appends the ciphertext + GCM auth tag + // Seal appends the ciphertext + GCM auth tag. + // #nosec G407 -- nonce is generated randomly per encryption call. ciphertext := gcm.Seal(nil, nonce, plaintext, nil) return &EncryptedField{ diff --git a/pkg/settings/types.go b/pkg/settings/types.go index f1b826e85..7fcb9d838 100644 --- a/pkg/settings/types.go +++ b/pkg/settings/types.go @@ -96,7 +96,7 @@ type AllSettings struct { // APIKeyEntry holds a provider's API key and optional model override type APIKeyEntry struct { - APIKey string `json:"apiKey"` + APIKey string `json:"apiKey"` // #nosec G117 -- API keys are stored in settings payloads. Model string `json:"model,omitempty"` }