From 62ec80f55e294e0b056b223dd077e45747d1c46f Mon Sep 17 00:00:00 2001 From: egibs <20933572+egibs@users.noreply.github.com> Date: Fri, 6 Dec 2024 18:34:04 -0600 Subject: [PATCH] [WIP] Add BubbleTea TUI Signed-off-by: egibs <20933572+egibs@users.noreply.github.com> --- cmd/mal/mal.go | 22 +- go.mod | 13 ++ go.sum | 27 +++ pkg/action/scan.go | 6 +- pkg/malcontent/malcontent.go | 1 + pkg/render/json.go | 2 + pkg/render/markdown.go | 2 + pkg/render/render.go | 4 + pkg/render/simple.go | 2 + pkg/render/strings.go | 2 + pkg/render/tea.go | 431 +++++++++++++++++++++++++++++++++++ pkg/render/tea_style.go | 277 ++++++++++++++++++++++ pkg/render/terminal.go | 2 + pkg/render/terminal_brief.go | 2 + pkg/render/yaml.go | 2 + 15 files changed, 782 insertions(+), 13 deletions(-) create mode 100644 pkg/render/tea.go create mode 100644 pkg/render/tea_style.go diff --git a/cmd/mal/mal.go b/cmd/mal/mal.go index 523e2b726..2cc466988 100644 --- a/cmd/mal/mal.go +++ b/cmd/mal/mal.go @@ -281,7 +281,7 @@ func main() { &cli.StringFlag{ Name: "format", Value: "auto", - Usage: "Output format (json, markdown, simple, strings, terminal, yaml)", + Usage: "Output format (json, markdown, simple, strings, terminal, tui, yaml)", Destination: &formatFlag, }, &cli.BoolFlag{ @@ -536,25 +536,27 @@ func main() { } res, err = action.Scan(ctx, mc) - if err != nil { + if err != nil && renderer.Name() != "BubbleTeaTerminal" { returnCode = ExitActionFailed return fmt.Errorf("scan: %w", err) } - err = renderer.Full(ctx, res) - if err != nil { - returnCode = ExitRenderFailed - return err - } - - if length := func(m *sync.Map) int { + length := func(m *sync.Map) int { length := 0 m.Range(func(_, _ any) bool { length++ return true }) return length - }(&res.Files); length > 0 { + }(&res.Files) + + err = renderer.Full(ctx, res) + if err != nil { + returnCode = ExitRenderFailed + return err + } + + if length > 0 && mc.Renderer.Name() != "BubbleTeaTerminal" { fmt.Fprintf(os.Stderr, "\nšŸ’” For detailed analysis, try \"mal analyze \"\n") } diff --git a/go.mod b/go.mod index 1753f8562..6def2d087 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,9 @@ require ( github.com/cavaliergopher/cpio v1.0.1 github.com/cavaliergopher/rpm v1.2.0 github.com/chainguard-dev/clog v1.5.1 + github.com/charmbracelet/bubbles v0.20.1-0.20241115220041-e5296a2b0fd6 + github.com/charmbracelet/bubbletea v1.2.3 + github.com/charmbracelet/lipgloss v1.0.0 github.com/egibs/go-debian v0.18.0 github.com/fatih/color v1.18.0 github.com/gabriel-vasile/mimetype v1.4.7 @@ -24,23 +27,32 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/docker/cli v27.3.1+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/ebitengine/purego v0.8.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/kr/pretty v0.2.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -56,5 +68,6 @@ require ( golang.org/x/crypto v0.29.0 // indirect golang.org/x/net v0.31.0 // indirect golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect pault.ag/go/topsort v0.1.1 // indirect ) diff --git a/go.sum b/go.sum index eb963b4ce..770faf7fa 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= @@ -10,6 +12,16 @@ github.com/cavaliergopher/rpm v1.2.0 h1:s0h+QeVK252QFTolkhGiMeQ1f+tMeIMhGl8B1HUm github.com/cavaliergopher/rpm v1.2.0/go.mod h1:R0q3vTqa7RUvPofAZYrnjJ63hh2vngjFfphuXiExVos= github.com/chainguard-dev/clog v1.5.1 h1:LeFeVlxiicswuTevtaXc0MXH1zV1iWkbg+H8iUuBTtQ= github.com/chainguard-dev/clog v1.5.1/go.mod h1:4+WFhRMsGH79etYXY3plYdp+tCz/KCkU8fAr0HoaPvs= +github.com/charmbracelet/bubbles v0.20.1-0.20241115220041-e5296a2b0fd6 h1:GJISC755OMVHyl7Zw9ziGFAXhbwr6FprHjxJYke8lGc= +github.com/charmbracelet/bubbles v0.20.1-0.20241115220041-e5296a2b0fd6/go.mod h1:1WuvnY5+/Kf/JMo0ispmMqILezqZMLBKac+4VANbhfs= +github.com/charmbracelet/bubbletea v1.2.3 h1:d9MdMsANIYZB5pE1KkRqaUV6GfsiWm+/9z4fTuGVm9I= +github.com/charmbracelet/bubbletea v1.2.3/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= +github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU= github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= @@ -27,6 +39,8 @@ github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/ github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/egibs/go-debian v0.18.0 h1:NxOTNOJzrjX/hrz+bKcz9TTzyLfuK6u9ytOB6ROZnW4= github.com/egibs/go-debian v0.18.0/go.mod h1:DRm6WbNRb5VHPAnwpcvSo/0wlnyQnlmJ+5mec1g+qKA= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= @@ -48,6 +62,8 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -57,11 +73,19 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -111,6 +135,7 @@ golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -119,6 +144,8 @@ golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/action/scan.go b/pkg/action/scan.go index 67842559b..33b9e22a8 100644 --- a/pkg/action/scan.go +++ b/pkg/action/scan.go @@ -104,7 +104,7 @@ func scanSinglePath(ctx context.Context, c malcontent.Config, path string, ruleF mime := "" kind, err := programkind.File(path) - if err != nil { + if err != nil && c.Renderer.Name() != "BubbleTeaTerminal" { logger.Errorf("file type failure: %s: %s", path, err) } if kind != nil { @@ -117,7 +117,7 @@ func scanSinglePath(ctx context.Context, c malcontent.Config, path string, ruleF logger = logger.With("mime", mime) f, err := os.Open(path) - if err != nil { + if err != nil && c.Renderer.Name() != "BubbleTeaTerminal" { return nil, err } defer f.Close() @@ -541,7 +541,7 @@ func processFile(ctx context.Context, c malcontent.Config, ruleFS []fs.FS, path return nil, nil } - if fr.Error != "" { + if fr.Error != "" && c.Renderer.Name() != "BubbleTeaTerminal" { logger.Errorf("scan error: %s", fr.Error) return nil, fmt.Errorf("report error: %v", fr.Error) } diff --git a/pkg/malcontent/malcontent.go b/pkg/malcontent/malcontent.go index 480bf0ce0..49319b0e4 100644 --- a/pkg/malcontent/malcontent.go +++ b/pkg/malcontent/malcontent.go @@ -18,6 +18,7 @@ type Renderer interface { Scanning(context.Context, string) File(context.Context, *FileReport) error Full(context.Context, *Report) error + Name() string } type Config struct { diff --git a/pkg/render/json.go b/pkg/render/json.go index 7b1a97184..21d6ab30e 100644 --- a/pkg/render/json.go +++ b/pkg/render/json.go @@ -20,6 +20,8 @@ func NewJSON(w io.Writer) JSON { return JSON{w: w} } +func (r JSON) Name() string { return "JSON" } + func (r JSON) Scanning(_ context.Context, _ string) {} func (r JSON) File(_ context.Context, _ *malcontent.FileReport) error { diff --git a/pkg/render/markdown.go b/pkg/render/markdown.go index 63298629b..0b58cdfee 100644 --- a/pkg/render/markdown.go +++ b/pkg/render/markdown.go @@ -41,6 +41,8 @@ func matchFragmentLink(s string) string { return fmt.Sprintf("[%s](https://github.com/search?q=%s&type=code)", s, url.QueryEscape(s)) } +func (r Markdown) Name() string { return "Markdown" } + func (r Markdown) Scanning(_ context.Context, _ string) {} func (r Markdown) File(ctx context.Context, fr *malcontent.FileReport) error { diff --git a/pkg/render/render.go b/pkg/render/render.go index 8d94a2784..19a078f6f 100644 --- a/pkg/render/render.go +++ b/pkg/render/render.go @@ -34,6 +34,10 @@ func New(kind string, w io.Writer) (malcontent.Renderer, error) { return NewSimple(w), nil case "strings": return NewStringMatches(w), nil + case "interactive": + t := NewInteractive(w) + t.Start() + return t, nil default: return nil, fmt.Errorf("unknown renderer: %q", kind) } diff --git a/pkg/render/simple.go b/pkg/render/simple.go index fe9f52814..ec3093c0b 100644 --- a/pkg/render/simple.go +++ b/pkg/render/simple.go @@ -21,6 +21,8 @@ func NewSimple(w io.Writer) Simple { return Simple{w: w} } +func (r Simple) Name() string { return "Simple" } + func (r Simple) Scanning(_ context.Context, _ string) {} func (r Simple) File(_ context.Context, fr *malcontent.FileReport) error { diff --git a/pkg/render/strings.go b/pkg/render/strings.go index 54faf3416..269e76047 100644 --- a/pkg/render/strings.go +++ b/pkg/render/strings.go @@ -73,6 +73,8 @@ type Match struct { Strings []string } +func (r StringMatches) Name() string { return "TerminalStrings" } + func (r StringMatches) Scanning(_ context.Context, path string) { fmt.Fprintf(r.w, "šŸ”Ž Scanning %q\n", path) } diff --git a/pkg/render/tea.go b/pkg/render/tea.go new file mode 100644 index 000000000..4214bf3b3 --- /dev/null +++ b/pkg/render/tea.go @@ -0,0 +1,431 @@ +package render + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/chainguard-dev/malcontent/pkg/malcontent" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type scanUpdateMsg struct { + path string +} + +type resultUpdateMsg struct { + content string + isResult bool +} + +type scanCompleteMsg struct{} + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("211")). + MarginLeft(2) + + statusStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#666666", Dark: "#999999"}). + MarginLeft(2). + MaxHeight(1). + Inline(true) // Force inline rendering + + viewportStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("62")). + Padding(1, 2) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) + + searchPromptStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + + matchStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("230")) + + progressStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")) +) + +type mainModel struct { + content []string + currentFile string + errors []string + height int + quitting bool + ready bool + resultCount int + searchMode bool + searchTerm string + spinner spinner.Model + viewport viewport.Model + width int +} + +func newMainModel() mainModel { + s := spinner.New() + style := spinner.Spinner{ + Frames: []string{"šŸ”", "šŸ”Ž"}, + FPS: time.Second / 4, + } + s.Spinner = style + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + return mainModel{ + content: make([]string, 0), + quitting: false, + ready: false, + searchMode: false, + spinner: s, + } +} + +func (m mainModel) Init() tea.Cmd { + return tea.Batch( + tea.EnterAltScreen, + m.spinner.Tick, + ) +} + +func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + headerHeight := 3 + footerHeight := 2 + verticalMarginHeight := headerHeight + footerHeight + + if !m.ready { + m.viewport = viewport.New(msg.Width-4, msg.Height-verticalMarginHeight) + m.viewport.Style = viewportStyle + m.viewport.SetContent(strings.Join(m.content, "\n")) + m.ready = true + } else { + m.viewport.Width = msg.Width - 4 + m.viewport.Height = msg.Height - verticalMarginHeight + } + m.width = msg.Width + m.height = msg.Height + + case scanUpdateMsg: + m.currentFile = msg.path + return m, cmd + + case resultUpdateMsg: + newContent := msg.content + if len(m.content) > 0 { + newContent = "\n" + newContent + } + + if strings.Contains(newContent, ": permission denied") || + strings.Contains(newContent, "skipped") { + m.errors = append(m.errors, strings.TrimSpace(newContent)) + } else { + m.content = append(m.content, strings.TrimSpace(newContent)) + if msg.isResult { + m.resultCount++ + } + } + + // Update viewport to show both content and errors + var displayContent []string + displayContent = append(displayContent, m.content...) + // if len(m.errors) > 0 { + // if len(displayContent) > 0 { + // displayContent = append(displayContent, "") + // } + // displayContent = append(displayContent, "Errors:") + // displayContent = append(displayContent, m.errors...) + // } + + m.viewport.SetContent(strings.Join(displayContent, "\n")) + m.viewport.GotoBottom() + return m, cmd + + case tea.KeyMsg: + if m.searchMode { + switch msg.String() { + case "esc": + m.searchMode = false + m.searchTerm = "" + m.resetContent() + case "enter": + m.searchMode = false + m.performSearch() + case "backspace": + if len(m.searchTerm) > 0 { + m.searchTerm = m.searchTerm[:len(m.searchTerm)-1] + m.performSearch() + } + default: + if len(msg.String()) == 1 { + m.searchTerm += msg.String() + m.performSearch() + } + } + return m, nil + } + + switch msg.String() { + case "q", "ctrl+c", "esc": + m.quitting = true + return m, tea.Quit + case "up", "k": + m.viewport.LineUp(1) + case "down", "j": + m.viewport.LineDown(1) + case "pgup": + m.viewport.HalfViewUp() + case "pgdown": + m.viewport.HalfViewDown() + case "home": + m.viewport.GotoTop() + case "end": + m.viewport.GotoBottom() + case "/": + m.searchMode = true + m.searchTerm = "" + } + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case scanCompleteMsg: + m.currentFile = "" + return m, nil + } + + m.viewport, cmd = m.viewport.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *mainModel) performSearch() { + if m.searchTerm == "" { + m.resetContent() + return + } + + term := strings.ToLower(m.searchTerm) + var matchedLines []string + var foundMatch bool + + for _, line := range m.content { + lowerLine := strings.ToLower(line) + if strings.Contains(lowerLine, term) { + foundMatch = true + lastIndex := 0 + resultLine := "" + for { + index := strings.Index(strings.ToLower(line[lastIndex:]), term) + if index == -1 { + resultLine += line[lastIndex:] + break + } + resultLine += line[lastIndex : lastIndex+index] + matchText := line[lastIndex+index : lastIndex+index+len(term)] + resultLine += matchStyle.Render(matchText) + lastIndex += index + len(term) + } + matchedLines = append(matchedLines, resultLine) + } else if foundMatch { + matchedLines = append(matchedLines, line) + foundMatch = false + } + } + + if len(matchedLines) > 0 { + m.viewport.SetContent(strings.Join(matchedLines, "\n")) + } else { + m.viewport.SetContent("No matches found for: " + m.searchTerm) + } +} + +func (m *mainModel) resetContent() { + m.viewport.SetContent(strings.Join(m.content, "\n")) +} + +func (m mainModel) View() string { + var b strings.Builder + + // Render header with controls + header := titleStyle.Render("malcontent scan results") + controls := helpStyle.Render("ā†‘/ā†“: scroll ā€¢ /: search ā€¢ q: quit") + gap := strings.Repeat(" ", max(0, m.width-lipgloss.Width(header)-lipgloss.Width(controls))) + headerLine := lipgloss.JoinHorizontal(lipgloss.Center, header, gap, controls) + b.WriteString(headerLine) + b.WriteString("\n") + + // Render scan status if active + if m.currentFile != "" { + scanLine := progressStyle.Render(fmt.Sprintf("%s Scanning: %s", m.spinner.View(), m.currentFile)) + b.WriteString(scanLine) + b.WriteString("\n") + } + + // Render search bar if in search mode + if m.searchMode { + prompt := searchPromptStyle.Render("Search:") + cursor := m.searchTerm + "ā–ˆ" + searchLine := lipgloss.JoinHorizontal(lipgloss.Left, prompt, " ", cursor) + b.WriteString(searchLine) + b.WriteString("\n") + } + + // Viewport content + b.WriteString(m.viewport.View()) + + // Single line footer + footerContent := fmt.Sprintf("Found %d results", m.resultCount) + if m.searchMode && m.searchTerm != "" { + footerContent += fmt.Sprintf(" (searching for: %q)", m.searchTerm) + } + footer := statusStyle.Width(m.width).Render(footerContent) + + // Ensure footer is rendered below viewport with proper spacing + return fmt.Sprintf("%s\n%s", strings.TrimRight(b.String(), "\n"), footer) +} + +type Interactive struct { + writer io.Writer + model *mainModel + program *tea.Program + mu sync.Mutex + wg sync.WaitGroup +} + +func NewInteractive(w io.Writer) *Interactive { + if w == nil { + w = os.Stdout + } + model := newMainModel() + program := tea.NewProgram( + model, + tea.WithAltScreen(), + ) + + return &Interactive{ + writer: w, + model: &model, + program: program, + } +} + +func (r *Interactive) Name() string { + return "BubbleTeaTerminal" +} + +func (r *Interactive) Start() { + r.wg.Add(1) + go func() { + defer r.wg.Done() + if _, err := r.program.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error running program: %v\n", err) + } + }() +} + +func (r *Interactive) Scanning(_ context.Context, path string) { + r.mu.Lock() + defer r.mu.Unlock() + r.program.Send(scanUpdateMsg{path: path}) +} + +func (r *Interactive) File(ctx context.Context, fr *malcontent.FileReport) error { + r.mu.Lock() + defer r.mu.Unlock() + + if fr == nil { + return nil + } + + var content string + switch { + case fr.Error != "": + content = fmt.Sprintf("error scanning %s: %s", fr.Path, fr.Error) + case fr.Skipped != "": + content = fmt.Sprintf("skipped %s: %s", fr.Path, fr.Skipped) + case len(fr.Behaviors) > 0: + var builder strings.Builder + renderFileSummaryTea(ctx, fr, &builder, tableConfig{ + Title: fmt.Sprintf("%s %s", fr.Path, darkBrackets(riskInColor(fr.RiskLevel))), + }) + content = strings.TrimSpace(builder.String()) + } + + if content != "" { + r.program.Send(resultUpdateMsg{ + content: content, + isResult: len(fr.Behaviors) > 0, + }) + } + + return nil +} + +func (r *Interactive) Full(ctx context.Context, rep *malcontent.Report) error { + defer func() { + r.program.Send(scanCompleteMsg{}) + r.wg.Wait() + }() + + if rep == nil { + return nil + } + + r.mu.Lock() + defer r.mu.Unlock() + + processFile := func(fr *malcontent.FileReport, prefix string) { + if fr != nil { + var builder strings.Builder + renderFileSummaryTea(ctx, fr, &builder, tableConfig{ + Title: fmt.Sprintf("%s: %s", prefix, fr.Path), + }) + content := strings.TrimSpace(builder.String()) + r.program.Send(resultUpdateMsg{ + content: content, + isResult: true, + }) + } + } + + if rep.Diff != nil { + // Process all diffs, handling any potential nil values + for removed := rep.Diff.Removed.Oldest(); removed != nil; removed = removed.Next() { + processFile(removed.Value, "Removed") + } + + for added := rep.Diff.Added.Oldest(); added != nil; added = added.Next() { + processFile(added.Value, "Added") + } + + for modified := rep.Diff.Modified.Oldest(); modified != nil; modified = modified.Next() { + processFile(modified.Value, "Modified") + } + } + + return nil +} diff --git a/pkg/render/tea_style.go b/pkg/render/tea_style.go new file mode 100644 index 000000000..7d928c187 --- /dev/null +++ b/pkg/render/tea_style.go @@ -0,0 +1,277 @@ +package render + +import ( + "context" + "fmt" + "io" + "sort" + "strconv" + "strings" + + "github.com/chainguard-dev/malcontent/pkg/malcontent" + "github.com/charmbracelet/lipgloss" +) + +var ( + roundedBorder = lipgloss.Border{ + Top: "ā”€", + Bottom: "ā”€", + Left: "ā”‚", + Right: "ā”‚", + TopLeft: "ā•­", + TopRight: "ā•®", + BottomLeft: "ā•°", + BottomRight: "ā•Æ", + } + + fileBoxStyle = lipgloss.NewStyle(). + Border(roundedBorder). + BorderForeground(lipgloss.Color("238")). // neutral gray + Padding(0, 1) + + namespaceStyle = lipgloss.NewStyle(). + Bold(true). + MarginLeft(2). + MarginTop(1) + + behaviorStyle = lipgloss.NewStyle(). + MarginLeft(4) + + evidenceStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("246")). + MarginLeft(6) + + riskColors = map[string]lipgloss.Color{ + "NONE": lipgloss.Color("15"), + "LOW": lipgloss.Color("69"), + "MEDIUM": lipgloss.Color("221"), + "HIGH": lipgloss.Color("196"), + "CRITICAL": lipgloss.Color("201"), + } + + headerStyle = lipgloss.NewStyle(). + Bold(true) + + riskBadgeStyle = lipgloss.NewStyle(). + Padding(0, 1) + + diffAddedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("118")) + + diffRemovedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")) + + diffUnchangedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("69")) +) + +// cleanAndWrapEvidence handles evidence strings, including those with escape sequences. +func cleanAndWrapEvidence(evidence string, width int) string { + // Split into separate strings if multiple are present + lines := strings.Split(evidence, ", ") + + var result strings.Builder + for i, line := range lines { + if i > 0 { + result.WriteString("\n") + } + result.WriteString(" ") + + unquoted, err := strconv.Unquote(`"` + line + `"`) + if err != nil { + // If unquoting fails, use original string + unquoted = line + } + + if len(unquoted) > width { + wrapped := wrapLine(unquoted, width) + result.WriteString(wrapped) + } else { + result.WriteString(unquoted) + } + } + + return result.String() +} + +// wrapLine wraps a single line of text. +func wrapLine(text string, width int) string { + if len(text) <= width { + return text + } + + var result strings.Builder + remaining := text + firstLine := true + + for len(remaining) > 0 { + if !firstLine { + result.WriteString("\n ") // indent continuation lines + } + + chunk := remaining + if len(remaining) > width { + chunk = remaining[:width] + remaining = remaining[width:] + } else { + remaining = "" + } + + result.WriteString(chunk) + firstLine = false + } + + return result.String() +} + +func renderFileSummaryTea(_ context.Context, fr *malcontent.FileReport, w io.Writer, rc tableConfig) { + if fr.Skipped != "" { + return + } + + // Organize behaviors by namespace + byNamespace := map[string][]*malcontent.Behavior{} + nsRiskScore := map[string]int{} + previousNsRiskScore := map[string]int{} + diffMode := false + + for _, b := range fr.Behaviors { + ns, _ := splitRuleID(b.ID) + if b.DiffAdded || b.DiffRemoved { + diffMode = true + } + if !b.DiffAdded && b.RiskScore > previousNsRiskScore[ns] { + previousNsRiskScore[ns] = b.RiskScore + } + byNamespace[ns] = append(byNamespace[ns], b) + if !b.DiffRemoved && b.RiskScore > nsRiskScore[ns] { + nsRiskScore[ns] = b.RiskScore + } + } + + // Sort namespaces + nss := make([]string, 0, len(byNamespace)) + for ns := range byNamespace { + nss = append(nss, ns) + } + sort.Slice(nss, func(i, j int) bool { + return nsLongName(nss[i]) < nsLongName(nss[j]) + }) + + // Build the complete content + var content strings.Builder + + // File header with risk level + pathStyle := headerStyle. + Foreground(riskColors[fr.RiskLevel]) + + riskBadge := riskBadgeStyle. + Foreground(riskColors[fr.RiskLevel]). + Render(fr.RiskLevel) + + header := lipgloss.JoinHorizontal( + lipgloss.Center, + pathStyle.Render(fr.Path), + " ", + riskBadge, + ) + + if diffMode { + header = lipgloss.JoinHorizontal( + lipgloss.Center, + pathStyle.Render(rc.Title), + " ", + riskBadge, + ) + } + + content.WriteString(header) + content.WriteString("\n") + + // Render namespace sections + for _, ns := range nss { + bs := byNamespace[ns] + riskScore := nsRiskScore[ns] + riskLevel := riskLevels[riskScore] + + // Namespace header + nsHeader := nsLongName(ns) + if len(previousNsRiskScore) > 0 && riskScore != previousNsRiskScore[ns] { + previousRiskLevel := riskLevels[previousNsRiskScore[ns]] + riskChangeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + nsHeader = fmt.Sprintf("%s %s", + nsHeader, + riskChangeStyle.Render(fmt.Sprintf("%s ā†’ %s", + riskBadgeStyle.Foreground(riskColors[previousRiskLevel]).Render(previousRiskLevel), + riskBadgeStyle.Foreground(riskColors[riskLevel]).Render(riskLevel)))) + } else { + badgeStyle := riskBadgeStyle.Foreground(riskColors[riskLevel]).Render(riskLevel) + nsHeader = fmt.Sprintf("%s %s", + nsHeader, + badgeStyle) + } + + nsStyle := namespaceStyle.Foreground(riskColors[riskLevel]).Render(nsHeader) + content.WriteString(nsStyle) + content.WriteString("\n") + + // Render behaviors + for _, b := range bs { + _, rest := splitRuleID(b.ID) + e := evidenceString(b.MatchStrings, b.Description) + desc, _, _ := strings.Cut(b.Description, " - ") + + if b.RuleAuthor != "" { + if desc != "" { + desc = fmt.Sprintf("%s, by %s", desc, b.RuleAuthor) + } else { + desc = fmt.Sprintf("by %s", b.RuleAuthor) + } + } + + // Style behavior based on risk level and diff status + baseStyle := behaviorStyle. + Foreground(riskColors[b.RiskLevel]) + + bullet := "ā€¢" + + if diffMode { + switch { + case b.DiffAdded: + bullet = "+" + baseStyle = diffAddedStyle + case b.DiffRemoved: + bullet = "-" + baseStyle = diffRemovedStyle + e = "" + default: + baseStyle = diffUnchangedStyle + e = "" + } + } + + // Add risk level badge to behavior + behaviorRisk := riskBadgeStyle. + Foreground(riskColors[b.RiskLevel]). + Render(ShortRisk(b.RiskLevel)) + + content.WriteString(baseStyle.Render(fmt.Sprintf("%s %s %s %s", + bullet, + behaviorRisk, + rest, + desc))) + content.WriteString("\n") + + // Add evidence if present + if e != "" { + formattedEvidence := cleanAndWrapEvidence(e, 70) // Adjust width as needed + content.WriteString(evidenceStyle.Render(formattedEvidence)) + content.WriteString("\n") + } + } + } + + // Render the complete file box + fmt.Fprintln(w, fileBoxStyle.Render(content.String())) + fmt.Fprintln(w) +} diff --git a/pkg/render/terminal.go b/pkg/render/terminal.go index df036ab78..c330c89cf 100644 --- a/pkg/render/terminal.go +++ b/pkg/render/terminal.go @@ -75,6 +75,8 @@ func ShortRisk(s string) string { return short } +func (r Terminal) Name() string { return "Terminal" } + func (r Terminal) Scanning(_ context.Context, path string) { fmt.Fprintf(r.w, "šŸ”Ž Scanning %q\n", path) } diff --git a/pkg/render/terminal_brief.go b/pkg/render/terminal_brief.go index 2b3d3cdbd..d00bbe338 100644 --- a/pkg/render/terminal_brief.go +++ b/pkg/render/terminal_brief.go @@ -28,6 +28,8 @@ func NewTerminalBrief(w io.Writer) TerminalBrief { return TerminalBrief{w: w} } +func (r TerminalBrief) Name() string { return "TerminalBrief" } + func (r TerminalBrief) Scanning(_ context.Context, path string) { fmt.Fprintf(r.w, "šŸ”Ž Scanning %q\n", path) } diff --git a/pkg/render/yaml.go b/pkg/render/yaml.go index bba213835..387b72aee 100644 --- a/pkg/render/yaml.go +++ b/pkg/render/yaml.go @@ -20,6 +20,8 @@ func NewYAML(w io.Writer) YAML { return YAML{w: w} } +func (r YAML) Name() string { return "YAML" } + func (r YAML) Scanning(_ context.Context, _ string) {} func (r YAML) File(_ context.Context, _ *malcontent.FileReport) error {