github.com/wolfi-dev/wolfictl@v0.16.11/pkg/cli/components/advisory/matchwatcher/model.go (about)

     1  package matchwatcher
     2  
     3  import (
     4  	"fmt"
     5  	"strings"
     6  
     7  	"github.com/charmbracelet/bubbles/spinner"
     8  	tea "github.com/charmbracelet/bubbletea"
     9  	"github.com/charmbracelet/lipgloss"
    10  	"github.com/savioxavier/termlink"
    11  	"github.com/wolfi-dev/wolfictl/pkg/cli/styles"
    12  	"github.com/wolfi-dev/wolfictl/pkg/vuln"
    13  )
    14  
    15  var _ tea.Model = (*Model)(nil)
    16  
    17  var (
    18  	helpKeyStyle         = styles.FaintAccent().Copy().Bold(true)
    19  	helpExplanationStyle = styles.Faint().Copy()
    20  
    21  	styleSubtle = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
    22  
    23  	styleNegligible = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999"))
    24  	styleLow        = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00"))
    25  	styleMedium     = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffff00"))
    26  	styleHigh       = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff9900"))
    27  	styleCritical   = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000"))
    28  )
    29  
    30  func New(vulnEvents chan interface{}, countTotalPackages int) Model {
    31  	return Model{
    32  		vulnEvents:         vulnEvents,
    33  		countTotalPackages: countTotalPackages,
    34  		spinner: spinner.New(
    35  			spinner.WithSpinner(spinner.Points),
    36  			spinner.WithStyle(styles.Faint()),
    37  		),
    38  	}
    39  }
    40  
    41  type Model struct {
    42  	// Err is an output the Model can return if something goes wrong.
    43  	Err error
    44  
    45  	vulnEvents         <-chan interface{}
    46  	countTotalPackages int
    47  
    48  	countPassedPackages int
    49  	countFailedPackages int
    50  
    51  	packages        []string
    52  	packageStateMap map[string]packageState
    53  
    54  	showEveryVulnerability bool
    55  
    56  	exiting bool
    57  
    58  	spinner spinner.Model
    59  }
    60  
    61  type packageState struct {
    62  	name         string
    63  	searching    bool
    64  	matchesFound []vuln.Match
    65  }
    66  
    67  func (m Model) appendPackage(ps packageState) Model {
    68  	if m.packageStateMap == nil {
    69  		m.packageStateMap = make(map[string]packageState)
    70  	}
    71  
    72  	m.packages = append(m.packages, ps.name)
    73  	m.packageStateMap[ps.name] = ps
    74  	return m
    75  }
    76  
    77  func (m Model) updatePackage(ps packageState) Model {
    78  	if m.packageStateMap == nil {
    79  		m.packageStateMap = make(map[string]packageState)
    80  	}
    81  
    82  	m.packageStateMap[ps.name] = ps
    83  	return m
    84  }
    85  
    86  func (m Model) removePackage(name string) Model {
    87  	if m.packageStateMap == nil {
    88  		m.packageStateMap = make(map[string]packageState)
    89  	}
    90  
    91  	delete(m.packageStateMap, name)
    92  	for i, pkg := range m.packages {
    93  		if pkg == name {
    94  			m.packages = append(m.packages[:i], m.packages[i+1:]...)
    95  			break
    96  		}
    97  	}
    98  	return m
    99  }
   100  
   101  func (m Model) Init() tea.Cmd {
   102  	return tea.Batch(
   103  		m.processNextEventCmd(),
   104  		m.spinner.Tick,
   105  	)
   106  }
   107  
   108  func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   109  	switch msg := msg.(type) {
   110  	case tea.KeyMsg:
   111  		switch msg.String() {
   112  		case "ctrl+c":
   113  			m.exiting = true
   114  			return m, tea.Quit
   115  
   116  		case "v":
   117  			if m.showEveryVulnerability {
   118  				m.showEveryVulnerability = false
   119  			} else {
   120  				m.showEveryVulnerability = true
   121  			}
   122  			return m, nil
   123  
   124  		default:
   125  			return m, nil
   126  		}
   127  
   128  	case packageState:
   129  		if msg.searching {
   130  			return m.appendPackage(msg), m.processNextEventCmd()
   131  		}
   132  
   133  		// If the scan was clean, we don't need to show the package anymore.
   134  		if len(msg.matchesFound) == 0 {
   135  			m.countPassedPackages++
   136  			m = m.removePackage(msg.name)
   137  		} else {
   138  			m.countFailedPackages++
   139  			m = m.updatePackage(msg)
   140  		}
   141  
   142  		return m, m.processNextEventCmd()
   143  
   144  	case errMsg:
   145  		m.Err = msg.err
   146  		return m, tea.Quit
   147  
   148  	case doneMsg:
   149  		m.exiting = true
   150  		return m, tea.Quit
   151  
   152  	default:
   153  		var cmd tea.Cmd
   154  		m.spinner, cmd = m.spinner.Update(msg)
   155  		return m, cmd
   156  	}
   157  }
   158  
   159  func (m Model) processNextEventCmd() tea.Cmd {
   160  	return func() tea.Msg {
   161  		// TODO: make this preempt-able
   162  		if m.vulnEvents == nil {
   163  			return nil
   164  		}
   165  
   166  		e := <-m.vulnEvents
   167  		switch e := e.(type) {
   168  		case vuln.EventPackageMatchingStarting:
   169  			return packageState{
   170  				name:      e.Package,
   171  				searching: true,
   172  			}
   173  
   174  		case vuln.EventPackageMatchingFinished:
   175  			return packageState{
   176  				name:         e.Package,
   177  				searching:    false,
   178  				matchesFound: e.Matches,
   179  			}
   180  
   181  		case vuln.EventPackageMatchingError:
   182  			return errMsg{
   183  				err: e.Err,
   184  			}
   185  
   186  		case vuln.EventMatchingFinished:
   187  			return doneMsg{}
   188  		}
   189  
   190  		return nil
   191  	}
   192  }
   193  
   194  // doneMsg is a message that is sent when the matching reporter is done.
   195  type doneMsg struct{}
   196  
   197  type errMsg struct {
   198  	err error
   199  }
   200  
   201  func (m Model) View() string {
   202  	// Summary
   203  
   204  	viewSummary := styles.Secondary().Render(fmt.Sprintf(
   205  		"%d clean, %d vulnerable, %d remaining",
   206  		m.countPassedPackages,
   207  		m.countFailedPackages,
   208  		m.countTotalPackages-m.countPassedPackages-m.countFailedPackages,
   209  	))
   210  
   211  	// Package list
   212  
   213  	var searchingPackageRows []string
   214  	var vulnerablePackageRows []string
   215  
   216  	for _, pkg := range m.packages {
   217  		state := m.packageStateMap[pkg]
   218  
   219  		if state.searching {
   220  			msg := fmt.Sprintf(
   221  				"%s %s %s",
   222  				m.spinner.View(),
   223  				styles.Secondary().Render("searching for vulnerabilities:"),
   224  				pkg,
   225  			)
   226  			searchingPackageRows = append(searchingPackageRows, msg)
   227  
   228  			continue
   229  		}
   230  
   231  		vulnCount := len(state.matchesFound)
   232  		if vulnCount == 0 {
   233  			continue
   234  		}
   235  
   236  		if m.showEveryVulnerability {
   237  			row := strings.Builder{}
   238  			fmt.Fprintf(&row, "%s", pkg)
   239  
   240  			for i := range state.matchesFound {
   241  				match := state.matchesFound[i]
   242  				severity := ""
   243  				if match.Vulnerability.Severity != "" {
   244  					severity = renderSeverity(match.Vulnerability.Severity) + " "
   245  				}
   246  				fmt.Fprintf(&row, "\n  %s%s", severity, styleSubtle.Render(hyperlinkCVE(match.Vulnerability.ID)))
   247  			}
   248  
   249  			vulnerablePackageRows = append(vulnerablePackageRows, row.String())
   250  			continue
   251  		}
   252  
   253  		vulnsWord := "vulnerability"
   254  		if vulnCount != 1 {
   255  			vulnsWord = "vulnerabilities"
   256  		}
   257  
   258  		row := fmt.Sprintf(
   259  			"%s: %s",
   260  			pkg,
   261  			styles.Accented().Render(fmt.Sprintf("%d new %s", vulnCount, vulnsWord)),
   262  		)
   263  		vulnerablePackageRows = append(vulnerablePackageRows, row)
   264  	}
   265  
   266  	viewSearchingPackages := strings.Join(searchingPackageRows, "\n")
   267  	viewVulnerablePackages := strings.Join(vulnerablePackageRows, "\n")
   268  
   269  	// Help text (only if there are vulnerabilities)
   270  	viewHelp := ""
   271  	if m.countFailedPackages > 0 {
   272  		action := "expand"
   273  		if m.showEveryVulnerability {
   274  			action = "collapse"
   275  		}
   276  
   277  		viewHelp = fmt.Sprintf("%s%s",
   278  			helpKeyStyle.Render("v"),
   279  			helpExplanationStyle.Render(" to "+action+" vulnerabilities"),
   280  		)
   281  	}
   282  
   283  	// Put it all together
   284  
   285  	if viewSearchingPackages != "" {
   286  		viewSearchingPackages += "\n\n"
   287  	}
   288  
   289  	if viewVulnerablePackages != "" {
   290  		viewVulnerablePackages += "\n\n"
   291  	}
   292  
   293  	if m.exiting {
   294  		return fmt.Sprintf(
   295  			"%s%s",
   296  			viewVulnerablePackages,
   297  			viewSummary+"\n",
   298  		)
   299  	}
   300  
   301  	return fmt.Sprintf(
   302  		"%s%s%s%s",
   303  		viewSearchingPackages,
   304  		viewVulnerablePackages,
   305  		viewSummary+"\n",
   306  		viewHelp+"\n",
   307  	)
   308  }
   309  
   310  var termSupportsHyperlinks = termlink.SupportsHyperlinks()
   311  
   312  func hyperlinkCVE(id string) string {
   313  	if !termSupportsHyperlinks {
   314  		return id
   315  	}
   316  
   317  	return termlink.Link(id, fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", id))
   318  }
   319  
   320  func renderSeverity(severity vuln.Severity) string {
   321  	s := string(severity)
   322  
   323  	switch severity {
   324  	case vuln.SeverityLow:
   325  		return styleLow.Render(s)
   326  	case vuln.SeverityMedium:
   327  		return styleMedium.Render(s)
   328  	case vuln.SeverityHigh:
   329  		return styleHigh.Render(s)
   330  	case vuln.SeverityCritical:
   331  		return styleCritical.Render(s)
   332  	default:
   333  		return styleNegligible.Render(s)
   334  	}
   335  }