internal/checks/imports.go
Rama main · réplica de lectura del repositorio original.
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)
}