diff --git a/internal/args/args.go b/internal/args/args.go index 8b22f3a..dcee810 100644 --- a/internal/args/args.go +++ b/internal/args/args.go @@ -5,6 +5,9 @@ import ( "errors" "flag" "fmt" + "os" + "strings" + "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/checks/naming" "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/checks/organization" "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/checks/performance" @@ -13,8 +16,6 @@ import ( "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/defaults" "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/types" "github.com/spf13/viper" - "os" - "strings" ) func ParseArgs(args []string) (*config.OctolintConfig, error) { @@ -64,6 +65,7 @@ func ParseArgs(args []string) (*config.OctolintConfig, error) { flags.IntVar(&octolintConfig.MaxInvalidNameTargets, "maxInvalidNameTargets", defaults.MaxInvalidNameTargets, "Maximum number of targets to check for invalid names for the "+naming.OctoLintInvalidTargetNames+" check. Set to 0 to check all targets.") flags.IntVar(&octolintConfig.MaxInsecureK8sTargets, "maxInsecureK8sTargets", defaults.MaxInsecureK8sTargets, "Maximum number of targets to check for insecure k8s configuration for the "+security.OctoLintInsecureK8sTargets+" check. Set to 0 to check all targets.") flags.IntVar(&octolintConfig.MaxDeploymentTasks, "maxDeploymentTasks", defaults.MaxDeploymentTasks, "Maximum number of deployment tasks to scan for the "+performance.OctoLintDeploymentQueuedTime+" check. Set to 0 to check all targets.") + flags.IntVar(&octolintConfig.MaxSha1CertificatesMachines, "maxSha1CertificatesMachines", defaults.MaxSha1CertificatesMachines, "Maximum number of machines to check for SHA1 certificates for the "+security.OctoLintSha1Certificates+" check. Set to 0 to check all targets and workers.") flags.StringVar(&octolintConfig.ContainerImageRegex, "containerImageRegex", "", "The regular expression used to validate container images for the "+naming.OctoLintContainerImageName+" check") flags.StringVar(&octolintConfig.VariableNameRegex, "variableNameRegex", "", "The regular expression used to validate variable names for the "+naming.OctoLintInvalidVariableNames+" check") flags.StringVar(&octolintConfig.TargetNameRegex, "targetNameRegex", "", "The regular expression used to validate target names for the "+naming.OctoLintInvalidTargetNames+" check") diff --git a/internal/checks/factory/octopus_check_factory.go b/internal/checks/factory/octopus_check_factory.go index 197c503..4d1e536 100644 --- a/internal/checks/factory/octopus_check_factory.go +++ b/internal/checks/factory/octopus_check_factory.go @@ -1,6 +1,8 @@ package factory import ( + "strings" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/checks" "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/checks/naming" @@ -10,7 +12,6 @@ import ( "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/config" "github.com/samber/lo" "golang.org/x/exp/slices" - "strings" ) // OctopusCheckFactory builds all the lint checks. This is where you can customize things like error handlers. @@ -45,6 +46,7 @@ func (o OctopusCheckFactory) BuildAllChecks(config *config.OctolintConfig) ([]ch security.NewOctopusInsecureK8sCheck(o.client, config, o.errorHandler), security.NewOctopusInsecureFeedsCheck(o.client, config, o.errorHandler), security.NewOctopusInsecureSubscriptionsCheck(o.client, config, o.errorHandler), + security.NewOctopusSha1CertificatesCheck(o.client, config, o.errorHandler), organization.NewOctopusEnvironmentCountCheck(o.client, config, o.errorHandler), organization.NewOctopusDefaultProjectGroupCountCheck(o.client, config, o.errorHandler), organization.NewOctopusEmptyProjectCheck(o.client, config, o.errorHandler), diff --git a/internal/checks/security/octopus_duplicated_git_creds_check.go b/internal/checks/security/octopus_duplicated_git_creds_check.go index 5c17850..dc12d3e 100644 --- a/internal/checks/security/octopus_duplicated_git_creds_check.go +++ b/internal/checks/security/octopus_duplicated_git_creds_check.go @@ -3,13 +3,14 @@ package security import ( "errors" "fmt" + "strings" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/checks" "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/config" "go.uber.org/zap" - "strings" ) // CustomProject is the simplest representation of a project and its version controlled settings @@ -103,7 +104,7 @@ func (o OctopusDuplicatedGitCredentialsCheck) Execute(concurrency int) (checks.O } return checks.NewOctopusCheckResultImpl( - "No Git usernames have been resued", + "No Git usernames have been reused", o.Id(), "", checks.Ok, diff --git a/internal/checks/security/octopus_sha1_certificates_check.go b/internal/checks/security/octopus_sha1_certificates_check.go new file mode 100644 index 0000000..9e7a381 --- /dev/null +++ b/internal/checks/security/octopus_sha1_certificates_check.go @@ -0,0 +1,188 @@ +package security + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strings" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/checks" + "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/client_wrapper" + "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/config" + "go.uber.org/zap" +) + +const ( + OctoLintSha1Certificates = "OctoLintSha1Certificates" + sha1Alg = "sha1RSA" +) + +// OctopusSha1CertificatesCheck checks to see if any targets, workers or the server itself is using a sha1 certificate +type OctopusSha1CertificatesCheck struct { + client *client.Client + errorHandler checks.OctopusClientErrorHandler + config *config.OctolintConfig +} + +type Sha1CertificateResult struct { + Name string + Type string // "Target", "Worker", or "Global" +} + +type ServerCertificate struct { + ID string `json:"Id"` + Name string `json:"Name"` + Thumbprint string `json:"Thumbprint"` + SignatureAlgorithm string `json:"SignatureAlgorithm"` + Links map[string]string `json:"Links"` +} + +func NewOctopusSha1CertificatesCheck(client *client.Client, config *config.OctolintConfig, errorHandler checks.OctopusClientErrorHandler) OctopusSha1CertificatesCheck { + return OctopusSha1CertificatesCheck{config: config, client: client, errorHandler: errorHandler} +} + +func (o OctopusSha1CertificatesCheck) Id() string { + return OctoLintSha1Certificates +} + +// fetchServerCertificate gets the server certificate object and returns it. +func fetchServerCertificate(url, apiKey, accessToken string) (*ServerCertificate, error) { + requestURL := fmt.Sprintf("%s/api/configuration/certificates/certificate-global", url) + + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + + if apiKey != "" { + req.Header.Set("X-Octopus-ApiKey", apiKey) + } else if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) + } + + var cert ServerCertificate + if err := json.NewDecoder(res.Body).Decode(&cert); err != nil { + return nil, err + } + + return &cert, nil +} + +// hasSha1Certificate checks if an endpoint has CertificateSignatureAlgorithm == sha1Alg +func hasSha1Certificate(ep machines.IEndpoint) bool { + if ep == nil { + return false + } + + switch e := ep.(type) { + case *machines.ListeningTentacleEndpoint: + return e.CertificateSignatureAlgorithm == sha1Alg + case *machines.PollingTentacleEndpoint: + return e.CertificateSignatureAlgorithm == sha1Alg + default: + return false + } +} + +// addSha1FromMachines is a top-level generic helper (function literals can't have type params). +func addSha1FromMachines[T any]( + results *[]Sha1CertificateResult, + items []T, + getName func(T) string, + getEndpoint func(T) machines.IEndpoint, + typ string, +) { + for _, item := range items { + if hasSha1Certificate(getEndpoint(item)) { + *results = append(*results, Sha1CertificateResult{Name: getName(item), Type: typ}) + } + } +} + +func (o OctopusSha1CertificatesCheck) Execute(concurrency int) (checks.OctopusCheckResult, error) { + if o.client == nil { + return nil, errors.New("octoclient is nil") + } + + zap.L().Debug("Starting check " + o.Id()) + defer func() { + zap.L().Debug("Ended check " + o.Id()) + }() + + var results []Sha1CertificateResult + + // Check server certificate + cert, err := fetchServerCertificate(o.config.Url, o.config.ApiKey, o.config.AccessToken) + if err != nil { + return o.errorHandler.HandleError(o.Id(), checks.Security, err) + } + if cert != nil && cert.SignatureAlgorithm == sha1Alg { + results = append(results, Sha1CertificateResult{Name: cert.Name, Type: "Global"}) + } + + // Check deployment targets + targets, err := client_wrapper.GetMachines(o.config.MaxSha1CertificatesMachines, o.client, o.client.GetSpaceID()) + if err != nil { + return o.errorHandler.HandleError(o.Id(), checks.Security, err) + } + addSha1FromMachines(&results, targets, + func(m *machines.DeploymentTarget) string { return m.Name }, + func(m *machines.DeploymentTarget) machines.IEndpoint { return m.Endpoint }, + "Target", + ) + + // Check workers + workers, err := client_wrapper.GetWorkers(o.config.MaxSha1CertificatesMachines, o.client, o.client.GetSpaceID()) + if err != nil { + return o.errorHandler.HandleError(o.Id(), checks.Security, err) + } + addSha1FromMachines(&results, workers, + func(w *machines.Worker) string { return w.Name }, + func(w *machines.Worker) machines.IEndpoint { return w.Endpoint }, + "Worker", + ) + + // Provide results + if len(results) > 0 { + // Sort by Type then Name for stable output + sort.Slice(results, func(i, j int) bool { + if results[i].Type == results[j].Type { + return results[i].Name < results[j].Name + } + return results[i].Type < results[j].Type + }) + + lines := make([]string, len(results)) + for i, m := range results { + lines[i] = fmt.Sprintf("%s: %s", m.Type, m.Name) + } + + return checks.NewOctopusCheckResultImpl( + "The following resources use a SHA1 certificate:\n"+strings.Join(lines, "\n"), + o.Id(), + "", + checks.Warning, + checks.Security), nil + } + + return checks.NewOctopusCheckResultImpl( + "There are no uses of SHA1 certificates in targets, workers or the main Server Certificate", + o.Id(), + "", + checks.Ok, + checks.Security), nil +} diff --git a/internal/checks/security/octopus_sha1_certificates_check_test.go b/internal/checks/security/octopus_sha1_certificates_check_test.go new file mode 100644 index 0000000..456df0a --- /dev/null +++ b/internal/checks/security/octopus_sha1_certificates_check_test.go @@ -0,0 +1,61 @@ +package security + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/checks" + "github.com/OctopusSolutionsEngineering/OctopusRecommendationEngine/internal/config" + "github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework/octoclient" + "github.com/OctopusSolutionsEngineering/OctopusTerraformTestFramework/test" +) + +func TestSha1Certificates(t *testing.T) { + testFramework := test.OctopusContainerTest{} + + testFramework.ArrangeTest(t, func(t *testing.T, container *test.OctopusContainer, client *client.Client) error { + // Act: Deploy Terraform scenario that sets up SHA1 certificates + newSpaceId, err := testFramework.Act( + t, + container, + filepath.Join("..", "..", "..", "test", "terraform"), + "33-sha1certificates", // folder containing your Terraform scenario + []string{}, + ) + if err != nil { + return err + } + + // Create a client for the new space + newSpaceClient, err := octoclient.CreateClient(container.URI, newSpaceId, test.ApiKey) + if err != nil { + return err + } + + // Create the check + check := NewOctopusSha1CertificatesCheck( + newSpaceClient, + &config.OctolintConfig{ + Url: container.URI, + ApiKey: test.ApiKey, + MaxSha1CertificatesMachines: 100, + }, + checks.OctopusClientPermissiveErrorHandler{}, + ) + + // Execute the check + result, err := check.Execute(2) + if err != nil { + return err + } + + // Assert + if result == nil || result.Severity() != checks.Ok { + return errors.New("check should have passed") + } + + return nil + }) +} diff --git a/internal/client_wrapper/workers.go b/internal/client_wrapper/workers.go new file mode 100644 index 0000000..31b58bc --- /dev/null +++ b/internal/client_wrapper/workers.go @@ -0,0 +1,23 @@ +package client_wrapper + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workers" +) + +func GetWorkers(limit int, client newclient.Client, spaceID string) ([]*machines.Worker, error) { + if limit == 0 { + return workers.GetAll(client, spaceID) + } + + result, err := workers.Get(client, spaceID, machines.WorkersQuery{ + Take: limit, + }) + + if err != nil { + return nil, err + } + + return result.Items, nil +} diff --git a/internal/config/args.go b/internal/config/args.go index 8cde540..5d1310a 100644 --- a/internal/config/args.go +++ b/internal/config/args.go @@ -80,6 +80,7 @@ type OctolintConfig struct { MaxInvalidNameTargets int MaxInsecureK8sTargets int MaxDeploymentTasks int + MaxSha1CertificatesMachines int } type StringSliceArgs []string diff --git a/internal/defaults/defaultValues.go b/internal/defaults/defaultValues.go index 42df0b5..56169dc 100644 --- a/internal/defaults/defaultValues.go +++ b/internal/defaults/defaultValues.go @@ -24,5 +24,6 @@ const MaxTenantTagsTenants = 100 const MaxProjectSpecificEnvironmentEnvironments = 100 const MaxInvalidNameTargets = 100 const MaxInsecureK8sTargets = 100 +const MaxSha1CertificatesMachines = 100 const MaxDeploymentTasks = 100 const MaxDefaultStepNameProjects = 100 diff --git a/test/terraform/33-sha1certificates/config.tf b/test/terraform/33-sha1certificates/config.tf new file mode 100644 index 0000000..dd4af4d --- /dev/null +++ b/test/terraform/33-sha1certificates/config.tf @@ -0,0 +1,5 @@ +terraform { + required_providers { + octopusdeploy = { source = "OctopusDeployLabs/octopusdeploy", version = "0.30.4" } + } +} diff --git a/test/terraform/33-sha1certificates/environments.tf b/test/terraform/33-sha1certificates/environments.tf new file mode 100644 index 0000000..720d18f --- /dev/null +++ b/test/terraform/33-sha1certificates/environments.tf @@ -0,0 +1,20 @@ +resource "octopusdeploy_environment" "development_environment" { + allow_dynamic_infrastructure = true + description = "A development environment" + name = "Development" + use_guided_failure = false +} + +resource "octopusdeploy_environment" "test_environment" { + allow_dynamic_infrastructure = true + description = "A test environment" + name = "Test" + use_guided_failure = false +} + +resource "octopusdeploy_environment" "production_environment" { + allow_dynamic_infrastructure = true + description = "A production environment" + name = "Production" + use_guided_failure = false +} \ No newline at end of file diff --git a/test/terraform/33-sha1certificates/provider.tf b/test/terraform/33-sha1certificates/provider.tf new file mode 100644 index 0000000..a041977 --- /dev/null +++ b/test/terraform/33-sha1certificates/provider.tf @@ -0,0 +1,5 @@ +provider "octopusdeploy" { + address = "${var.octopus_server}" + api_key = "${var.octopus_apikey}" + space_id = "${var.octopus_space_id}" +} diff --git a/test/terraform/33-sha1certificates/provider_vars.tf b/test/terraform/33-sha1certificates/provider_vars.tf new file mode 100644 index 0000000..c7d93fe --- /dev/null +++ b/test/terraform/33-sha1certificates/provider_vars.tf @@ -0,0 +1,18 @@ +variable "octopus_server" { + type = string + nullable = false + sensitive = false + description = "The URL of the Octopus server e.g. https://myinstance.octopus.app." +} +variable "octopus_apikey" { + type = string + nullable = false + sensitive = true + description = "The API key used to access the Octopus server. See https://octopus.com/docs/octopus-rest-api/how-to-create-an-api-key for details on creating an API key." +} +variable "octopus_space_id" { + type = string + nullable = false + sensitive = false + description = "The space ID to populate" +} diff --git a/test/terraform/33-sha1certificates/space.tf b/test/terraform/33-sha1certificates/space.tf new file mode 100644 index 0000000..ee59bdc --- /dev/null +++ b/test/terraform/33-sha1certificates/space.tf @@ -0,0 +1,3 @@ +output "octopus_space_id" { + value = var.octopus_space_id +} diff --git a/test/terraform/33-sha1certificates/targets.tf b/test/terraform/33-sha1certificates/targets.tf new file mode 100644 index 0000000..49f7143 --- /dev/null +++ b/test/terraform/33-sha1certificates/targets.tf @@ -0,0 +1,32 @@ +data "octopusdeploy_machine_policies" "default_machine_policy" { + ids = null + partial_name = "Default Machine Policy" + skip = 0 + take = 1 +} + +data "octopusdeploy_worker_pools" "workerpool_default" { + name = "Default Worker Pool" + ids = null + skip = 0 + take = 1 +} + +resource "octopusdeploy_listening_tentacle_deployment_target" "target_example" { + environments = ["${octopusdeploy_environment.development_environment.id}"] + is_disabled = true + machine_policy_id = "${data.octopusdeploy_machine_policies.default_machine_policy.machine_policies[0].id}" + name = "sha1 certificate test target" + roles = ["sha1cert-app"] + tenanted_deployment_participation = "Untenanted" + tentacle_url = "https://target-example.com:1234/" + thumbprint = "96203ED84246201C26A2F4360D7CBC36AC1D232D" +} + +resource "octopusdeploy_listening_tentacle_worker" "worker_example" { + name = "sha1 listening_worker" + machine_policy_id = "${data.octopusdeploy_machine_policies.default_machine_policy.machine_policies[0].id}" + worker_pool_ids = [ "${data.octopusdeploy_worker_pools.workerpool_default.worker_pools[0].id}" ] + thumbprint = "96203ED84246201C26A2F4360D7CBC36AC1D232C" + uri = "https://worker-example.com:1234/" +} \ No newline at end of file