cli/scorecard/score.go (286 lines of code) (raw):
// Copyright 2019 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package scorecard
import (
"context"
"crypto/md5"
"encoding/csv"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/GoogleCloudPlatform/config-validator/pkg/api/validator"
"github.com/GoogleCloudPlatform/config-validator/pkg/gcv"
"github.com/pkg/errors"
"google.golang.org/protobuf/types/known/structpb"
)
// ScoringConfig holds settings for generating a score
type ScoringConfig struct {
categories map[string]*constraintCategory // available constraint categories
constraints map[string]*constraintViolations // a map of constraints violated and their violations
validator *gcv.Validator // the validator instance used for scoring
}
// NewScoringConfigFromValidator creates a scoring engine with a given validator.
func NewScoringConfigFromValidator(v *gcv.Validator) *ScoringConfig {
config := &ScoringConfig{}
config.validator = v
return config
}
// NewScoringConfig creates a scoring engine for the given policy library
func NewScoringConfig(ctx context.Context, policyPath string) (*ScoringConfig, error) {
flag.Parse()
v, err := gcv.NewValidator(
[]string{filepath.Join(policyPath, "policies")},
filepath.Join(policyPath, "lib"),
)
if err != nil {
return nil, errors.Wrap(err, "initializing gcv validator")
}
config := NewScoringConfigFromValidator(v)
return config, nil
}
func (c ScoringConfig) CountViolations() int {
sum := 0
for _, cv := range c.constraints {
sum += cv.Count()
}
return sum
}
const otherCategoryKey = "other"
// constraintCategory holds constraints by category
type constraintCategory struct {
Name string
constraints []*constraintViolations
}
func (c constraintCategory) Count() int {
sum := 0
for _, cv := range c.constraints {
sum += cv.Count()
}
return sum
}
// constraintViolations holds violations for a particular constraint
type constraintViolations struct {
constraint string
Violations []*RichViolation `protobuf:"bytes,1,rep,name=violations,proto3" json:"violations,omitempty"`
}
func (cv constraintViolations) Count() int {
return len(cv.Violations)
}
func getConstraintShortName(constraintName string) string {
return strings.Split(constraintName, ".")[1]
}
// RichViolation holds a violation with its category
type RichViolation struct {
*validator.Violation `json:"-"`
Category string // category of violation
Resource string
Message string
Metadata *structpb.Value `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"`
asset *validator.Asset `json:"-"`
}
var availableCategories = map[string]string{
"operational-efficiency": "Operational Efficiency",
"security": "Security",
"reliability": "Reliability",
otherCategoryKey: "Other",
}
func (config *ScoringConfig) getConstraintForViolation(violation *RichViolation) (*constraintViolations, error) {
key := violation.GetConstraint()
cv, found := config.constraints[key]
if !found {
constraint := key
cv = &constraintViolations{
constraint: constraint,
}
config.constraints[key] = cv
metadata := violation.Violation.GetMetadata().GetStructValue().GetFields()["constraint"]
annotations := metadata.GetStructValue().GetFields()["annotations"].GetStructValue().GetFields()
categoryKey := otherCategoryKey
categoryValue, found := annotations["bundles.validator.forsetisecurity.org/scorecard-v1"]
if found {
categoryKey = categoryValue.GetStringValue()
}
category, found := config.categories[categoryKey]
if !found {
return nil, fmt.Errorf("unknown constraint category %v for constraint %v", categoryKey, key)
}
category.constraints = append(category.constraints, cv)
}
return cv, nil
}
// attachViolations puts violations into their appropriate categories
func (config *ScoringConfig) attachViolations(violations []*RichViolation) error {
// make violations unique
Log.Debug("AuditResult from Config Validator", "# of Violations", len(violations))
violations = uniqueViolations(violations)
Log.Debug("AuditResult from Config Validator", "# of Unique Violations", len(violations))
// Build map of categories
config.categories = make(map[string]*constraintCategory)
for k, name := range availableCategories {
config.categories[k] = &constraintCategory{
Name: name,
}
}
// Categorize violations
config.constraints = make(map[string]*constraintViolations)
for _, v := range violations {
cv, err := config.getConstraintForViolation(v)
if err != nil {
return errors.Wrap(err, "Categorizing violation")
}
cv.Violations = append(cv.Violations, v)
}
return nil
}
// writeResults writes scorecard results to the provided destination
func writeResults(config *ScoringConfig, dest io.Writer, outputFormat string, outputMetadataFields []string) error {
switch outputFormat {
case "json":
var richViolations []*RichViolation
for _, category := range config.categories {
for _, cv := range category.constraints {
for _, v := range cv.Violations {
v.Category = category.Name
if len(outputMetadataFields) > 0 {
newMetadata := make(map[string]interface{})
oldMetadata := v.Metadata.GetStructValue().Fields["details"].GetStructValue()
for _, field := range outputMetadataFields {
newMetadata[field], _ = interfaceViaJSON(oldMetadata.Fields[field])
}
err := protoViaJSON(newMetadata, v.Metadata)
if err != nil {
return err
}
}
richViolations = append(richViolations, v)
Log.Debug("violation metadata", "metadata", v.GetMetadata())
}
}
}
byteContent, err := json.MarshalIndent(richViolations, "", " ")
if err != nil {
return err
}
_, err = io.WriteString(dest, string(byteContent)+"\n")
if err != nil {
return err
}
return nil
case "csv":
w := csv.NewWriter(dest)
header := []string{"Category", "Constraint", "Resource", "Message", "Parent"}
header = append(header, outputMetadataFields...)
err := w.Write(header)
if err != nil {
return err
}
w.Flush()
for _, category := range config.categories {
for _, cv := range category.constraints {
for _, v := range cv.Violations {
parent := ""
if len(v.asset.Ancestors) > 0 {
parent = v.asset.Ancestors[0]
}
record := []string{category.Name, getConstraintShortName(v.Constraint), v.Resource, v.Message, parent}
for _, field := range outputMetadataFields {
metadata := v.Metadata.GetStructValue().Fields["details"].GetStructValue().Fields[field]
value, _ := stringViaJSON(metadata)
record = append(record, value)
}
err := w.Write(record)
if err != nil {
return err
}
w.Flush()
Log.Debug("Violation metadata", "metadata", v.GetMetadata())
}
}
}
return nil
case "txt":
_, err := io.WriteString(dest, fmt.Sprintf("\n\n%v total issues found\n", config.CountViolations()))
if err != nil {
return err
}
for _, category := range config.categories {
_, err = io.WriteString(dest, fmt.Sprintf("\n\n%v: %v issues found\n", category.Name, category.Count()))
if err != nil {
return err
}
_, err = io.WriteString(dest, "----------\n")
if err != nil {
return err
}
for _, cv := range category.constraints {
_, err = io.WriteString(dest, fmt.Sprintf("%v: %v issues\n", getConstraintShortName(cv.constraint), cv.Count()))
if err != nil {
return err
}
for _, v := range cv.Violations {
_, err = io.WriteString(dest, fmt.Sprintf("- %v\n", v.Message))
if err != nil {
return err
}
for _, field := range outputMetadataFields {
metadata := v.Metadata.GetStructValue().Fields["details"].GetStructValue().Fields[field]
value, _ := stringViaJSON(metadata)
if value != "" {
_, err = io.WriteString(dest, fmt.Sprintf(" %v: %v\n", field, value))
if err != nil {
return err
}
}
}
_, err = io.WriteString(dest, "\n")
if err != nil {
return err
}
Log.Debug("Violation metadata", "metadata", v.GetMetadata())
}
}
}
return nil
}
return fmt.Errorf("unsupported output format %v", outputFormat)
}
// findViolations gets violations for the inventory and attaches them
func (inventory *InventoryConfig) findViolations(config *ScoringConfig) error {
violations, err := getViolations(inventory, config)
if err != nil {
return err
}
err = config.attachViolations(violations)
if err != nil {
return err
}
return nil
}
// Score creates a Scorecard for an inventory
func (inventory *InventoryConfig) Score(config *ScoringConfig, outputPath string, outputFormat string, outputMetadataFields []string) error {
err := inventory.findViolations(config)
if err != nil {
return err
}
var dest io.Writer
if config.CountViolations() > 0 {
if outputPath == "" {
dest = os.Stdout
} else {
outputFile := "scorecard." + outputFormat
dest, err = os.Create(filepath.Join(outputPath, outputFile))
if err != nil {
return err
}
}
// Code to measure
err := writeResults(config, dest, outputFormat, outputMetadataFields)
if err != nil {
return err
}
} else {
fmt.Println("No issues found found! You have a perfect score.")
}
return nil
}
func uniqueViolations(violations []*RichViolation) []*RichViolation {
uniqueViolationMap := make(map[string]*RichViolation)
for _, v := range violations {
b, _ := json.Marshal(v)
hash := md5.Sum(b)
uniqueViolationMap[string(hash[:])] = v
}
uniqueViolations := make([]*RichViolation, 0, len(uniqueViolationMap))
for _, v := range uniqueViolationMap {
uniqueViolations = append(uniqueViolations, v)
}
return uniqueViolations
}