diff --git a/cmd/cli/app/profile/status/testdata/status_get.yaml.golden b/cmd/cli/app/profile/status/testdata/status_get.yaml.golden index f4f2761aa6..017b3b67d1 100644 --- a/cmd/cli/app/profile/status/testdata/status_get.yaml.golden +++ b/cmd/cli/app/profile/status/testdata/status_get.yaml.golden @@ -1,39 +1,39 @@ profileStatus: - lastUpdated: "2024-01-01T00:00:00Z" - profileId: 11111111-1111-1111-1111-111111111111 - profileName: mock-profile - profileStatus: success -ruleEvaluationStatus: - - details: Mock rule evaluation succeeded. - entity: repository - entityInfo: - entity_type: repository - name: acme-corp/mock-repo - provider: github-app-mock-provider - repo_name: mock-repo - repo_owner: acme-corp - repository_id: 22222222-2222-2222-2222-222222222222 - lastUpdated: "2024-01-01T00:00:00Z" - profileId: 11111111-1111-1111-1111-111111111111 - ruleDisplayName: Enable secret scanning to detect hardcoded secrets - ruleId: mock-rule-123 - ruleName: secret_scanning - ruleTypeName: secret_scanning - status: success - - details: Mock rule evaluation failed. - entity: repository - entityInfo: - entity_type: repository - name: acme-corp/mock-repo - provider: github-app-mock-provider - repo_name: mock-repo - repo_owner: acme-corp - repository_id: 22222222-2222-2222-2222-222222222222 lastUpdated: "2024-01-01T00:00:00Z" profileId: 11111111-1111-1111-1111-111111111111 - ruleDisplayName: Enable CodeQL for vulnerability scanning - ruleId: mock-rule-456 - ruleName: codeql_enabled - ruleTypeName: codeql_enabled - status: failure + profileName: mock-profile + profileStatus: success +ruleEvaluationStatus: + - details: Mock rule evaluation succeeded. + entity: repository + entityInfo: + entity_type: repository + name: acme-corp/mock-repo + provider: github-app-mock-provider + repo_name: mock-repo + repo_owner: acme-corp + repository_id: 22222222-2222-2222-2222-222222222222 + lastUpdated: "2024-01-01T00:00:00Z" + profileId: 11111111-1111-1111-1111-111111111111 + ruleDisplayName: Enable secret scanning to detect hardcoded secrets + ruleId: mock-rule-123 + ruleName: secret_scanning + ruleTypeName: secret_scanning + status: success + - details: Mock rule evaluation failed. + entity: repository + entityInfo: + entity_type: repository + name: acme-corp/mock-repo + provider: github-app-mock-provider + repo_name: mock-repo + repo_owner: acme-corp + repository_id: 22222222-2222-2222-2222-222222222222 + lastUpdated: "2024-01-01T00:00:00Z" + profileId: 11111111-1111-1111-1111-111111111111 + ruleDisplayName: Enable CodeQL for vulnerability scanning + ruleId: mock-rule-456 + ruleName: codeql_enabled + ruleTypeName: codeql_enabled + status: failure diff --git a/cmd/cli/app/profile/status/testdata/status_list.yaml.golden b/cmd/cli/app/profile/status/testdata/status_list.yaml.golden index f4f2761aa6..017b3b67d1 100644 --- a/cmd/cli/app/profile/status/testdata/status_list.yaml.golden +++ b/cmd/cli/app/profile/status/testdata/status_list.yaml.golden @@ -1,39 +1,39 @@ profileStatus: - lastUpdated: "2024-01-01T00:00:00Z" - profileId: 11111111-1111-1111-1111-111111111111 - profileName: mock-profile - profileStatus: success -ruleEvaluationStatus: - - details: Mock rule evaluation succeeded. - entity: repository - entityInfo: - entity_type: repository - name: acme-corp/mock-repo - provider: github-app-mock-provider - repo_name: mock-repo - repo_owner: acme-corp - repository_id: 22222222-2222-2222-2222-222222222222 - lastUpdated: "2024-01-01T00:00:00Z" - profileId: 11111111-1111-1111-1111-111111111111 - ruleDisplayName: Enable secret scanning to detect hardcoded secrets - ruleId: mock-rule-123 - ruleName: secret_scanning - ruleTypeName: secret_scanning - status: success - - details: Mock rule evaluation failed. - entity: repository - entityInfo: - entity_type: repository - name: acme-corp/mock-repo - provider: github-app-mock-provider - repo_name: mock-repo - repo_owner: acme-corp - repository_id: 22222222-2222-2222-2222-222222222222 lastUpdated: "2024-01-01T00:00:00Z" profileId: 11111111-1111-1111-1111-111111111111 - ruleDisplayName: Enable CodeQL for vulnerability scanning - ruleId: mock-rule-456 - ruleName: codeql_enabled - ruleTypeName: codeql_enabled - status: failure + profileName: mock-profile + profileStatus: success +ruleEvaluationStatus: + - details: Mock rule evaluation succeeded. + entity: repository + entityInfo: + entity_type: repository + name: acme-corp/mock-repo + provider: github-app-mock-provider + repo_name: mock-repo + repo_owner: acme-corp + repository_id: 22222222-2222-2222-2222-222222222222 + lastUpdated: "2024-01-01T00:00:00Z" + profileId: 11111111-1111-1111-1111-111111111111 + ruleDisplayName: Enable secret scanning to detect hardcoded secrets + ruleId: mock-rule-123 + ruleName: secret_scanning + ruleTypeName: secret_scanning + status: success + - details: Mock rule evaluation failed. + entity: repository + entityInfo: + entity_type: repository + name: acme-corp/mock-repo + provider: github-app-mock-provider + repo_name: mock-repo + repo_owner: acme-corp + repository_id: 22222222-2222-2222-2222-222222222222 + lastUpdated: "2024-01-01T00:00:00Z" + profileId: 11111111-1111-1111-1111-111111111111 + ruleDisplayName: Enable CodeQL for vulnerability scanning + ruleId: mock-rule-456 + ruleName: codeql_enabled + ruleTypeName: codeql_enabled + status: failure diff --git a/cmd/cli/app/profile/testdata/list_profiles.yaml.golden b/cmd/cli/app/profile/testdata/list_profiles.yaml.golden index d9290ed847..9005e269dd 100644 --- a/cmd/cli/app/profile/testdata/list_profiles.yaml.golden +++ b/cmd/cli/app/profile/testdata/list_profiles.yaml.golden @@ -1,40 +1,40 @@ --- -alert: "on" -artifact: - - def: - is_signed: true - is_verified: true - name: Mock ensure artifacts are signed - params: - name: mock-artifact - tags: - - latest - type: artifact_signature +name: mock-artifact-profile context: - project: 00000000-0000-0000-0000-000000000000 -displayName: Mock Artifact Signature Profile + project: 00000000-0000-0000-0000-000000000000 id: 11111111-1111-1111-1111-111111111111 -name: mock-artifact-profile +displayName: Mock Artifact Signature Profile remediate: "off" +alert: "on" +artifact: + - def: + is_signed: true + is_verified: true + name: Mock ensure artifacts are signed + params: + name: mock-artifact + tags: + - latest + type: artifact_signature --- -alert: "off" +name: mock-branch-protection context: - project: 00000000-0000-0000-0000-000000000000 -displayName: Mock Branch Protection Profile + project: 00000000-0000-0000-0000-000000000000 id: 22222222-2222-2222-2222-222222222222 -name: mock-branch-protection +displayName: Mock Branch Protection Profile remediate: "on" +alert: "off" repository: - - def: {} - name: Mock enable branch protection - params: - branch: main - type: branch_protection_enabled - - def: - required_approving_review_count: 2 - name: Mock require 2 reviews - params: - branch: main - type: branch_protection_require_pull_request_approving_review_count + - def: {} + name: Mock enable branch protection + params: + branch: main + type: branch_protection_enabled + - def: + required_approving_review_count: 2 + name: Mock require 2 reviews + params: + branch: main + type: branch_protection_require_pull_request_approving_review_count diff --git a/cmd/cli/app/ruletype/testdata/get_by_name.yaml.golden b/cmd/cli/app/ruletype/testdata/get_by_name.yaml.golden index 43b7b7b338..7d69ddbe30 100644 --- a/cmd/cli/app/ruletype/testdata/get_by_name.yaml.golden +++ b/cmd/cli/app/ruletype/testdata/get_by_name.yaml.golden @@ -1,72 +1,72 @@ +name: secret_push_protection context: - project: 00000000-0000-0000-0000-000000000000 - provider: "" -def: - alert: - securityAdvisory: {} - type: security_advisory - eval: - rego: - def: | - package minder + project: 00000000-0000-0000-0000-000000000000 + provider: "" +id: 00000000-0000-0000-0000-000000000001 +displayName: Enable secret push protection to avoid pushing hardcoded secrets +shortFailureMessage: Secret push protection is not enabled +description: | + Verifies that secret push protection is enabled for a given repository. + Note that this will will not work as expected for private repositories + unless you have GitHub Advanced Security enabled. If you still want to use + this rule because you have a mixture of private and public repositories, + enable the `skip_private_repos` flag. +guidance: | + Ensure that secret scanning push protection is enabled for the + repository. - import future.keywords.if + You can use secret scanning to prevent supported secrets from being + pushed into your repository by enabling secret scanning push + protection. - default allow := false - default skip := false - default message := "Secret push protection is disabled" + For more information, see [GitHub's + documentation](https://docs.github.com/en/code-security/secret-scanning/push-protection-for-repositories-and-organizations#enabling-secret-scanning-as-a-push-protection-for-a-repository). +severity: + value: VALUE_HIGH +releasePhase: RULE_TYPE_RELEASE_PHASE_BETA +def: + alert: + securityAdvisory: {} + type: security_advisory + eval: + rego: + def: | + package minder - allow if { - input.ingested.security_and_analysis.secret_scanning_push_protection.status == "enabled" - } + import future.keywords.if - skip if { - input.profile.skip_private_repos == true - input.ingested.private == true - } - type: deny-by-default - type: rego - inEntity: repository - ingest: - rest: - endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} - parse: json - type: rest - remediate: - rest: - body: | - { "security_and_analysis": {"secret_scanning_push_protection": { "status": "enabled" } } } - endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} - method: PATCH - type: rest - ruleSchema: - properties: - skip_private_repos: - default: true - description: | - If true, this rule will be marked as skipped for private repositories - type: boolean -description: | - Verifies that secret push protection is enabled for a given repository. - Note that this will will not work as expected for private repositories - unless you have GitHub Advanced Security enabled. If you still want to use - this rule because you have a mixture of private and public repositories, - enable the `skip_private_repos` flag. -displayName: Enable secret push protection to avoid pushing hardcoded secrets -guidance: | - Ensure that secret scanning push protection is enabled for the - repository. + default allow := false + default skip := false + default message := "Secret push protection is disabled" - You can use secret scanning to prevent supported secrets from being - pushed into your repository by enabling secret scanning push - protection. + allow if { + input.ingested.security_and_analysis.secret_scanning_push_protection.status == "enabled" + } - For more information, see [GitHub's - documentation](https://docs.github.com/en/code-security/secret-scanning/push-protection-for-repositories-and-organizations#enabling-secret-scanning-as-a-push-protection-for-a-repository). -id: 00000000-0000-0000-0000-000000000001 -name: secret_push_protection -releasePhase: RULE_TYPE_RELEASE_PHASE_BETA -severity: - value: VALUE_HIGH -shortFailureMessage: Secret push protection is not enabled + skip if { + input.profile.skip_private_repos == true + input.ingested.private == true + } + type: deny-by-default + type: rego + inEntity: repository + ingest: + rest: + endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} + parse: json + type: rest + remediate: + rest: + body: | + { "security_and_analysis": {"secret_scanning_push_protection": { "status": "enabled" } } } + endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} + method: PATCH + type: rest + ruleSchema: + properties: + skip_private_repos: + default: true + description: | + If true, this rule will be marked as skipped for private repositories + type: boolean diff --git a/cmd/cli/app/ruletype/testdata/list_populated.yaml.golden b/cmd/cli/app/ruletype/testdata/list_populated.yaml.golden index 4cb5a2e023..363eee5861 100644 --- a/cmd/cli/app/ruletype/testdata/list_populated.yaml.golden +++ b/cmd/cli/app/ruletype/testdata/list_populated.yaml.golden @@ -1,185 +1,185 @@ --- +name: secret_push_protection context: - project: 00000000-0000-0000-0000-000000000000 - provider: "" -def: - alert: - securityAdvisory: {} - type: security_advisory - eval: - rego: - def: | - package minder - - import future.keywords.if - - default allow := false - default skip := false - default message := "Secret push protection is disabled" - - allow if { - input.ingested.security_and_analysis.secret_scanning_push_protection.status == "enabled" - } - - skip if { - input.profile.skip_private_repos == true - input.ingested.private == true - } - type: deny-by-default - type: rego - inEntity: repository - ingest: - rest: - endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} - parse: json - type: rest - remediate: - rest: - body: | - { "security_and_analysis": {"secret_scanning_push_protection": { "status": "enabled" } } } - endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} - method: PATCH - type: rest - ruleSchema: - properties: - skip_private_repos: - default: true - description: | - If true, this rule will be marked as skipped for private repositories - type: boolean -description: | - Verifies that secret push protection is enabled for a given repository. - Note that this will will not work as expected for private repositories - unless you have GitHub Advanced Security enabled. If you still want to use - this rule because you have a mixture of private and public repositories, - enable the `skip_private_repos` flag. + project: 00000000-0000-0000-0000-000000000000 + provider: "" +id: 00000000-0000-0000-0000-000000000001 displayName: Enable secret push protection to avoid pushing hardcoded secrets +shortFailureMessage: Secret push protection is not enabled +description: | + Verifies that secret push protection is enabled for a given repository. + Note that this will will not work as expected for private repositories + unless you have GitHub Advanced Security enabled. If you still want to use + this rule because you have a mixture of private and public repositories, + enable the `skip_private_repos` flag. guidance: | - Ensure that secret scanning push protection is enabled for the - repository. + Ensure that secret scanning push protection is enabled for the + repository. - You can use secret scanning to prevent supported secrets from being - pushed into your repository by enabling secret scanning push - protection. + You can use secret scanning to prevent supported secrets from being + pushed into your repository by enabling secret scanning push + protection. - For more information, see [GitHub's - documentation](https://docs.github.com/en/code-security/secret-scanning/push-protection-for-repositories-and-organizations#enabling-secret-scanning-as-a-push-protection-for-a-repository). -id: 00000000-0000-0000-0000-000000000001 -name: secret_push_protection -releasePhase: RULE_TYPE_RELEASE_PHASE_BETA + For more information, see [GitHub's + documentation](https://docs.github.com/en/code-security/secret-scanning/push-protection-for-repositories-and-organizations#enabling-secret-scanning-as-a-push-protection-for-a-repository). severity: - value: VALUE_HIGH -shortFailureMessage: Secret push protection is not enabled + value: VALUE_HIGH +releasePhase: RULE_TYPE_RELEASE_PHASE_BETA +def: + alert: + securityAdvisory: {} + type: security_advisory + eval: + rego: + def: | + package minder + + import future.keywords.if + + default allow := false + default skip := false + default message := "Secret push protection is disabled" + + allow if { + input.ingested.security_and_analysis.secret_scanning_push_protection.status == "enabled" + } + + skip if { + input.profile.skip_private_repos == true + input.ingested.private == true + } + type: deny-by-default + type: rego + inEntity: repository + ingest: + rest: + endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} + parse: json + type: rest + remediate: + rest: + body: | + { "security_and_analysis": {"secret_scanning_push_protection": { "status": "enabled" } } } + endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} + method: PATCH + type: rest + ruleSchema: + properties: + skip_private_repos: + default: true + description: | + If true, this rule will be marked as skipped for private repositories + type: boolean --- +name: secret_scanning context: - project: 00000000-0000-0000-0000-000000000000 - provider: "" -def: - alert: - securityAdvisory: {} - type: security_advisory - eval: - rego: - def: | - package minder - - import future.keywords.if - - default allow := false - default skip := false - default message := "Secret scanning is disabled" - - allow if { - input.ingested.security_and_analysis.secret_scanning.status == "enabled" - } - - skip if { - input.profile.skip_private_repos == true - input.ingested.private == true - } - type: deny-by-default - type: rego - inEntity: repository - ingest: - rest: - endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} - parse: json - type: rest - remediate: - rest: - body: | - { "security_and_analysis": {"secret_scanning": { "status": "enabled" } } } - endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} - method: PATCH - type: rest - ruleSchema: - properties: - skip_private_repos: - default: true - description: | - If true, this rule will be marked as skipped for private repositories - type: boolean -description: | - Verifies that secret scanning is enabled for a given repository. - Note that this will will not work as expected for private repositories - unless you have GitHub Advanced Security enabled. If you still want to use - this rule because you have a mixture of private and public repositories, - enable the `skip_private_repos` flag. + project: 00000000-0000-0000-0000-000000000000 + provider: "" +id: 00000000-0000-0000-0000-000000000002 displayName: Enable secret scanning to detect hardcoded secrets +shortFailureMessage: Secret scanning is not enabled +description: | + Verifies that secret scanning is enabled for a given repository. + Note that this will will not work as expected for private repositories + unless you have GitHub Advanced Security enabled. If you still want to use + this rule because you have a mixture of private and public repositories, + enable the `skip_private_repos` flag. guidance: | - Ensure that secret scanning is enabled for the repository. + Ensure that secret scanning is enabled for the repository. - Secret scanning is a feature that scans repositories for secrets and - alerts the repository owner when a secret is found. To enable this - feature in GitHub, you must enable it in the repository settings. + Secret scanning is a feature that scans repositories for secrets and + alerts the repository owner when a secret is found. To enable this + feature in GitHub, you must enable it in the repository settings. - For more information, see [GitHub's - documentation](https://docs.github.com/en/github/administering-a-repository/about-secret-scanning). -id: 00000000-0000-0000-0000-000000000002 -name: secret_scanning -releasePhase: RULE_TYPE_RELEASE_PHASE_BETA + For more information, see [GitHub's + documentation](https://docs.github.com/en/github/administering-a-repository/about-secret-scanning). severity: - value: VALUE_HIGH -shortFailureMessage: Secret scanning is not enabled + value: VALUE_HIGH +releasePhase: RULE_TYPE_RELEASE_PHASE_BETA +def: + alert: + securityAdvisory: {} + type: security_advisory + eval: + rego: + def: | + package minder + + import future.keywords.if + + default allow := false + default skip := false + default message := "Secret scanning is disabled" + + allow if { + input.ingested.security_and_analysis.secret_scanning.status == "enabled" + } + + skip if { + input.profile.skip_private_repos == true + input.ingested.private == true + } + type: deny-by-default + type: rego + inEntity: repository + ingest: + rest: + endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} + parse: json + type: rest + remediate: + rest: + body: | + { "security_and_analysis": {"secret_scanning": { "status": "enabled" } } } + endpoint: /repos/{{.Entity.Owner}}/{{.Entity.Name}} + method: PATCH + type: rest + ruleSchema: + properties: + skip_private_repos: + default: true + description: | + If true, this rule will be marked as skipped for private repositories + type: boolean --- +name: test_base_file_rule context: - project: 00000000-0000-0000-0000-000000000000 - provider: "" -def: - alert: - securityAdvisory: {} - type: security_advisory - eval: - rego: - def: | - package minder - - default allow := false - - allow { - base_file.exists("README.md") - } - type: deny-by-default - type: rego - inEntity: repository - ingest: - git: {} - type: git - ruleSchema: - properties: {} - required: [] - type: object -description: | - Checks whether README.md exists in the repository. + project: 00000000-0000-0000-0000-000000000000 + provider: "" +id: 00000000-0000-0000-0000-000000000003 displayName: Check README exists +shortFailureMessage: README file not found +description: | + Checks whether README.md exists in the repository. guidance: | - Ensure that your repository contains a README.md file. -id: 00000000-0000-0000-0000-000000000003 -name: test_base_file_rule -releasePhase: RULE_TYPE_RELEASE_PHASE_ALPHA + Ensure that your repository contains a README.md file. severity: - value: VALUE_LOW -shortFailureMessage: README file not found + value: VALUE_LOW +releasePhase: RULE_TYPE_RELEASE_PHASE_ALPHA +def: + alert: + securityAdvisory: {} + type: security_advisory + eval: + rego: + def: | + package minder + + default allow := false + + allow { + base_file.exists("README.md") + } + type: deny-by-default + type: rego + inEntity: repository + ingest: + git: {} + type: git + ruleSchema: + properties: {} + required: [] + type: object diff --git a/cmd/smoketest/main.go b/cmd/smoketest/main.go new file mode 100644 index 0000000000..9b4445c889 --- /dev/null +++ b/cmd/smoketest/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/mindersec/minder/internal/util" + pb "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" +) + +func section(title string) { + fmt.Printf("\n%s\n%s\n", title, strings.Repeat("─", len(title))) +} + +func strPtr(s string) *string { return &s } + +func main() { + project := "my-project" + + section("./bin/minder profile get --output yaml") + p := &pb.Profile{ + Version: "v1", + Type: "profile", + Name: "secret-scanning", + Context: &pb.Context{Project: &project}, + Alert: strPtr("on"), + Remediate: strPtr("off"), + } + out, _ := util.GetOrderedYamlFromProto(p) + fmt.Print(out) + + section("./bin/minder ruletype get --output yaml") + rt := &pb.RuleType{ + Version: "v1", + Type: "rule-type", + Name: "secret_scanning", + Context: &pb.Context{Project: &project}, + Description: "Verifies secret scanning is enabled.", + Guidance: "Enable secret scanning in repo settings.", + } + out, _ = util.GetOrderedYamlFromProto(rt) + fmt.Print(out) + + section("./bin/minder datasource get --output yaml") + ds := &pb.DataSource{ + Version: "v1", + Type: "data-source", + Name: "github-api", + Context: &pb.ContextV2{ProjectId: project}, + } + out, _ = util.GetOrderedYamlFromProto(ds) + fmt.Print(out) +} diff --git a/internal/util/helpers.go b/internal/util/helpers.go index c8b5215bb1..f74cbc6ca0 100644 --- a/internal/util/helpers.go +++ b/internal/util/helpers.go @@ -5,7 +5,6 @@ package util import ( - "encoding/json" "fmt" "io" "math" @@ -17,8 +16,6 @@ import ( "github.com/rs/zerolog" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" - - "github.com/mindersec/minder/internal/util/jsonyaml" ) var ( @@ -57,26 +54,15 @@ func GetJsonFromProto(msg proto.Message) (string, error) { return string(out), nil } -// GetYamlFromProto given a proto message, formats into yaml +// GetYamlFromProto given a proto message, formats into yaml. +// For the three Minder resource types (Profile, RuleType, DataSource) the +// fields are emitted in the standard community order: +// +// version → type → name → context → (descriptive metadata) → deep structure +// +// All other message types fall back to alphabetical key order. func GetYamlFromProto(msg proto.Message) (string, error) { - // first converts into json using the marshal options - m := getProtoMarshalOptions() - out, err := m.Marshal(msg) - if err != nil { - return "", err - } - - // from byte, we get the raw message so we can convert into yaml - var rawMsg json.RawMessage - err = json.Unmarshal(out, &rawMsg) - if err != nil { - return "", err - } - yamlResult, err := jsonyaml.ConvertJsonToYaml(rawMsg) - if err != nil { - return "", err - } - return yamlResult, nil + return GetOrderedYamlFromProto(msg) } // GetBytesFromProto given a proto message, formats into bytes diff --git a/internal/util/yaml_order.go b/internal/util/yaml_order.go new file mode 100644 index 0000000000..2ba8b78d51 --- /dev/null +++ b/internal/util/yaml_order.go @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "encoding/json" + "fmt" + "sort" + + "google.golang.org/protobuf/proto" + "gopkg.in/yaml.v3" +) + +// Standard Minder resource proto full names — used to dispatch ordered +// YAML marshaling without importing the v1 package (which would create +// an import cycle since v1 already imports internal/util). +const ( + profileFullName = "minder.v1.Profile" + ruleTypeFullName = "minder.v1.RuleType" + dataSourceFullName = "minder.v1.DataSource" +) + +// orderedProfile mirrors minderv1.Profile with fields in the standard +// Minder field order: version, type, name, context, … deep structure last. +// camelCase yaml tags match the protojson default output used by GetYamlFromProto. +type orderedProfile struct { + Version interface{} `yaml:"version,omitempty"` + Type interface{} `yaml:"type,omitempty"` + Name interface{} `yaml:"name,omitempty"` + Context interface{} `yaml:"context,omitempty"` + Id interface{} `yaml:"id,omitempty"` + DisplayName interface{} `yaml:"displayName,omitempty"` + Labels interface{} `yaml:"labels,omitempty"` + Remediate interface{} `yaml:"remediate,omitempty"` + Alert interface{} `yaml:"alert,omitempty"` + // entity rule lists + Repository interface{} `yaml:"repository,omitempty"` + BuildEnvironment interface{} `yaml:"buildEnvironment,omitempty"` + Artifact interface{} `yaml:"artifact,omitempty"` + PullRequest interface{} `yaml:"pullRequest,omitempty"` + Release interface{} `yaml:"release,omitempty"` + PipelineRun interface{} `yaml:"pipelineRun,omitempty"` + TaskRun interface{} `yaml:"taskRun,omitempty"` + Build interface{} `yaml:"build,omitempty"` + Selection interface{} `yaml:"selection,omitempty"` +} + +// orderedRuleType mirrors minderv1.RuleType with fields in the standard +// Minder field order. camelCase yaml tags match protojson default output. +type orderedRuleType struct { + Version interface{} `yaml:"version,omitempty"` + Type interface{} `yaml:"type,omitempty"` + Name interface{} `yaml:"name,omitempty"` + Context interface{} `yaml:"context,omitempty"` + Id interface{} `yaml:"id,omitempty"` + DisplayName interface{} `yaml:"displayName,omitempty"` + ShortFailureMessage interface{} `yaml:"shortFailureMessage,omitempty"` + Description interface{} `yaml:"description,omitempty"` + Guidance interface{} `yaml:"guidance,omitempty"` + Severity interface{} `yaml:"severity,omitempty"` + ReleasePhase interface{} `yaml:"releasePhase,omitempty"` + Def interface{} `yaml:"def,omitempty"` +} + +// orderedDataSource mirrors minderv1.DataSource with fields in the standard +// Minder field order. camelCase yaml tags match protojson default output. +type orderedDataSource struct { + Version interface{} `yaml:"version,omitempty"` + Type interface{} `yaml:"type,omitempty"` + Name interface{} `yaml:"name,omitempty"` + Context interface{} `yaml:"context,omitempty"` + Id interface{} `yaml:"id,omitempty"` + Rest interface{} `yaml:"rest,omitempty"` + Structured interface{} `yaml:"structured,omitempty"` +} + +// GetOrderedYamlFromProto serializes a proto.Message to YAML with fields in +// the standard Minder order (version, type, name, context, …) for the three +// resource types (Profile, RuleType, DataSource). For all other message types +// it falls back to the default (alphabetical) ordering. +func GetOrderedYamlFromProto(msg proto.Message) (string, error) { + if msg == nil { + return "{}\n", nil + } + + m := getProtoMarshalOptions() + jsonBytes, err := m.Marshal(msg) + if err != nil { + return "", fmt.Errorf("marshaling proto to JSON: %w", err) + } + + var raw map[string]interface{} + if err := json.Unmarshal(jsonBytes, &raw); err != nil { + return "", fmt.Errorf("unmarshaling JSON: %w", err) + } + + protoName := string(msg.ProtoReflect().Descriptor().FullName()) + + var ordered interface{} + switch protoName { + case profileFullName: + ordered = mapToOrderedProfile(raw) + case ruleTypeFullName: + ordered = mapToOrderedRuleType(raw) + case dataSourceFullName: + ordered = mapToOrderedDataSource(raw) + default: + // Fall back to alphabetical (previous behaviour). + node, err := mapToSortedYAMLNode(raw) + if err != nil { + return "", err + } + out, err := yaml.Marshal(node) + if err != nil { + return "", err + } + return string(out), nil + } + + out, err := yaml.Marshal(ordered) + if err != nil { + return "", fmt.Errorf("marshaling ordered struct to YAML: %w", err) + } + return string(out), nil +} + +// mapToOrderedProfile fills an orderedProfile from the JSON-decoded map. +func mapToOrderedProfile(m map[string]interface{}) orderedProfile { + return orderedProfile{ + Version: m["version"], + Type: m["type"], + Name: m["name"], + Context: m["context"], + Id: m["id"], + DisplayName: m["displayName"], + Labels: m["labels"], + Remediate: m["remediate"], + Alert: m["alert"], + Repository: m["repository"], + BuildEnvironment: m["buildEnvironment"], + Artifact: m["artifact"], + PullRequest: m["pullRequest"], + Release: m["release"], + PipelineRun: m["pipelineRun"], + TaskRun: m["taskRun"], + Build: m["build"], + Selection: m["selection"], + } +} + +// mapToOrderedRuleType fills an orderedRuleType from the JSON-decoded map. +func mapToOrderedRuleType(m map[string]interface{}) orderedRuleType { + return orderedRuleType{ + Version: m["version"], + Type: m["type"], + Name: m["name"], + Context: m["context"], + Id: m["id"], + DisplayName: m["displayName"], + ShortFailureMessage: m["shortFailureMessage"], + Description: m["description"], + Guidance: m["guidance"], + Severity: m["severity"], + ReleasePhase: m["releasePhase"], + Def: m["def"], + } +} + +// mapToOrderedDataSource fills an orderedDataSource from the JSON-decoded map. +func mapToOrderedDataSource(m map[string]interface{}) orderedDataSource { + return orderedDataSource{ + Version: m["version"], + Type: m["type"], + Name: m["name"], + Context: m["context"], + Id: m["id"], + Rest: m["rest"], + Structured: m["structured"], + } +} + +// mapToSortedYAMLNode converts a map to a yaml.MappingNode with +// alphabetically-sorted keys (the previous default behaviour). +func mapToSortedYAMLNode(m map[string]interface{}) (*yaml.Node, error) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for _, k := range keys { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: k} + valNode := &yaml.Node{} + if err := valNode.Encode(m[k]); err != nil { + return nil, err + } + if valNode.Kind == yaml.DocumentNode && len(valNode.Content) > 0 { + valNode = valNode.Content[0] + } + mapping.Content = append(mapping.Content, keyNode, valNode) + } + return mapping, nil +} diff --git a/internal/util/yaml_order_test.go b/internal/util/yaml_order_test.go new file mode 100644 index 0000000000..8dcd24ccff --- /dev/null +++ b/internal/util/yaml_order_test.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright 2025 The Minder Authors +// SPDX-License-Identifier: Apache-2.0 + +package util_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mindersec/minder/internal/util" + pb "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1" +) + +func TestGetOrderedYamlFromProto_Profile(t *testing.T) { + t.Parallel() + + p := &pb.Profile{ + Version: "v1", + Type: "profile", + Name: "my-profile", + } + + out, err := util.GetOrderedYamlFromProto(p) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out), "\n") + require.GreaterOrEqual(t, len(lines), 3, "expected at least 3 lines of YAML output") + assert.True(t, strings.HasPrefix(lines[0], "version:"), "first field should be version, got: %s", lines[0]) + assert.True(t, strings.HasPrefix(lines[1], "type:"), "second field should be type, got: %s", lines[1]) + assert.True(t, strings.HasPrefix(lines[2], "name:"), "third field should be name, got: %s", lines[2]) +} + +func TestGetOrderedYamlFromProto_RuleType(t *testing.T) { + t.Parallel() + + rt := &pb.RuleType{ + Version: "v1", + Type: "rule-type", + Name: "my-rule", + } + + out, err := util.GetOrderedYamlFromProto(rt) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out), "\n") + require.GreaterOrEqual(t, len(lines), 3, "expected at least 3 lines of YAML output") + assert.True(t, strings.HasPrefix(lines[0], "version:"), "first field should be version, got: %s", lines[0]) + assert.True(t, strings.HasPrefix(lines[1], "type:"), "second field should be type, got: %s", lines[1]) + assert.True(t, strings.HasPrefix(lines[2], "name:"), "third field should be name, got: %s", lines[2]) +} + +func TestGetOrderedYamlFromProto_DataSource(t *testing.T) { + t.Parallel() + + ds := &pb.DataSource{ + Version: "v1", + Type: "data-source", + Name: "my-datasource", + } + + out, err := util.GetOrderedYamlFromProto(ds) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out), "\n") + require.GreaterOrEqual(t, len(lines), 3, "expected at least 3 lines of YAML output") + assert.True(t, strings.HasPrefix(lines[0], "version:"), "first field should be version, got: %s", lines[0]) + assert.True(t, strings.HasPrefix(lines[1], "type:"), "second field should be type, got: %s", lines[1]) + assert.True(t, strings.HasPrefix(lines[2], "name:"), "third field should be name, got: %s", lines[2]) +} + +func TestGetOrderedYamlFromProto_NonResourceFallsBackToAlpha(t *testing.T) { + t.Parallel() + + // Repository is not a resource type — should fall back to alphabetical order (name before owner) + repo := &pb.Repository{ + Owner: "my-org", + Name: "my-repo", + } + + out, err := util.GetOrderedYamlFromProto(repo) + require.NoError(t, err) + assert.NotEmpty(t, out) + // alphabetical: "name" comes before "owner" + lines := strings.Split(strings.TrimSpace(out), "\n") + assert.True(t, strings.HasPrefix(lines[0], "name:"), "non-resource type should be alphabetical, got: %s", lines[0]) +} + +func TestGetOrderedYamlFromProto_NilReturnsEmptyObject(t *testing.T) { + t.Parallel() + + out, err := util.GetOrderedYamlFromProto(nil) + require.NoError(t, err) + assert.Equal(t, "{}\n", out) +} + +func TestGetOrderedYamlFromProto_ContextComesAfterName(t *testing.T) { + t.Parallel() + + project := "proj-123" + p := &pb.Profile{ + Version: "v1", + Type: "profile", + Name: "test-profile", + Context: &pb.Context{Project: &project}, + } + + out, err := util.GetOrderedYamlFromProto(p) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out), "\n") + assert.True(t, strings.HasPrefix(lines[0], "version:"), "line 0: %s", lines[0]) + assert.True(t, strings.HasPrefix(lines[1], "type:"), "line 1: %s", lines[1]) + assert.True(t, strings.HasPrefix(lines[2], "name:"), "line 2: %s", lines[2]) + assert.True(t, strings.HasPrefix(lines[3], "context:"), "line 3: %s", lines[3]) +}