Ctrl K
ComunidadGuías…

internal/checks/imports.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package checks

import (
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/sophotechlabs/spinoza/internal/api"
)

const (
	ImportsKey     = "spinoza.checks.imports.v1"
	maxImportPaths = 32
	maxImportBytes = 32 << 20
	maxImportRules = 2000
	maxImportItems = 100000
)

var errImportTooLarge = errors.New("larger than 32 MiB")

func ParseImports(raw string) []string {
	out := []string{}
	for line := range strings.SplitSeq(raw, "\n") {
		path := strings.TrimSpace(line)
		if path == "" || strings.HasPrefix(path, "#") || slices.Contains(out, path) {
			continue
		}
		if len(out) == maxImportPaths {
			break
		}
		out = append(out, path)
	}
	return out
}

type importedObject struct {
	group     string
	version   string
	kind      string
	namespace string
	name      string
}

type importedFinding struct {
	object    importedObject
	container string
	detail    string
}

type importedRule struct {
	id       string
	title    string
	category string
	severity string
	wrong    string
	remedy   string
	findings []importedFinding
}

type importFile struct {
	path  string
	tool  string
	taken time.Time
	rules []importedRule
}

type importReader func(raw []byte) (string, []importedRule, error)

type cachedImport struct {
	modified time.Time
	size     int64
	file     importFile
	err      error
}

var importCache = struct {
	mu   sync.Mutex
	seen map[string]cachedImport
}{seen: map[string]cachedImport{}}

func readImports(paths []string) ([]importFile, []string) {
	files := []importFile{}
	faults := []string{}
	for _, path := range paths {
		file, err := readImport(path)
		if err != nil {
			faults = append(faults, "import "+path+": "+err.Error())
			continue
		}
		files = append(files, file)
	}
	return files, faults
}

func readImport(path string) (importFile, error) {
	info, err := os.Stat(path)
	if err != nil {
		return importFile{}, err
	}
	if !info.Mode().IsRegular() {
		return importFile{}, errors.New("not a regular file")
	}
	if info.Size() > maxImportBytes {
		return importFile{}, errImportTooLarge
	}
	importCache.mu.Lock()
	defer importCache.mu.Unlock()
	held, seen := importCache.seen[path]
	if seen && held.modified.Equal(info.ModTime()) && held.size == info.Size() {
		return held.file, held.err
	}
	file, err := loadImport(path, info.ModTime())
	importCache.seen[path] = cachedImport{modified: info.ModTime(), size: info.Size(), file: file, err: err}
	return file, err
}

func loadImport(path string, modified time.Time) (importFile, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return importFile{}, err
	}
	read, err := readerFor(raw)
	if err != nil {
		return importFile{}, err
	}
	tool, rules, err := read(raw)
	if err != nil {
		return importFile{}, err
	}
	if tool == "" {
		tool = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
	}
	return importFile{path: path, tool: strings.ToLower(tool), taken: modified, rules: rules}, nil
}

func readerFor(raw []byte) (importReader, error) {
	var top map[string]json.RawMessage
	if err := json.Unmarshal(raw, &top); err != nil {
		return nil, errors.New("not a JSON object: " + err.Error())
	}
	_, native := top["findings"]
	_, trivy := top["Resources"]
	_, sarif := top["runs"]
	_, kubescape := top["summaryDetails"]
	switch {
	case native:
		return readNative, nil
	case trivy:
		return readTrivy, nil
	case sarif:
		return readSARIF, nil
	case kubescape:
		return readKubescape, nil
	default:
		return nil, errors.New("not a format spinoza reads: spinoza findings, a trivy Kubernetes report, a kubescape report or SARIF")
	}
}

type nativeFile struct {
	Tool     string          `json:"tool"`
	Findings []nativeFinding `json:"findings"`
}

type nativeFinding struct {
	ID        string       `json:"id"`
	Title     string       `json:"title"`
	Category  string       `json:"category"`
	Severity  string       `json:"severity"`
	Wrong     string       `json:"wrong"`
	Remedy    string       `json:"remedy"`
	Detail    string       `json:"detail"`
	Container string       `json:"container"`
	Object    nativeObject `json:"object"`
}

type nativeObject struct {
	APIVersion string `json:"apiVersion"`
	Kind       string `json:"kind"`
	Namespace  string `json:"namespace"`
	Name       string `json:"name"`
}

func readNative(raw []byte) (string, []importedRule, error) {
	var file nativeFile
	if err := json.Unmarshal(raw, &file); err != nil {
		return "", nil, err
	}
	rules := newRuleSet()
	for at, one := range file.Findings {
		if one.ID == "" || one.Object.Kind == "" || one.Object.Name == "" {
			return "", nil, errors.New("finding " + itoa(at+1) + " needs an id and an object with a kind and a name")
		}
		rule := rules.add(importedRule{
			id:       one.ID,
			title:    firstOf(one.Title, one.ID),
			category: knownCategory(one.Category),
			severity: knownSeverity(one.Severity),
			wrong:    one.Wrong,
			remedy:   one.Remedy,
		})
		group, version := splitAPIVersion(one.Object.APIVersion)
		rule.findings = append(rule.findings, importedFinding{
			object: importedObject{
				group:     group,
				version:   version,
				kind:      one.Object.Kind,
				namespace: one.Object.Namespace,
				name:      one.Object.Name,
			},
			container: one.Container,
			detail:    firstOf(one.Detail, one.Wrong, one.Title, one.ID),
		})
		if err := rules.bounded(); err != nil {
			return "", nil, err
		}
	}
	return file.Tool, rules.list(), nil
}

type trivyReport struct {
	Resources []trivyResource `json:"Resources"`
}

type trivyResource struct {
	Namespace string        `json:"Namespace"`
	Kind      string        `json:"Kind"`
	Name      string        `json:"Name"`
	Results   []trivyResult `json:"Results"`
}

type trivyResult struct {
	Misconfigurations []trivyMisconfiguration `json:"Misconfigurations"`
}

type trivyMisconfiguration struct {
	ID          string `json:"ID"`
	Title       string `json:"Title"`
	Description string `json:"Description"`
	Message     string `json:"Message"`
	Resolution  string `json:"Resolution"`
	Severity    string `json:"Severity"`
	Status      string `json:"Status"`
}

func readTrivy(raw []byte) (string, []importedRule, error) {
	var report trivyReport
	if err := json.Unmarshal(raw, &report); err != nil {
		return "", nil, err
	}
	rules := newRuleSet()
	for _, resource := range report.Resources {
		for _, result := range resource.Results {
			for _, one := range result.Misconfigurations {
				if one.Status != "" && one.Status != "FAIL" {
					continue
				}
				rule := rules.add(importedRule{
					id:       one.ID,
					title:    firstOf(one.Title, one.ID),
					category: categorySecurity,
					severity: severityFromScanner(one.Severity),
					wrong:    one.Description,
					remedy:   one.Resolution,
				})
				rule.findings = append(rule.findings, importedFinding{
					object: importedObject{kind: resource.Kind, namespace: resource.Namespace, name: resource.Name},
					detail: firstOf(one.Message, one.Title),
				})
				if err := rules.bounded(); err != nil {
					return "", nil, err
				}
			}
		}
	}
	return "trivy", rules.list(), nil
}

type kubescapeReport struct {
	Resources []kubescapeResource `json:"resources"`
	Results   []kubescapeResult   `json:"results"`
	Summary   kubescapeSummary    `json:"summaryDetails"`
}

type kubescapeSummary struct {
	Controls map[string]kubescapeControlSummary `json:"controls"`
}

type kubescapeControlSummary struct {
	Name     string `json:"name"`
	Severity string `json:"severity"`
}

type kubescapeResource struct {
	ResourceID string          `json:"resourceID"`
	Object     kubescapeObject `json:"object"`
}

type kubescapeObject struct {
	APIVersion string            `json:"apiVersion"`
	Kind       string            `json:"kind"`
	Namespace  string            `json:"namespace"`
	Name       string            `json:"name"`
	Metadata   kubescapeMetadata `json:"metadata"`
}

type kubescapeMetadata struct {
	Namespace string `json:"namespace"`
	Name      string `json:"name"`
}

type kubescapeResult struct {
	ResourceID string             `json:"resourceID"`
	Controls   []kubescapeControl `json:"controls"`
}

type kubescapeControl struct {
	ControlID string          `json:"controlID"`
	Name      string          `json:"name"`
	Severity  string          `json:"severity"`
	Status    kubescapeStatus `json:"status"`
}

type kubescapeStatus struct {
	Status string `json:"status"`
}

func readKubescape(raw []byte) (string, []importedRule, error) {
	var report kubescapeReport
	if err := json.Unmarshal(raw, &report); err != nil {
		return "", nil, err
	}
	objects := map[string]importedObject{}
	for _, resource := range report.Resources {
		objects[resource.ResourceID] = kubescapeIdentity(resource.Object)
	}
	rules := newRuleSet()
	for _, result := range report.Results {
		object, known := objects[result.ResourceID]
		if !known {
			continue
		}
		for _, control := range result.Controls {
			if control.Status.Status != "failed" {
				continue
			}
			summary := report.Summary.Controls[control.ControlID]
			name := firstOf(control.Name, summary.Name, control.ControlID)
			rule := rules.add(importedRule{
				id:       control.ControlID,
				title:    name,
				category: categorySecurity,
				severity: severityFromScanner(firstOf(control.Severity, summary.Severity)),
				wrong:    "kubescape control " + control.ControlID + " failed: " + name + ".",
				remedy:   "See https://hub.armosec.io/docs/" + strings.ToLower(control.ControlID) + " for what the control expects.",
			})
			rule.findings = append(rule.findings, importedFinding{object: object, detail: name})
			if err := rules.bounded(); err != nil {
				return "", nil, err
			}
		}
	}
	return "kubescape", rules.list(), nil
}

func kubescapeIdentity(object kubescapeObject) importedObject {
	group, version := splitAPIVersion(object.APIVersion)
	return importedObject{
		group:     group,
		version:   version,
		kind:      object.Kind,
		namespace: firstOf(object.Metadata.Namespace, object.Namespace),
		name:      firstOf(object.Metadata.Name, object.Name),
	}
}

type sarifFile struct {
	Runs []sarifRun `json:"runs"`
}

type sarifRun struct {
	Tool    sarifTool     `json:"tool"`
	Results []sarifResult `json:"results"`
}

type sarifTool struct {
	Driver sarifDriver `json:"driver"`
}

type sarifDriver struct {
	Name  string      `json:"name"`
	Rules []sarifRule `json:"rules"`
}

type sarifRule struct {
	ID               string             `json:"id"`
	ShortDescription sarifText          `json:"shortDescription"`
	FullDescription  sarifText          `json:"fullDescription"`
	Help             sarifText          `json:"help"`
	Default          sarifConfiguration `json:"defaultConfiguration"`
}

type sarifText struct {
	Text string `json:"text"`
}

type sarifConfiguration struct {
	Level string `json:"level"`
}

type sarifResult struct {
	RuleID    string          `json:"ruleId"`
	Level     string          `json:"level"`
	Message   sarifText       `json:"message"`
	Locations []sarifLocation `json:"locations"`
}

type sarifLocation struct {
	Physical sarifPhysical  `json:"physicalLocation"`
	Logical  []sarifLogical `json:"logicalLocations"`
}

type sarifPhysical struct {
	Artifact sarifArtifact `json:"artifactLocation"`
}

type sarifArtifact struct {
	URI string `json:"uri"`
}

type sarifLogical struct {
	FullyQualifiedName string `json:"fullyQualifiedName"`
	Kind               string `json:"kind"`
}

func readSARIF(raw []byte) (string, []importedRule, error) {
	var file sarifFile
	if err := json.Unmarshal(raw, &file); err != nil {
		return "", nil, err
	}
	rules := newRuleSet()
	tool := ""
	for _, run := range file.Runs {
		tool = firstOf(tool, run.Tool.Driver.Name)
		described := map[string]sarifRule{}
		for _, rule := range run.Tool.Driver.Rules {
			described[rule.ID] = rule
		}
		for _, result := range run.Results {
			meta := described[result.RuleID]
			rule := rules.add(importedRule{
				id:       firstOf(result.RuleID, "unnamed"),
				title:    firstOf(meta.ShortDescription.Text, result.RuleID),
				category: categorySecurity,
				severity: severityFromSARIF(firstOf(result.Level, meta.Default.Level)),
				wrong:    firstOf(meta.FullDescription.Text, meta.ShortDescription.Text),
				remedy:   firstLine(meta.Help.Text),
			})
			rule.findings = append(rule.findings, importedFinding{
				object: sarifIdentity(result),
				detail: messageLine(result.Message.Text),
			})
			if err := rules.bounded(); err != nil {
				return "", nil, err
			}
		}
	}
	return tool, rules.list(), nil
}

func sarifIdentity(result sarifResult) importedObject {
	for _, location := range result.Locations {
		for _, logical := range location.Logical {
			if object, ok := objectFromQualifiedName(logical.FullyQualifiedName); ok {
				return object
			}
		}
	}
	if object, ok := objectFromMessage(result.Message.Text); ok {
		return object
	}
	for _, location := range result.Locations {
		if location.Physical.Artifact.URI != "" {
			return importedObject{name: location.Physical.Artifact.URI}
		}
	}
	return importedObject{name: "unnamed"}
}

func objectFromQualifiedName(name string) (importedObject, bool) {
	parts := strings.Split(name, "/")
	switch len(parts) {
	case 3:
		return importedObject{kind: parts[0], namespace: parts[1], name: parts[2]}, parts[0] != "" && parts[2] != ""
	case 2:
		return importedObject{kind: parts[0], name: parts[1]}, parts[0] != "" && parts[1] != ""
	default:
		return importedObject{}, false
	}
}

func objectFromMessage(text string) (importedObject, bool) {
	for line := range strings.SplitSeq(text, "\n") {
		object, ok := objectInLine(line)
		if ok {
			return object, true
		}
	}
	return importedObject{}, false
}

func objectInLine(line string) (importedObject, bool) {
	words := strings.Fields(line)
	for at := 0; at+1 < len(words); at++ {
		kind := words[at]
		quoted := words[at+1]
		if !isKindWord(kind) || !strings.HasPrefix(quoted, "'") {
			continue
		}
		name := strings.Trim(quoted, "'")
		if name == "" {
			continue
		}
		return importedObject{kind: kind, name: name}, true
	}
	return importedObject{}, false
}

var kindWords = map[string]bool{
	"Pod": true, "Deployment": true, "StatefulSet": true, "DaemonSet": true, "ReplicaSet": true,
	"ReplicationController": true, "Job": true, "CronJob": true, "Service": true, "Ingress": true,
	"ConfigMap": true, "Secret": true, "ServiceAccount": true, "Role": true, "ClusterRole": true,
	"RoleBinding": true, "ClusterRoleBinding": true, "Namespace": true, "NetworkPolicy": true,
	"PersistentVolumeClaim": true, "LimitRange": true, "ResourceQuota": true, "Node": true,
}

func isKindWord(word string) bool {
	return kindWords[word]
}

type ruleSet struct {
	byID    map[string]int
	entries []*importedRule
	items   int
}

func newRuleSet() *ruleSet {
	return &ruleSet{byID: map[string]int{}}
}

func (s *ruleSet) add(rule importedRule) *importedRule {
	s.items++
	at, seen := s.byID[rule.id]
	if seen {
		return s.entries[at]
	}
	s.byID[rule.id] = len(s.entries)
	held := rule
	s.entries = append(s.entries, &held)
	return &held
}

func (s *ruleSet) bounded() error {
	if len(s.entries) > maxImportRules {
		return errors.New("more than " + itoa(maxImportRules) + " distinct rules")
	}
	if s.items > maxImportItems {
		return errors.New("more than " + itoa(maxImportItems) + " findings")
	}
	return nil
}

func (s *ruleSet) list() []importedRule {
	out := make([]importedRule, 0, len(s.entries))
	for _, rule := range s.entries {
		out = append(out, *rule)
	}
	return out
}

func severityFromScanner(raw string) string {
	switch strings.ToLower(raw) {
	case "critical", "high":
		return severityHigh
	case "medium":
		return severityMedium
	default:
		return severityLow
	}
}

func severityFromSARIF(level string) string {
	switch level {
	case "error":
		return severityHigh
	case "warning":
		return severityMedium
	default:
		return severityLow
	}
}

func splitAPIVersion(apiVersion string) (group, version string) {
	group, version, found := strings.Cut(apiVersion, "/")
	if !found {
		return "", group
	}
	return group, version
}

func firstOf(values ...string) string {
	for _, value := range values {
		if value != "" {
			return value
		}
	}
	return ""
}

func firstLine(text string) string {
	line, _, _ := strings.Cut(text, "\n")
	return strings.TrimSpace(line)
}

func messageLine(text string) string {
	for line := range strings.SplitSeq(text, "\n") {
		if rest, found := strings.CutPrefix(strings.TrimSpace(line), "Message: "); found {
			return rest
		}
	}
	return firstLine(text)
}

type importOrigin struct {
	sources  []string
	taken    time.Time
	findings []importedFinding
}

func importedChecks(files []importFile) []check {
	byID := map[string]int{}
	out := []check{}
	for _, file := range files {
		for _, rule := range file.rules {
			id := file.tool + "/" + rule.id
			at, seen := byID[id]
			if !seen {
				at = len(out)
				byID[id] = at
				out = append(out, check{
					id:       id,
					title:    rule.title,
					category: rule.category,
					severity: firstOf(rule.severity, severityMedium),
					wrong:    firstOf(rule.wrong, rule.title),
					remedy:   firstOf(rule.remedy, "See the report that found it."),
					imported: &importOrigin{taken: file.taken},
				})
			}
			entry := &out[at]
			if !slices.Contains(entry.imported.sources, file.path) {
				entry.imported.sources = append(entry.imported.sources, file.path)
			}
			if file.taken.Before(entry.imported.taken) {
				entry.imported.taken = file.taken
			}
			entry.imported.findings = append(entry.imported.findings, rule.findings...)
		}
	}
	for at := range out {
		out[at].find = importFinder(out[at].imported.findings)
	}
	return out
}

func importFinder(items []importedFinding) finder {
	return func(sc scan) []found {
		known := identitiesOf(sc)
		out := make([]found, 0, len(items))
		for _, item := range items {
			subject, matched := known.lookup(item.object)
			out = append(out, found{
				subject:   subject,
				container: item.container,
				detail:    item.detail,
				unmatched: !matched,
			})
		}
		return out
	}
}

type identities struct {
	exact  map[string]Subject
	byName map[string][]Subject
}

func identityKey(kind, namespace, name string) string {
	return kind + "\x00" + namespace + "\x00" + name
}

func identitiesOf(sc scan) identities {
	out := identities{exact: map[string]Subject{}, byName: map[string][]Subject{}}
	for _, item := range sc.held.everything() {
		out.remember(subjectFromHeld(item))
	}
	for _, subject := range sc.subjects {
		out.remember(subject)
	}
	return out
}

func (i identities) remember(subject Subject) {
	key := identityKey(subject.Kind, subject.Ref.Namespace, subject.Ref.Name)
	_, seen := i.exact[key]
	i.exact[key] = subject
	if seen {
		return
	}
	loose := identityKey(subject.Kind, "", subject.Ref.Name)
	i.byName[loose] = append(i.byName[loose], subject)
}

func (i identities) lookup(object importedObject) (Subject, bool) {
	if subject, ok := i.exact[identityKey(object.kind, object.namespace, object.name)]; ok {
		return subject, true
	}
	if object.namespace == "" {
		if candidates := i.byName[identityKey(object.kind, "", object.name)]; len(candidates) == 1 {
			return candidates[0], true
		}
	}
	return placeholderSubject(object), false
}

func placeholderSubject(object importedObject) Subject {
	return Subject{
		Ref: api.ObjectRef{
			Group:     object.group,
			Version:   object.version,
			Namespace: object.namespace,
			Name:      object.name,
		},
		Kind: object.kind,
	}
}

func itoa(value int) string {
	return strconv.Itoa(value)
}