Skip to content

Commit 04dda37

Browse files
authored
feat: add best practices badge URL generator (#301)
Signed-off-by: Vinaya Damle <vinayada1@users.noreply.github.com> Co-authored-by: Vinaya Damle <vinayada1@users.noreply.github.com>
1 parent df50a45 commit 04dda37

7 files changed

Lines changed: 1070 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# ignore the generated artifacts
22
pvtr-github-repo
33
github-repo
4+
badge-url
45
evaluation_results
56

67
# ignore any local dev config file

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ docker run \
4242

4343
See the [OSPS Security Baseline Scanner](https://github.com/marketplace/actions/open-source-project-security-baseline-scanner)
4444

45+
## Best Practices Badge Integration
46+
47+
To use scan results with the OpenSSF Best Practices Badge, see the user guide in
48+
[docs/best-practices-badge.md](docs/best-practices-badge.md).
49+
4550
## Contributing
4651

4752
Contributions are welcome! Please see our [Contributing Guidelines](.github/CONTRIBUTING.md) for more information.

badgeurl/badgeurl.go

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
package badgeurl
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"os"
7+
"sort"
8+
"strings"
9+
10+
"github.com/goccy/go-yaml"
11+
)
12+
13+
const (
14+
// DefaultBadge lets bestpractices.dev prompt the user to choose a target
15+
// baseline section after opening the generated edit URL.
16+
DefaultBadge = "choose"
17+
defaultMaxURLLength = 2000
18+
defaultJustificationSize = 240
19+
)
20+
21+
var (
22+
// These are the BPB sections this utility knows how to target from
23+
// Privateer's current OSPS baseline mapping.
24+
supportedBadgeSections = map[string]struct{}{
25+
"choose": {},
26+
"baseline-1": {},
27+
"baseline-2": {},
28+
"baseline-3": {},
29+
}
30+
)
31+
32+
// Options configures badge URL generation.
33+
//
34+
// IncludeJustifications is a tri-state field: nil applies the package default
35+
// of including justifications, while non-nil values explicitly enable or
36+
// disable them.
37+
type Options struct {
38+
Badge string
39+
IncludeJustifications *bool
40+
}
41+
42+
type resultsFile struct {
43+
Payload struct {
44+
Config *payloadConfig `yaml:"config"`
45+
RestData *struct {
46+
Config *payloadConfig `yaml:"config"`
47+
} `yaml:"restdata"`
48+
} `yaml:"payload"`
49+
EvaluationSuites []evaluationSuite `yaml:"evaluation-suites"`
50+
}
51+
52+
type payloadConfig struct {
53+
Vars map[string]string `yaml:"vars"`
54+
}
55+
56+
type evaluationSuite struct {
57+
ControlEvaluations controlEvaluations `yaml:"control-evaluations"`
58+
}
59+
60+
type controlEvaluations struct {
61+
Evaluations []controlEvaluation `yaml:"evaluations"`
62+
}
63+
64+
type controlEvaluation struct {
65+
AssessmentLogs []assessmentLog `yaml:"assessment-logs"`
66+
}
67+
68+
type assessmentLog struct {
69+
Requirement struct {
70+
EntryID string `yaml:"entry-id"`
71+
} `yaml:"requirement"`
72+
Result string `yaml:"result"`
73+
Message string `yaml:"message"`
74+
Applicability []string `yaml:"applicability"`
75+
}
76+
77+
type proposalUnit struct {
78+
key string
79+
encoded string
80+
}
81+
82+
// GenerateFromFile reads a serialized Privateer results file and returns one
83+
// or more Best Practices Badge automation proposal URLs.
84+
func GenerateFromFile(path string, options Options) ([]string, error) {
85+
content, err := os.ReadFile(path)
86+
if err != nil {
87+
return nil, fmt.Errorf("read results file: %w", err)
88+
}
89+
90+
return Generate(content, options)
91+
}
92+
93+
// Generate converts a serialized Privateer results document into one or more
94+
// Best Practices Badge automation-proposals URLs.
95+
// Reference: https://github.com/coreinfrastructure/best-practices-badge/blob/main/docs/automation-proposals.md
96+
func Generate(content []byte, options Options) ([]string, error) {
97+
options = normalizeOptions(options)
98+
if err := validateOptions(options); err != nil {
99+
return nil, err
100+
}
101+
102+
var results resultsFile
103+
if err := yaml.Unmarshal(content, &results); err != nil {
104+
return nil, fmt.Errorf("parse results YAML: %w", err)
105+
}
106+
107+
repoURL, err := extractRepositoryURL(results)
108+
if err != nil {
109+
return nil, err
110+
}
111+
112+
units := collectProposalUnits(results, options)
113+
if len(units) == 0 {
114+
return nil, fmt.Errorf("no supported Best Practices Badge links could be generated from the results")
115+
}
116+
117+
baseURL := buildBaseURL(repoURL, options.Badge)
118+
return buildURLs(baseURL, units)
119+
}
120+
121+
func normalizeOptions(options Options) Options {
122+
if strings.TrimSpace(options.Badge) == "" {
123+
options.Badge = DefaultBadge
124+
}
125+
if options.IncludeJustifications == nil {
126+
options.IncludeJustifications = boolPtr(true)
127+
}
128+
return options
129+
}
130+
131+
func validateOptions(options Options) error {
132+
if _, ok := supportedBadgeSections[options.Badge]; !ok {
133+
return fmt.Errorf("invalid badge %q: must be one of choose, baseline-1, baseline-2, baseline-3", options.Badge)
134+
}
135+
return nil
136+
}
137+
138+
// extractRepositoryURL finds the repository identity that BPB uses to look up
139+
// the target project before applying proposal fields.
140+
func extractRepositoryURL(results resultsFile) (string, error) {
141+
// Privateer results may carry config in more than one serialized payload
142+
// location depending on how the scanner wrote the output.
143+
configs := []*payloadConfig{
144+
results.Payload.Config,
145+
}
146+
if results.Payload.RestData != nil {
147+
configs = append(configs, results.Payload.RestData.Config)
148+
}
149+
150+
for _, cfg := range configs {
151+
if cfg == nil {
152+
continue
153+
}
154+
owner := strings.TrimSpace(cfg.Vars["owner"])
155+
repo := strings.TrimSpace(cfg.Vars["repo"])
156+
if owner != "" && repo != "" {
157+
return fmt.Sprintf("https://github.com/%s/%s", owner, repo), nil
158+
}
159+
}
160+
161+
return "", fmt.Errorf("could not determine repository URL from results payload")
162+
}
163+
164+
// collectProposalUnits walks the evaluation logs, filters them to the target
165+
// badge scope, and turns supported findings into stable query-string fragments.
166+
func collectProposalUnits(results resultsFile, options Options) []proposalUnit {
167+
allowedLevels := levelsForBadge(options.Badge)
168+
seen := map[string]struct{}{}
169+
units := make([]proposalUnit, 0)
170+
171+
for _, suite := range results.EvaluationSuites {
172+
for _, evaluation := range suite.ControlEvaluations.Evaluations {
173+
for _, log := range evaluation.AssessmentLogs {
174+
// Requirement IDs can appear more than once across suites. Keep the
175+
// first supported occurrence so the output is stable and non-duplicated.
176+
requirementID := strings.TrimSpace(log.Requirement.EntryID)
177+
if requirementID == "" {
178+
continue
179+
}
180+
if _, ok := seen[requirementID]; ok {
181+
continue
182+
}
183+
if !isApplicable(log.Applicability, allowedLevels) {
184+
continue
185+
}
186+
187+
status, ok := mapResult(log.Result)
188+
if !ok {
189+
continue
190+
}
191+
192+
key := badgeFieldName(requirementID)
193+
parts := []string{fmt.Sprintf("%s_status=%s", key, url.QueryEscape(status))}
194+
if *options.IncludeJustifications {
195+
justification := sanitizeJustification(log.Message)
196+
if justification != "" {
197+
parts = append(parts, fmt.Sprintf("%s_justification=%s", key, url.QueryEscape(justification)))
198+
}
199+
}
200+
201+
units = append(units, proposalUnit{
202+
key: key,
203+
encoded: strings.Join(parts, "&"),
204+
})
205+
seen[requirementID] = struct{}{}
206+
}
207+
}
208+
}
209+
210+
sort.Slice(units, func(i, j int) bool {
211+
return units[i].key < units[j].key
212+
})
213+
214+
return units
215+
}
216+
217+
// levelsForBadge maps a BPB section to the Privateer OSPS maturity levels
218+
// whose findings should be included in the generated link.
219+
func levelsForBadge(badge string) map[string]struct{} {
220+
if badge == DefaultBadge {
221+
// "choose" defers section choice to BPB, so include all applicable levels.
222+
return nil
223+
}
224+
225+
levels := map[string]struct{}{}
226+
for _, level := range []string{"Maturity Level 1", "Maturity Level 2", "Maturity Level 3"} {
227+
levels[level] = struct{}{}
228+
if badge == "baseline-1" && level == "Maturity Level 1" {
229+
break
230+
}
231+
if badge == "baseline-2" && level == "Maturity Level 2" {
232+
break
233+
}
234+
if badge == "baseline-3" && level == "Maturity Level 3" {
235+
break
236+
}
237+
}
238+
return levels
239+
}
240+
241+
func isApplicable(applicability []string, allowedLevels map[string]struct{}) bool {
242+
if allowedLevels == nil || len(applicability) == 0 {
243+
return true
244+
}
245+
for _, level := range applicability {
246+
if _, ok := allowedLevels[level]; ok {
247+
return true
248+
}
249+
}
250+
return false
251+
}
252+
253+
// mapResult converts Privateer's control result vocabulary into BPB's status
254+
// vocabulary and drops unsupported states.
255+
func mapResult(result string) (string, bool) {
256+
switch strings.TrimSpace(strings.ToLower(result)) {
257+
case "passed":
258+
return "Met", true
259+
case "failed":
260+
return "Unmet", true
261+
case "notapplicable", "not applicable", "n/a":
262+
return "N/A", true
263+
default:
264+
return "", false
265+
}
266+
}
267+
268+
func badgeFieldName(requirementID string) string {
269+
replacer := strings.NewReplacer("-", "_", ".", "_")
270+
return strings.ToLower(replacer.Replace(requirementID))
271+
}
272+
273+
// sanitizeJustification keeps reviewer context short and URL-safe so it can be
274+
// embedded directly into a BPB proposal link.
275+
func sanitizeJustification(message string) string {
276+
cleaned := strings.TrimSpace(message)
277+
if cleaned == "" {
278+
return ""
279+
}
280+
// Keep reviewer context compact while preserving evidence details such as
281+
// URLs; QueryEscape handles the actual URL encoding later.
282+
cleaned = strings.ReplaceAll(cleaned, "\n", " ")
283+
cleaned = strings.ReplaceAll(cleaned, "\r", " ")
284+
cleaned = strings.Join(strings.Fields(cleaned), " ")
285+
runes := []rune(cleaned)
286+
if len(runes) > defaultJustificationSize {
287+
cleaned = strings.TrimSpace(string(runes[:defaultJustificationSize]))
288+
}
289+
return cleaned
290+
}
291+
292+
func boolPtr(value bool) *bool {
293+
return &value
294+
}
295+
296+
func buildBaseURL(repoURL string, badge string) string {
297+
return fmt.Sprintf(
298+
"https://www.bestpractices.dev/projects?as=edit&section=%s&url=%s",
299+
url.QueryEscape(badge),
300+
url.QueryEscape(repoURL),
301+
)
302+
}
303+
304+
// buildURLs emits one link when it fits within the default URL budget and
305+
// otherwise batches proposal fragments into multiple links that can be applied
306+
// in order.
307+
func buildURLs(baseURL string, units []proposalUnit) ([]string, error) {
308+
urls := make([]string, 0, 1)
309+
current := baseURL
310+
hasUnits := false
311+
312+
for _, unit := range units {
313+
candidate := current + "&" + unit.encoded
314+
if len(candidate) > defaultMaxURLLength {
315+
if !hasUnits {
316+
return nil, fmt.Errorf("a single Best Practices Badge proposal entry exceeds %d characters; disable justifications or shorten the source evidence text", defaultMaxURLLength)
317+
}
318+
urls = append(urls, current)
319+
current = baseURL + "&" + unit.encoded
320+
hasUnits = true
321+
continue
322+
}
323+
current = candidate
324+
hasUnits = true
325+
}
326+
327+
if hasUnits {
328+
urls = append(urls, current)
329+
}
330+
331+
return urls, nil
332+
}

0 commit comments

Comments
 (0)