diff --git a/.gitignore b/.gitignore index 4907d58..d2ba41c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # ignore the generated artifacts pvtr-github-repo github-repo +badge-url evaluation_results # ignore any local dev config file diff --git a/README.md b/README.md index 5a6dcfe..a857f4a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,11 @@ docker run \ See the [OSPS Security Baseline Scanner](https://github.com/marketplace/actions/open-source-project-security-baseline-scanner) +## Best Practices Badge Integration + +To use scan results with the OpenSSF Best Practices Badge, see the user guide in +[docs/best-practices-badge.md](docs/best-practices-badge.md). + ## Contributing Contributions are welcome! Please see our [Contributing Guidelines](.github/CONTRIBUTING.md) for more information. diff --git a/badgeurl/badgeurl.go b/badgeurl/badgeurl.go new file mode 100644 index 0000000..0c03ecf --- /dev/null +++ b/badgeurl/badgeurl.go @@ -0,0 +1,332 @@ +package badgeurl + +import ( + "fmt" + "net/url" + "os" + "sort" + "strings" + + "github.com/goccy/go-yaml" +) + +const ( + // DefaultBadge lets bestpractices.dev prompt the user to choose a target + // baseline section after opening the generated edit URL. + DefaultBadge = "choose" + defaultMaxURLLength = 2000 + defaultJustificationSize = 240 +) + +var ( + // These are the BPB sections this utility knows how to target from + // Privateer's current OSPS baseline mapping. + supportedBadgeSections = map[string]struct{}{ + "choose": {}, + "baseline-1": {}, + "baseline-2": {}, + "baseline-3": {}, + } +) + +// Options configures badge URL generation. +// +// IncludeJustifications is a tri-state field: nil applies the package default +// of including justifications, while non-nil values explicitly enable or +// disable them. +type Options struct { + Badge string + IncludeJustifications *bool +} + +type resultsFile struct { + Payload struct { + Config *payloadConfig `yaml:"config"` + RestData *struct { + Config *payloadConfig `yaml:"config"` + } `yaml:"restdata"` + } `yaml:"payload"` + EvaluationSuites []evaluationSuite `yaml:"evaluation-suites"` +} + +type payloadConfig struct { + Vars map[string]string `yaml:"vars"` +} + +type evaluationSuite struct { + ControlEvaluations controlEvaluations `yaml:"control-evaluations"` +} + +type controlEvaluations struct { + Evaluations []controlEvaluation `yaml:"evaluations"` +} + +type controlEvaluation struct { + AssessmentLogs []assessmentLog `yaml:"assessment-logs"` +} + +type assessmentLog struct { + Requirement struct { + EntryID string `yaml:"entry-id"` + } `yaml:"requirement"` + Result string `yaml:"result"` + Message string `yaml:"message"` + Applicability []string `yaml:"applicability"` +} + +type proposalUnit struct { + key string + encoded string +} + +// GenerateFromFile reads a serialized Privateer results file and returns one +// or more Best Practices Badge automation proposal URLs. +func GenerateFromFile(path string, options Options) ([]string, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read results file: %w", err) + } + + return Generate(content, options) +} + +// Generate converts a serialized Privateer results document into one or more +// Best Practices Badge automation-proposals URLs. +// Reference: https://github.com/coreinfrastructure/best-practices-badge/blob/main/docs/automation-proposals.md +func Generate(content []byte, options Options) ([]string, error) { + options = normalizeOptions(options) + if err := validateOptions(options); err != nil { + return nil, err + } + + var results resultsFile + if err := yaml.Unmarshal(content, &results); err != nil { + return nil, fmt.Errorf("parse results YAML: %w", err) + } + + repoURL, err := extractRepositoryURL(results) + if err != nil { + return nil, err + } + + units := collectProposalUnits(results, options) + if len(units) == 0 { + return nil, fmt.Errorf("no supported Best Practices Badge links could be generated from the results") + } + + baseURL := buildBaseURL(repoURL, options.Badge) + return buildURLs(baseURL, units) +} + +func normalizeOptions(options Options) Options { + if strings.TrimSpace(options.Badge) == "" { + options.Badge = DefaultBadge + } + if options.IncludeJustifications == nil { + options.IncludeJustifications = boolPtr(true) + } + return options +} + +func validateOptions(options Options) error { + if _, ok := supportedBadgeSections[options.Badge]; !ok { + return fmt.Errorf("invalid badge %q: must be one of choose, baseline-1, baseline-2, baseline-3", options.Badge) + } + return nil +} + +// extractRepositoryURL finds the repository identity that BPB uses to look up +// the target project before applying proposal fields. +func extractRepositoryURL(results resultsFile) (string, error) { + // Privateer results may carry config in more than one serialized payload + // location depending on how the scanner wrote the output. + configs := []*payloadConfig{ + results.Payload.Config, + } + if results.Payload.RestData != nil { + configs = append(configs, results.Payload.RestData.Config) + } + + for _, cfg := range configs { + if cfg == nil { + continue + } + owner := strings.TrimSpace(cfg.Vars["owner"]) + repo := strings.TrimSpace(cfg.Vars["repo"]) + if owner != "" && repo != "" { + return fmt.Sprintf("https://github.com/%s/%s", owner, repo), nil + } + } + + return "", fmt.Errorf("could not determine repository URL from results payload") +} + +// collectProposalUnits walks the evaluation logs, filters them to the target +// badge scope, and turns supported findings into stable query-string fragments. +func collectProposalUnits(results resultsFile, options Options) []proposalUnit { + allowedLevels := levelsForBadge(options.Badge) + seen := map[string]struct{}{} + units := make([]proposalUnit, 0) + + for _, suite := range results.EvaluationSuites { + for _, evaluation := range suite.ControlEvaluations.Evaluations { + for _, log := range evaluation.AssessmentLogs { + // Requirement IDs can appear more than once across suites. Keep the + // first supported occurrence so the output is stable and non-duplicated. + requirementID := strings.TrimSpace(log.Requirement.EntryID) + if requirementID == "" { + continue + } + if _, ok := seen[requirementID]; ok { + continue + } + if !isApplicable(log.Applicability, allowedLevels) { + continue + } + + status, ok := mapResult(log.Result) + if !ok { + continue + } + + key := badgeFieldName(requirementID) + parts := []string{fmt.Sprintf("%s_status=%s", key, url.QueryEscape(status))} + if *options.IncludeJustifications { + justification := sanitizeJustification(log.Message) + if justification != "" { + parts = append(parts, fmt.Sprintf("%s_justification=%s", key, url.QueryEscape(justification))) + } + } + + units = append(units, proposalUnit{ + key: key, + encoded: strings.Join(parts, "&"), + }) + seen[requirementID] = struct{}{} + } + } + } + + sort.Slice(units, func(i, j int) bool { + return units[i].key < units[j].key + }) + + return units +} + +// levelsForBadge maps a BPB section to the Privateer OSPS maturity levels +// whose findings should be included in the generated link. +func levelsForBadge(badge string) map[string]struct{} { + if badge == DefaultBadge { + // "choose" defers section choice to BPB, so include all applicable levels. + return nil + } + + levels := map[string]struct{}{} + for _, level := range []string{"Maturity Level 1", "Maturity Level 2", "Maturity Level 3"} { + levels[level] = struct{}{} + if badge == "baseline-1" && level == "Maturity Level 1" { + break + } + if badge == "baseline-2" && level == "Maturity Level 2" { + break + } + if badge == "baseline-3" && level == "Maturity Level 3" { + break + } + } + return levels +} + +func isApplicable(applicability []string, allowedLevels map[string]struct{}) bool { + if allowedLevels == nil || len(applicability) == 0 { + return true + } + for _, level := range applicability { + if _, ok := allowedLevels[level]; ok { + return true + } + } + return false +} + +// mapResult converts Privateer's control result vocabulary into BPB's status +// vocabulary and drops unsupported states. +func mapResult(result string) (string, bool) { + switch strings.TrimSpace(strings.ToLower(result)) { + case "passed": + return "Met", true + case "failed": + return "Unmet", true + case "notapplicable", "not applicable", "n/a": + return "N/A", true + default: + return "", false + } +} + +func badgeFieldName(requirementID string) string { + replacer := strings.NewReplacer("-", "_", ".", "_") + return strings.ToLower(replacer.Replace(requirementID)) +} + +// sanitizeJustification keeps reviewer context short and URL-safe so it can be +// embedded directly into a BPB proposal link. +func sanitizeJustification(message string) string { + cleaned := strings.TrimSpace(message) + if cleaned == "" { + return "" + } + // Keep reviewer context compact while preserving evidence details such as + // URLs; QueryEscape handles the actual URL encoding later. + cleaned = strings.ReplaceAll(cleaned, "\n", " ") + cleaned = strings.ReplaceAll(cleaned, "\r", " ") + cleaned = strings.Join(strings.Fields(cleaned), " ") + runes := []rune(cleaned) + if len(runes) > defaultJustificationSize { + cleaned = strings.TrimSpace(string(runes[:defaultJustificationSize])) + } + return cleaned +} + +func boolPtr(value bool) *bool { + return &value +} + +func buildBaseURL(repoURL string, badge string) string { + return fmt.Sprintf( + "https://www.bestpractices.dev/projects?as=edit§ion=%s&url=%s", + url.QueryEscape(badge), + url.QueryEscape(repoURL), + ) +} + +// buildURLs emits one link when it fits within the default URL budget and +// otherwise batches proposal fragments into multiple links that can be applied +// in order. +func buildURLs(baseURL string, units []proposalUnit) ([]string, error) { + urls := make([]string, 0, 1) + current := baseURL + hasUnits := false + + for _, unit := range units { + candidate := current + "&" + unit.encoded + if len(candidate) > defaultMaxURLLength { + if !hasUnits { + return nil, fmt.Errorf("a single Best Practices Badge proposal entry exceeds %d characters; disable justifications or shorten the source evidence text", defaultMaxURLLength) + } + urls = append(urls, current) + current = baseURL + "&" + unit.encoded + hasUnits = true + continue + } + current = candidate + hasUnits = true + } + + if hasUnits { + urls = append(urls, current) + } + + return urls, nil +} diff --git a/badgeurl/badgeurl_test.go b/badgeurl/badgeurl_test.go new file mode 100644 index 0000000..e0d61a2 --- /dev/null +++ b/badgeurl/badgeurl_test.go @@ -0,0 +1,373 @@ +package badgeurl + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGenerateSingleURL(t *testing.T) { + filePath := writeResultsFile(t, minimalResultsYAML()) + + urls, err := GenerateFromFile(filePath, Options{Badge: "baseline-1", IncludeJustifications: boolOption(true)}) + if err != nil { + t.Fatalf("GenerateFromFile returned error: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + + url := urls[0] + if !strings.Contains(url, "section=baseline-1") { + t.Fatalf("expected baseline section in URL: %s", url) + } + if !strings.Contains(url, "url=https%3A%2F%2Fgithub.com%2Facme%2Frocket") { + t.Fatalf("expected repository URL in URL: %s", url) + } + if !strings.Contains(url, "osps_ac_01_01_status=Met") { + t.Fatalf("expected mapped status in URL: %s", url) + } + if !strings.Contains(url, "osps_ac_01_01_justification=MFA+required") { + t.Fatalf("expected justification in URL: %s", url) + } + if strings.Contains(url, "osps_do_03_01") { + t.Fatalf("did not expect level 2 criterion in baseline-1 URL: %s", url) + } + if strings.Contains(url, "osps_do_04_01") { + t.Fatalf("did not expect NeedsReview criterion in URL: %s", url) + } +} + +func TestGenerateMultipleURLsAboveDefaultLengthLimit(t *testing.T) { + const proposalCount = 28 + filePath := writeResultsFile(t, longResultsYAML(28)) + + urls, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge, IncludeJustifications: boolOption(true)}) + if err != nil { + t.Fatalf("GenerateFromFile returned error: %v", err) + } + if len(urls) < 2 { + t.Fatalf("expected multiple URLs above the default length limit, got %d", len(urls)) + } + for _, generatedURL := range urls { + if len(generatedURL) > 2000 { + t.Fatalf("expected URL length <= %d, got %d", 2000, len(generatedURL)) + } + } + + joined := strings.Join(urls, "\n") + if got := strings.Count(joined, "_status="); got != proposalCount { + t.Fatalf("expected %d statuses across all split URLs, got %d", proposalCount, got) + } + if got := strings.Count(joined, "_justification="); got != proposalCount { + t.Fatalf("expected %d justifications across all split URLs, got %d", proposalCount, got) + } + for i := 1; i <= proposalCount; i++ { + statusKey := fmt.Sprintf("osps_ac_%02d_01_status=Met", i) + if got := strings.Count(joined, statusKey); got != 1 { + t.Fatalf("expected %q exactly once across all split URLs, got %d", statusKey, got) + } + } +} + +func TestRejectsInvalidBadge(t *testing.T) { + _, err := Generate([]byte(minimalResultsYAML()), Options{Badge: "gold", IncludeJustifications: boolOption(true)}) + if err == nil { + t.Fatal("expected an error for invalid badge") + } +} + +func TestMissingInputFile(t *testing.T) { + _, err := GenerateFromFile(filepath.Join(t.TempDir(), "missing.yaml"), Options{Badge: DefaultBadge, IncludeJustifications: boolOption(true)}) + if err == nil { + t.Fatal("expected an error for missing file") + } +} + +func TestMalformedYAML(t *testing.T) { + filePath := writeResultsFile(t, "payload: [\n") + + _, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge, IncludeJustifications: boolOption(true)}) + if err == nil { + t.Fatal("expected an error for malformed YAML") + } +} + +func TestNoSupportedLinks(t *testing.T) { + filePath := writeResultsFile(t, unsupportedResultsYAML()) + + _, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge, IncludeJustifications: boolOption(true)}) + if err == nil { + t.Fatal("expected an error when there are no supported links") + } +} + +func TestOmitJustificationsWhenDisabled(t *testing.T) { + filePath := writeResultsFile(t, minimalResultsYAML()) + + urls, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge, IncludeJustifications: boolOption(false)}) + if err != nil { + t.Fatalf("GenerateFromFile returned error: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + if strings.Contains(urls[0], "_justification=") { + t.Fatalf("did not expect justification in URL: %s", urls[0]) + } +} + +func TestIncludeJustificationsByDefault(t *testing.T) { + filePath := writeResultsFile(t, minimalResultsYAML()) + + urls, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge}) + if err != nil { + t.Fatalf("GenerateFromFile returned error: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + if !strings.Contains(urls[0], "_justification=") { + t.Fatalf("expected justification in URL by default: %s", urls[0]) + } +} + +func TestMapNotApplicableToNA(t *testing.T) { + filePath := writeResultsFile(t, notApplicableResultsYAML()) + + urls, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge, IncludeJustifications: boolOption(true)}) + if err != nil { + t.Fatalf("GenerateFromFile returned error: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + if !strings.Contains(urls[0], "osps_le_01_01_status=N%2FA") { + t.Fatalf("expected NotApplicable to map to N/A: %s", urls[0]) + } +} + +func TestPreserveEvidenceURLInJustification(t *testing.T) { + filePath := writeResultsFile(t, urlJustificationResultsYAML()) + + urls, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge, IncludeJustifications: boolOption(true)}) + if err != nil { + t.Fatalf("GenerateFromFile returned error: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + if !strings.Contains(urls[0], "https%3A%2F%2Fexample.com%2Fevidence%3Fcheck%3Dbranch-protection%26source%3Dscanner") { + t.Fatalf("expected evidence URL to be preserved in justification: %s", urls[0]) + } +} + +func TestTruncateJustificationOnRuneBoundary(t *testing.T) { + filePath := writeResultsFile(t, unicodeJustificationResultsYAML()) + + urls, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge, IncludeJustifications: boolOption(true)}) + if err != nil { + t.Fatalf("GenerateFromFile returned error: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + if strings.Contains(urls[0], "%EF%BF%BD") { + t.Fatalf("expected justification truncation to preserve UTF-8 rune boundaries: %s", urls[0]) + } + if !strings.Contains(urls[0], "%C3%A9") { + t.Fatalf("expected justification to preserve unicode characters: %s", urls[0]) + } + if strings.Contains(urls[0], strings.Repeat("a", 260)) { + t.Fatalf("expected justification to be truncated: %s", urls[0]) + } +} + +func TestGenerateFromSerializedPayloadConfigShape(t *testing.T) { + filePath := writeResultsFile(t, serializedPayloadResultsYAML()) + + urls, err := GenerateFromFile(filePath, Options{Badge: DefaultBadge, IncludeJustifications: boolOption(true)}) + if err != nil { + t.Fatalf("GenerateFromFile returned error: %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + + url := urls[0] + if !strings.Contains(url, "url=https%3A%2F%2Fgithub.com%2Fradius-project%2Fradius") { + t.Fatalf("expected repository URL from serialized payload config: %s", url) + } + if !strings.Contains(url, "osps_ac_01_01_status=Met") { + t.Fatalf("expected suffixed status field in URL: %s", url) + } + if !strings.Contains(url, "osps_ac_01_01_justification=This+control+is+enforced+by+GitHub") { + t.Fatalf("expected justification in URL: %s", url) + } +} + +func writeResultsFile(t *testing.T, content string) string { + t.Helper() + filePath := filepath.Join(t.TempDir(), "results.yaml") + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatalf("write results file: %v", err) + } + return filePath +} + +func boolOption(value bool) *bool { + return &value +} + +func minimalResultsYAML() string { + return `payload: + config: + vars: + owner: acme + repo: rocket +evaluation-suites: + - control-evaluations: + evaluations: + - assessment-logs: + - requirement: + entry-id: OSPS-AC-01.01 + result: Passed + message: MFA required + applicability: + - Maturity Level 1 + - Maturity Level 2 + - requirement: + entry-id: OSPS-DO-03.01 + result: Failed + message: Missing provenance + applicability: + - Maturity Level 2 + - requirement: + entry-id: OSPS-DO-04.01 + result: NeedsReview + message: Requires manual review + applicability: + - Maturity Level 1 +` +} + +func unsupportedResultsYAML() string { + return `payload: + config: + vars: + owner: acme + repo: rocket +evaluation-suites: + - control-evaluations: + evaluations: + - assessment-logs: + - requirement: + entry-id: OSPS-DO-04.01 + result: NeedsReview + message: Requires manual review + applicability: + - Maturity Level 1 +` +} + +func notApplicableResultsYAML() string { + return "payload:\n" + + " config:\n" + + " vars:\n" + + " owner: acme\n" + + " repo: rocket\n" + + "evaluation-suites:\n" + + " - control-evaluations:\n" + + " evaluations:\n" + + " - assessment-logs:\n" + + " - requirement:\n" + + " entry-id: OSPS-LE-01.01\n" + + " result: NotApplicable\n" + + " message: Repository has no releases\n" + + " applicability:\n" + + " - Maturity Level 1\n" +} + +func urlJustificationResultsYAML() string { + return "payload:\n" + + " config:\n" + + " vars:\n" + + " owner: acme\n" + + " repo: rocket\n" + + "evaluation-suites:\n" + + " - control-evaluations:\n" + + " evaluations:\n" + + " - assessment-logs:\n" + + " - requirement:\n" + + " entry-id: OSPS-AC-01.01\n" + + " result: Passed\n" + + " message: \"Evidence: https://example.com/evidence?check=branch-protection&source=scanner\"\n" + + " applicability:\n" + + " - Maturity Level 1\n" +} + +func unicodeJustificationResultsYAML() string { + return "payload:\n" + + " config:\n" + + " vars:\n" + + " owner: acme\n" + + " repo: rocket\n" + + "evaluation-suites:\n" + + " - control-evaluations:\n" + + " evaluations:\n" + + " - assessment-logs:\n" + + " - requirement:\n" + + " entry-id: OSPS-AC-01.01\n" + + " result: Passed\n" + + fmt.Sprintf(" message: \"%s\"\n", strings.Repeat("a", 239)+"é"+strings.Repeat("b", 40)) + + " applicability:\n" + + " - Maturity Level 1\n" +} + +func serializedPayloadResultsYAML() string { + return "service-name: my-scan\n" + + "plugin-name: github-repo\n" + + "payload:\n" + + " restdata:\n" + + " config:\n" + + " vars:\n" + + " owner: radius-project\n" + + " repo: radius\n" + + " config:\n" + + " vars:\n" + + " owner: radius-project\n" + + " repo: radius\n" + + "evaluation-suites:\n" + + " - control-evaluations:\n" + + " evaluations:\n" + + " - assessment-logs:\n" + + " - requirement:\n" + + " entry-id: OSPS-AC-01.01\n" + + " result: Passed\n" + + " message: This control is enforced by GitHub\n" + + " applicability:\n" + + " - Maturity Level 1\n" +} + +func longResultsYAML(count int) string { + var builder strings.Builder + builder.WriteString("payload:\n") + builder.WriteString(" config:\n") + builder.WriteString(" vars:\n") + builder.WriteString(" owner: acme\n") + builder.WriteString(" repo: rocket\n") + builder.WriteString("evaluation-suites:\n") + builder.WriteString(" - control-evaluations:\n") + builder.WriteString(" evaluations:\n") + builder.WriteString(" - assessment-logs:\n") + for i := 1; i <= count; i++ { + _, _ = fmt.Fprintf(&builder, " - requirement:\n entry-id: OSPS-AC-%02d.01\n", i) + builder.WriteString(" result: Passed\n") + _, _ = fmt.Fprintf(&builder, " message: %s\n", strings.Repeat("evidence ", 24)) + builder.WriteString(" applicability:\n") + builder.WriteString(" - Maturity Level 1\n") + } + return builder.String() +} diff --git a/cmd/badge-url/main.go b/cmd/badge-url/main.go new file mode 100644 index 0000000..43c0565 --- /dev/null +++ b/cmd/badge-url/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + + "github.com/ossf/pvtr-github-repo-scanner/badgeurl" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + var ( + filePath string + badge string + includeJustifications bool + ) + + flags := flag.NewFlagSet("badge-url", flag.ContinueOnError) + flags.SetOutput(stderr) + flags.StringVar(&filePath, "f", "", "path to Privateer YAML results file") + flags.StringVar(&badge, "badge", badgeurl.DefaultBadge, "badge target: choose, baseline-1, baseline-2, or baseline-3") + flags.BoolVar(&includeJustifications, "justifications", true, "include justification text in generated Best Practices Badge links") + if err := flags.Parse(args); err != nil { + return 2 + } + + if filePath == "" { + _, _ = fmt.Fprintln(stderr, "badge-url requires -f ") + return 2 + } + + urls, err := badgeurl.GenerateFromFile(filePath, badgeurl.Options{ + Badge: badge, + IncludeJustifications: &includeJustifications, + }) + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + + if len(urls) == 1 { + _, _ = fmt.Fprintln(stderr, "Open this link in your browser, review the proposed answers, and save.") + } else { + _, _ = fmt.Fprintln(stderr, "Open the links in order. After each link, review the proposed answers and save before opening the next one.") + } + + for _, generatedURL := range urls { + if _, err := fmt.Fprintln(stdout, generatedURL); err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + } + + return 0 +} diff --git a/docs/best-practices-badge-spec.md b/docs/best-practices-badge-spec.md new file mode 100644 index 0000000..4f3a5fe --- /dev/null +++ b/docs/best-practices-badge-spec.md @@ -0,0 +1,172 @@ +# Best Practices Badge Integration Implementation Spec + +This document defines the intended behavior of the Best Practices Badge utility +for Privateer GitHub scanner results. + +## Purpose + +The utility reads a Privateer results YAML file and generates one or more OpenSSF +Best Practices Badge links. + +The generated URLs are intended for human review in a browser. The utility does +not authenticate to bestpractices.dev and does not submit answers directly. + +## Goals + +- Convert Privateer scan results into Best Practices Badge link data. +- Preserve enough context for a reviewer to understand why an answer was + suggested. +- Keep the output suitable for a browser-based handoff. +- Generate the smallest number of Best Practices Badge links needed while + keeping each generated URL within a browser-friendly size. + +## Non-Goals + +- Updating badge answers directly through an API. +- Replacing human review. +- Re-running Privateer scans. +- Creating or managing bestpractices.dev project entries. + +## Command-Line Interface + +The utility is exposed as: + +```sh +badge-url -f [-badge
] [-justifications=] +``` + +### Flags + +`-f` + +Required. Path to a Privateer results YAML file. + +`-badge` + +Optional. Target badge section. + +Allowed values: + +- `choose` +- `baseline-1` +- `baseline-2` +- `baseline-3` + +Default: `choose`. + +Behavior: + +- `choose` leaves section selection to bestpractices.dev after the proposal page + opens. +- A specific baseline value scopes the generated proposal to that badge section. + +`-justifications` + +Optional boolean. + +Default: `true`. + +Behavior: + +- `true` includes short justification text where available. +- `false` omits justification text to reduce URL size. + +## Input Contract + +The input file must be a Privateer GitHub scanner results YAML document that: + +- identifies the target repository URL or repository identity clearly enough for + bestpractices.dev project matching +- contains control results that can be mapped to Best Practices Badge criteria +- contains optional explanatory text that can be used as reviewer justification + +If required repository identity is missing, the utility must fail with a clear +error. + +## Output Contract + +The utility writes one or more URLs to standard output. + +Each URL must: + +- target the Best Practices Badge automation proposal flow +- encode a set of proposed answers derived from the input results +- refer to the same target repository/project across all generated batches + +The utility should emit one URL per line so the output is easy to copy, pipe, or +process. + +The utility should emit a single link when the generated proposal fits within +the supported URL length budget, and otherwise emit multiple links. + +## Mapping Behavior + +The implementation must map supported Privateer findings to Best Practices Badge +criteria. + +Each proposal should include: + +- the target criterion identifier +- the proposed answer value +- optional justification text when enabled and available + +Unsupported or unknown findings must be ignored rather than producing invalid +proposal data. + +## Batching Rules + +The utility should generate as few links as possible while keeping each link at +or below 2000 characters. + +Batching requirements: + +- preserve a stable ordering of proposals across runs for the same input +- ensure every generated URL targets the same repository/project +- avoid overlapping proposal entries across batches +- print batches in the order they should be applied by the user + +## Error Handling + +The utility must return a non-zero exit code when: + +- the input file cannot be opened +- the input file cannot be parsed +- required repository metadata is missing +- no supported Best Practices Badge links can be generated from the input +- a flag value is invalid + +Error messages should explain what failed and what the user should check next. + +## User Workflow Assumptions + +The implementation may assume that the user: + +- has access to the repository referenced by the scan results +- will review and save proposals manually in the browser + +The implementation must not assume that the project entry already exists. The +generated URLs may still be valid even if the user needs to create or verify the +project entry first. + +## Documentation Requirements + +The user guide should explain: + +- what the command does +- required inputs +- the meaning of each flag +- what to do with generated URLs +- that the utility automatically splits oversized proposals into multiple links +- when users should manually review or override a proposed answer + +## Suggested Validation + +At minimum, tests should cover: + +- valid single-URL generation +- multi-batch URL generation +- invalid flag values +- missing input file +- malformed YAML input +- input with no supported proposals +- inclusion and omission of justification text \ No newline at end of file diff --git a/docs/best-practices-badge.md b/docs/best-practices-badge.md new file mode 100644 index 0000000..c03e2f6 --- /dev/null +++ b/docs/best-practices-badge.md @@ -0,0 +1,127 @@ +# Best Practices Badge Integration + +This guide explains how to use the Best Practices Badge integration utility with +Privateer scan results. + +## What The Utility Does + +The utility reads a Privateer GitHub scanner results file and generates an +OpenSSF Best Practices Badge link using the [Best Practices Badge Automation +Proposals](https://github.com/coreinfrastructure/best-practices-badge/blob/main/docs/automation-proposals.md) +mechanism, which lets an external tool prefill badge answers through a browser +URL. + +The utility generates one link when the proposal fits within a browser-friendly +URL length. When the generated proposal would exceed 2000 characters, it +automatically splits the result into multiple links that you apply in order. + +The utility does not submit changes to bestpractices.dev on its own. It creates +links that open in a browser so you can review the suggested answers +before saving them. + +## Before You Begin + +Make sure you have: + +1. Run a Privateer scan and written the results to a YAML file. +2. A repository that already has, or will have, an entry on + bestpractices.dev. +3. A browser ready to open the generated link and review the suggested answers. + +## Generate Best Practices Badge Links + +Base command: + +```sh +go run ./cmd/badge-url -f evaluation_results/my-scan/my-scan.yaml +``` + +Arguments: + +`-f ` + +Required. Path to a Privateer GitHub scanner results YAML file. + +--- + +`-badge
` + +Optional. Target badge section. + +Allowed values: + +- `choose` +- `baseline-1` +- `baseline-2` +- `baseline-3` + +Default: `choose`. + +Example: + +```sh +go run ./cmd/badge-url -f evaluation_results/my-scan/my-scan.yaml -badge baseline-1 +``` + +--- + +`-justifications` + +Optional. Whether to include short Privateer justification text in the +generated Best Practices Badge links. + +Default: `true`. + +Leave justifications enabled when you want more review context in the badge +form. Disable them when you want shorter URLs. + +Example: + +```sh +go run ./cmd/badge-url -f evaluation_results/my-scan/my-scan.yaml -justifications=false +``` + +--- + +## Review The Proposed Answers + +After you run the command: + +1. Read the short terminal guidance and copy the printed URL from standard output. +2. Open the link in your browser. +3. Let bestpractices.dev match the project by repository URL. +4. Review the prefilled answers. +5. Adjust any answers that need manual correction. +6. Save the changes. + +The generated answers are meant to speed up review, not replace it. + +## If The Utility Prints More Than One Link + +The utility generates multiple links automatically when the full proposal would +otherwise exceed 2000 characters. + +The command prints one BPB URL per line on standard output. Any human guidance +about how to apply the links is written separately to standard error. + +When that happens, apply the links in order: + +1. Open the first link. +2. Review and save the proposed answers. +3. Open the next link. +4. Review and save that batch. +5. Repeat until every link has been applied. + +Each link represents another batch of proposals for the same project. + +## Review Tips + +Take a closer look before saving when: + +- a criterion is known to be heuristic or noisy +- the repository changed after the scan ran +- a proposed answer conflicts with data already stored in bestpractices.dev +- the project is new and the badge site does not immediately find a match + +If bestpractices.dev does not find the project, create or verify the project +entry first and then open the generated link again. \ No newline at end of file