From bee634d543ee292fe79d52ed02fd28ee1b89a87c Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Tue, 24 Mar 2026 17:43:40 +0100 Subject: [PATCH] feat(outputs,console): Implement console output colorization The console output allows to configure output colorization, which is much more intuitive for analysts as it helps to explore the events and callstacks. --- configs/fibratus.yml | 3 + pkg/callstack/callstack.go | 25 +++- pkg/callstack/colorize.go | 175 ++++++++++++++++++++++++ pkg/config/config.schema.json | 3 + pkg/event/formatter.go | 234 +++++++++++++++++++++++++++++++- pkg/event/param.go | 79 +++++++++++ pkg/event/types_windows.go | 118 ++++++++++++++++ pkg/outputs/console/config.go | 3 + pkg/outputs/console/console.go | 23 +++- pkg/util/colorizer/colorizer.go | 141 +++++++++++++++++++ 10 files changed, 797 insertions(+), 7 deletions(-) create mode 100644 pkg/callstack/colorize.go create mode 100644 pkg/util/colorizer/colorizer.go diff --git a/configs/fibratus.yml b/configs/fibratus.yml index d86d78952..c04cd94fc 100644 --- a/configs/fibratus.yml +++ b/configs/fibratus.yml @@ -313,6 +313,9 @@ output: # Indicates whether the console output is active enabled: true + # Indicates if the console output is colorized + colorize: true + # Specifies the console output format. The "pretty" format dictates that formatting is accomplished # by replacing the specifiers in the template. The "json" format outputs the event as a raw JSON string format: pretty diff --git a/pkg/callstack/callstack.go b/pkg/callstack/callstack.go index 6d5839157..ee9f1a325 100644 --- a/pkg/callstack/callstack.go +++ b/pkg/callstack/callstack.go @@ -1,5 +1,5 @@ /* - * Copyright 2021-2022 by Nedim Sabic Sabic + * Copyright 2021-present by Nedim Sabic Sabic * https://www.fibratus.io * All Rights Reserved. * @@ -30,6 +30,15 @@ import ( "golang.org/x/sys/windows" ) +// FrameProvenance designates the frame provenance +type FrameProvenance uint8 + +const ( + Kernel FrameProvenance = iota + System + User +) + // unbacked represents the identifier for unbacked regions in stack frames const unbacked = "unbacked" @@ -48,6 +57,20 @@ type Frame struct { ModuleAddress va.Address // module base address } +// Provenance resolves the frame provenance. +func (f Frame) Provenance() FrameProvenance { + if f.Addr.InSystemRange() { + return Kernel + } + + mod := filepath.Base(strings.ToLower(f.Module)) + if mod == "ntdll.dll" || mod == "kernel32.dll" || mod == "kernelbase.dll" { + return System + } + + return User +} + // IsUnbacked returns true if this frame is originated // from unbacked memory section func (f Frame) IsUnbacked() bool { return f.Module == unbacked } diff --git a/pkg/callstack/colorize.go b/pkg/callstack/colorize.go new file mode 100644 index 000000000..811adb352 --- /dev/null +++ b/pkg/callstack/colorize.go @@ -0,0 +1,175 @@ +/* + * Copyright 2021-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * 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 callstack + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/rabbitstack/fibratus/pkg/util/colorizer" +) + +// Colorize renders a callstack as a multi-line, +// ANSI-colourised string. It works directly with the typed Frame slice so +// no string parsing is required. +// +// Visual hierarchy per frame (left > right, dim > bright): +// +// ! +// +// Frame tiers +// ─────────── +// +// kernel – frame.Addr.InSystemRange() > magenta +// unbacked – frame.IsUnbacked() > red (highest suspicion) +// system – system module > teal +// user – everything else > amber +// +// Consecutive unresolved frames (kernel-space with no symbol) are collapsed +// into a single dim counter line to avoid flooding the view. +func (s Callstack) Colorize() string { + if s.IsEmpty() { + return "" + } + + // iterate in reverse so the outermost frame comes first + depth := s.Depth() + l := s.maxAddrLength() + + var idx int + var unresolved int + var b strings.Builder + b.Grow(depth * 100) + + flushUnresolved := func() { + if unresolved == 0 { + return + } + line := fmt.Sprintf(" %s %d unresolved %s", + colorizer.SpanDim("▸"), + unresolved, + "frame(s)", + ) + b.WriteString(colorizer.SpanDim(colorizer.Span(colorizer.Gray, line))) + b.WriteByte('\n') + unresolved = 0 + } + + for i := depth - 1; i >= 0; i-- { + f := s.FrameAt(i) + + // frames in kernel range with no resolved symbol are unresolved so + // we can collapse them into a counter + if f.Addr.InSystemRange() && (f.Symbol == "" || f.Symbol == "?") { + unresolved++ + continue + } + + flushUnresolved() + idx++ + + // draw gutter + b.WriteString(colorizer.SpanDim(colorizer.Span(colorizer.Gray, fmt.Sprintf(" %3d ", idx)))) + + // draw address + addrStr := "0x" + f.Addr.String() + paddedAddr := addrStr + strings.Repeat(" ", l-len(addrStr)) + b.WriteString(colorizer.SpanDim(colorizer.Span(colorizer.Gray, paddedAddr))) + b.WriteString(" ") + + // unbacked means execution from anonymous memory which is the highest- + // suspicion tier, rendered red regardless of address range. + if f.IsUnbacked() { + b.WriteString(f.colorizeUnbacked()) + b.WriteByte('\n') + continue + } + + clr := f.Provenance().color() + + dir := filepath.Dir(f.Module) + mod := filepath.Base(f.Module) + if dir == "." { + dir = "" + } + // module directory + if dir != "" { + dir += `\` + b.WriteString(colorizer.SpanDim(colorizer.Span(clr, dir))) + } + // module name + b.WriteString(colorizer.Span(clr, mod)) + + // symbol + b.WriteString(colorizer.SpanDim("!")) + + sym := f.Symbol + if sym == "" || sym == "?" { + sym = "?" + } + b.WriteString(colorizer.SpanBold(clr, sym)) + + // offset + if f.Offset != 0 { + b.WriteString(colorizer.SpanDim(colorizer.Span(colorizer.Gray, fmt.Sprintf("+0x%x", f.Offset)))) + } + + b.WriteByte('\n') + } + + flushUnresolved() + + return strings.TrimRight(b.String(), "\n") +} + +// maxAddrLength measure the widest address string +func (s Callstack) maxAddrLength() int { + maxw := 0 + for _, f := range s { + w := len("0x") + len(f.Addr.String()) + maxw = max(maxw, w) + } + return maxw +} + +// colorizeUnbackedFrame renders the unbacked frame. +func (f Frame) colorizeUnbacked() string { + var b strings.Builder + b.WriteString(colorizer.SpanBold(colorizer.Red, "unbacked")) + b.WriteString(colorizer.SpanDim("!")) + b.WriteString(colorizer.SpanBold(colorizer.Red, "?")) + if f.Offset != 0 { + b.WriteString(colorizer.SpanDim(colorizer.Span(colorizer.Gray, fmt.Sprintf("+0x%x", f.Offset)))) + } + return b.String() +} + +// color return frame provenance color to fill the module +// directory, module base name, and the symbol respectively. +func (p FrameProvenance) color() uint8 { + switch p { + case Kernel: + return colorizer.Magenta + case System: + return colorizer.Teal + default: + return colorizer.Amber + } +} diff --git a/pkg/config/config.schema.json b/pkg/config/config.schema.json index 0fccfc8d0..f24d078e3 100644 --- a/pkg/config/config.schema.json +++ b/pkg/config/config.schema.json @@ -498,6 +498,9 @@ "enabled": { "type": "boolean" }, + "colorize": { + "type": "boolean" + }, "format": { "type": "string", "enum": [ diff --git a/pkg/event/formatter.go b/pkg/event/formatter.go index 620ec1f3f..d1181ada7 100644 --- a/pkg/event/formatter.go +++ b/pkg/event/formatter.go @@ -19,12 +19,19 @@ package event import ( + "bytes" "fmt" - "github.com/rabbitstack/fibratus/pkg/util/fasttemplate" + "io" "regexp" "sort" + "strconv" "strings" + "sync" + "time" "unicode" + + "github.com/rabbitstack/fibratus/pkg/util/colorizer" + "github.com/rabbitstack/fibratus/pkg/util/fasttemplate" ) const ( @@ -119,6 +126,7 @@ func NewFormatter(template string) (*Formatter, error) { if ok, pos := isTemplateBalanced(template); !ok { return nil, fmt.Errorf("template syntax error near field #%d: %q", pos, template) } + for i, field := range flds { if len(field) > 0 { name := sanitize(field[0]) @@ -134,6 +142,7 @@ func NewFormatter(template string) (*Formatter, error) { } } } + // user might define the tag such as `{{ .Seq }}` or {{ .Seq}}`. We have to make sure // inner spaces are removed before building the fast template instance norm := normalizeTemplate(template) @@ -141,12 +150,235 @@ func NewFormatter(template string) (*Formatter, error) { if err != nil { return nil, fmt.Errorf("invalid template format %q: %v", norm, err) } + return &Formatter{ t: t, expandParamsDot: tmplExpandParamsRegexp.MatchString(norm), }, nil } +// ColorFormatter wraps a Formatter and re-renders each template tag with +// ANSI colour codes before it is substituted into the output string. +// +// It replaces no logic in base formatter template parsing, field validation, and +// normalisation all remain there. ColorFormatter only intercepts the moment +// fasttemplate calls the per-tag writer function, injecting colour at that +// exact boundary. +// +// When colour output is not available (piped stdout, NO_COLOR, dumb terminal, +// pre-Win10) ColorFormatter falls back transparently to the plain formatter. +type ColorFormatter struct { + f *Formatter + enabled bool + + mu sync.Mutex + prevTime time.Time // timestamp of the most recently rendered event +} + +// NewColorFormatter constructs a ColorFormatter backed by the given Formatter. +// If colour is not available in the current environment the returned value +// behaves identically to the underlying plain Formatter. +func NewColorFormatter(f *Formatter) *ColorFormatter { + return &ColorFormatter{f: f, enabled: colorizer.IsAnsiEnabled()} +} + +// Format renders the event according to the template. Each {{ .Field }} tag is +// substituted with a colour-decorated string when colour output is available, +// or with the plain field value otherwise. +func (f *ColorFormatter) Format(e *Event) []byte { + if !f.enabled { + return f.f.Format(e) + } + + var b bytes.Buffer + b.WriteString(e.Type.arrow()) + + // fasttemplate.Template.ExecuteFuncString calls tagWriter once per tag in + // document order, passing the bare tag name (e.g. ".Seq", ".Type", + // ".Params.file_path"). The writer writes the coloured substitution value. + _, _ = f.f.t.ExecuteFunc(&b, func(w io.Writer, tag string) (int, error) { + return io.WriteString(w, f.colourTag(tag, e)) + }) + + return bytes.TrimRight(b.Bytes(), "\n") +} + +// colourTag maps a bare tag name to its coloured string representation. +func (f *ColorFormatter) colourTag(tag string, e *Event) string { + switch tag { + case seq: + // sequence number is ok to render as dim gray + return colorizer.SpanDim(colorizer.Span(colorizer.Gray, strconv.FormatUint(e.Seq, 10))) + + case ts: + return f.colourTimestamp(e) + + case cpu: + return colorizer.Span(colorizer.Yellow, strconv.FormatUint(uint64(e.CPU), 10)) + + case proc: + // render process name with bold green as it is the most important + // identity anchor on the line. Analysts scan for it first. + ps := e.PS + if ps == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.SpanBold(colorizer.Green, ps.Name) + + case pid: + return colorizer.Span(colorizer.Green, strconv.FormatUint(uint64(e.PID), 10)) + + case ppid: + ps := e.PS + if ps == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.Green, strconv.FormatUint(uint64(ps.Ppid), 10)) + + case tid: + return colorizer.Span(colorizer.Green, strconv.FormatUint(uint64(e.Tid), 10)) + + case exe: + ps := e.PS + if ps == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.White, ps.Exe) + + case pexe: + ps := e.PS + if ps == nil || ps.Parent == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.White, ps.Parent.Exe) + + case cmd: + ps := e.PS + if ps == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.White, ps.Cmdline) + + case pcmd: + ps := e.PS + if ps == nil || ps.Parent == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.White, ps.Parent.Cmdline) + + case cwd: + ps := e.PS + if ps == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.White, ps.Cwd) + + case sid: + ps := e.PS + if ps == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.Gray, ps.SID) + + case pproc: + ps := e.PS + if ps == nil || ps.Parent == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.Green, ps.Parent.Name) + + case typ: + return e.Type.color() + + case cat: + return colorizer.Span(colorizer.Magenta, string(e.Category)) + + case parameters: + return e.Params.Colorize() + + case pe: + ps := e.PS + if ps == nil || ps.PE == nil { + return colorizer.Span(colorizer.Gray, "N/A") + } + return colorizer.Span(colorizer.Magenta, ps.PE.String()) + + case cstack: + return fmt.Sprintf("\n%s", e.Callstack.Colorize()) + } + + return "" +} + +// colourTimestamp renders the timestamp tag with: +// - date in blue, time in cyan, tz components dim gray +// - a Δt suffix showing the gap from the previous event, colour-coded by +// duration: dim gray (<1ms), brighter gray (1–100ms), amber (>100ms) +func (f *ColorFormatter) colourTimestamp(e *Event) string { + // compute Δt under the lock, then update prevTime. + f.mu.Lock() + var delta time.Duration + if !f.prevTime.IsZero() { + delta = max(e.Timestamp.Sub(f.prevTime), 0) + } + f.prevTime = e.Timestamp + f.mu.Unlock() + + // split date and time into two colours so the eye + // can parse them independently without any delimiter + // change. + s := e.Timestamp.String() + // split into at most 4 parts: date, time, offset, tz-name + parts := strings.SplitN(s, " ", 4) + var b strings.Builder + b.Grow(len(s) + 60) + for i, p := range parts { + if i > 0 { + b.WriteByte(' ') + } + switch i { + case 0: // date + b.WriteString(colorizer.Span(colorizer.Blue, p)) + case 1: // time with sub-second precision + b.WriteString(colorizer.Span(colorizer.Cyan, p)) + default: // tz offset, tz name recede visually + b.WriteString(colorizer.SpanDim(colorizer.Span(colorizer.Gray, p))) + } + } + + // Δt suffix only shown after the first event. + if delta > 0 { + b.WriteString(colorizer.Span(colorizer.Gray, " · ")) + b.WriteString(f.colourDelta(delta)) + } + + return b.String() +} + +// colourDelta formats a duration as a compact human-readable string and +// applies a colour that encodes its significance: +// +// dim gray — sub-millisecond (high-frequency burst, expected noise) +// gray — 1ms–100ms (normal inter-event cadence) +// amber — >100ms (notable gap; something blocked or is infrequent) +func (f *ColorFormatter) colourDelta(d time.Duration) string { + var s string + switch { + case d < time.Millisecond: + s = fmt.Sprintf("+%dµs", d.Microseconds()) + return colorizer.SpanDim(colorizer.Span(colorizer.Gray, s)) + case d < 100*time.Millisecond: + s = fmt.Sprintf("+%.2fms", float64(d.Microseconds())/1000) + return colorizer.Span(colorizer.Gray, s) + case d < time.Second: + s = fmt.Sprintf("+%.0fms", float64(d.Microseconds())/1000) + return colorizer.Span(colorizer.Amber, s) + default: + s = fmt.Sprintf("+%.2fs", d.Seconds()) + return colorizer.SpanBold(colorizer.Amber, s) + } +} + func sanitize(s string) string { return strings.Map(func(r rune) rune { if r == '{' || r == '}' || unicode.IsSpace(r) { diff --git a/pkg/event/param.go b/pkg/event/param.go index 82e88104f..a7d21ce20 100644 --- a/pkg/event/param.go +++ b/pkg/event/param.go @@ -28,7 +28,9 @@ import ( "github.com/rabbitstack/fibratus/pkg/fs" "github.com/rabbitstack/fibratus/pkg/network" + "github.com/rabbitstack/fibratus/pkg/util/colorizer" "github.com/rabbitstack/fibratus/pkg/util/key" + "github.com/rabbitstack/fibratus/pkg/util/ntstatus" "github.com/rabbitstack/fibratus/pkg/util/va" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -741,3 +743,80 @@ func (pars Params) findParam(name string) (*Param, error) { } return pars[name], nil } + +// Colorize renders the full parameter list for the {{.Params}} tag. +// Each entry is formatted as: key value, with the key in +// amber, the seperator dim, and the value semantically coloured. +func (pars Params) Colorize() string { + if len(pars) == 0 { + return "" + } + + // sort parameters by name + s := make([]*Param, 0, len(pars)) + for _, par := range pars { + s = append(s, par) + } + sort.Slice(s, func(i, j int) bool { return s[i].Name < s[j].Name }) + + var b strings.Builder + b.Grow(len(pars) * 40) + + for i, p := range s { + if i > 0 { + b.WriteString(colorizer.SpanDim(", ")) + } + b.WriteString(colorizer.Span(colorizer.Amber, p.Name)) + b.WriteString(colorizer.SpanDim(ParamKVDelimiter)) + b.WriteString(p.color()) + } + + return b.String() +} + +// color applies a semantic colour to a single parameter value based +// on its type and for string types its content. +func (p *Param) color() string { + switch p.Type { + case params.Address: + return colorizer.SpanDim(colorizer.Span(colorizer.Gray, "0x"+p.String())) + + case params.Status: + v := p.String() + + if v == ntstatus.Success { + return colorizer.Span(colorizer.Green, v) + } + return colorizer.Span(colorizer.Red, v) + + case params.Int8, params.Int16, params.Int32, params.Int64, + params.Uint8, params.Uint16, params.Uint32, params.Uint64, + params.Float, params.Double: + return colorizer.Span(colorizer.Yellow, p.String()) + + case params.Bool: + b, ok := p.Value.(bool) + if !ok { + return colorizer.Span(colorizer.Coral, p.String()) + } + if b { + return colorizer.Span(colorizer.Green, p.String()) + } + return colorizer.Span(colorizer.Coral, p.String()) + + case params.UnicodeString, params.AnsiString, params.SID: + return colorizer.Span(colorizer.White, p.String()) + + case params.IPv4, params.IPv6: + return colorizer.Span(colorizer.Blue, p.String()) + + case params.Port: + return colorizer.Span(colorizer.Cyan, p.String()) + + case params.PID, params.TID: + return colorizer.Span(colorizer.Green, p.String()) + + default: + return colorizer.Span(colorizer.White, p.String()) + } +} diff --git a/pkg/event/types_windows.go b/pkg/event/types_windows.go index d727e9178..1812436e2 100644 --- a/pkg/event/types_windows.go +++ b/pkg/event/types_windows.go @@ -20,7 +20,9 @@ package event import ( "encoding/binary" + "github.com/rabbitstack/fibratus/pkg/sys/etw" + "github.com/rabbitstack/fibratus/pkg/util/colorizer" "github.com/rabbitstack/fibratus/pkg/util/hashers" "golang.org/x/sys/windows" ) @@ -635,3 +637,119 @@ func pack(g windows.GUID, id uint16) Type { byte(id >> 8), byte(id), } } + +// color return the colorized event type to render by the color formatter. +func (t Type) color() string { + switch t { + case CreateFile, ReadFile, CloseFile, SetFileInformation, MapViewFile, UnmapViewFile: + return colorizer.SpanBold(colorizer.Cyan, t.String()) + + case RenameFile: + return colorizer.SpanBold(colorizer.Amber, t.String()) + + case WriteFile: + return colorizer.SpanBold(colorizer.Teal, t.String()) + + case DeleteFile: + return colorizer.SpanBold(colorizer.Red, t.String()) + + case RegOpenKey, RegCreateKey, RegQueryValue, RegQueryKey: + return colorizer.SpanBold(colorizer.Yellow, t.String()) + + case RegDeleteKey, RegDeleteValue: + return colorizer.SpanBold(colorizer.Red, t.String()) + + case RegSetValue: + return colorizer.SpanBold(colorizer.Amber, t.String()) + + case CreateProcess, OpenProcess: + return colorizer.SpanBold(colorizer.Green, t.String()) + + case TerminateProcess: + return colorizer.SpanBold(colorizer.Red, t.String()) + + case CreateThread, OpenThread: + return colorizer.SpanBold(colorizer.Green, t.String()) + + case TerminateThread: + return colorizer.SpanBold(colorizer.Red, t.String()) + + case SetThreadContext: + return colorizer.SpanBold(colorizer.Amber, t.String()) + + case LoadImage, UnloadImage: + return colorizer.SpanBold(colorizer.Magenta, t.String()) + + case SendTCPv4, SendTCPv6, SendUDPv4, SendUDPv6, + RecvTCPv4, RecvTCPv6, RecvUDPv4, RecvUDPv6: + return colorizer.SpanBold(colorizer.Blue, t.String()) + + case ConnectTCPv4, ConnectTCPv6: + return colorizer.SpanBold(colorizer.Teal, t.String()) + + case DisconnectTCPv4, DisconnectTCPv6: + return colorizer.SpanBold(colorizer.Blue, t.String()) + + case AcceptTCPv4, AcceptTCPv6: + return colorizer.SpanBold(colorizer.Teal, t.String()) + + case QueryDNS, ReplyDNS: + return colorizer.SpanBold(colorizer.Indigo, t.String()) + + case CreateHandle, CloseHandle: + return colorizer.SpanBold(colorizer.Gray, t.String()) + case DuplicateHandle: + return colorizer.SpanBold(colorizer.Amber, t.String()) + + case VirtualAlloc, VirtualFree: + return colorizer.SpanBold(colorizer.Magenta, t.String()) + + case CreateSymbolicLinkObject: + return colorizer.SpanBold(colorizer.Lavender, t.String()) + + case SubmitThreadpoolCallback, SubmitThreadpoolWork, SetThreadpoolTimer: + return colorizer.SpanBold(colorizer.Lavender, t.String()) + + default: + return colorizer.SpanBold(colorizer.White, t.String()) + } +} + +// arrow renders the prefix arrow according to event severity. +// Events are grouped by destructive, mutate, read, and housekeeping +// severities. Destructive severity covers events that irreversibly +// alter system state: process termination, file deletion, registry +// key deletion, code injection. +// +// Mutate covers write/create operations: file writes, registry value +// sets, process creation, thread context changes. +// +// Read covers read/query/open operations that consume but do not +// alter state. Finally, houskeeping covers close/cleanup +// events that are expected noise in a healthy system. +func (t Type) arrow() string { + var clr uint8 + switch t { + case TerminateProcess, TerminateThread, DeleteFile, RegDeleteKey, + RegDeleteValue, UnloadImage, VirtualFree, UnmapViewFile: + clr = colorizer.Red + + case CreateProcess, CreateFile, WriteFile, RenameFile, SetFileInformation, + RegCreateKey, RegSetValue, CreateThread, SetThreadContext, VirtualAlloc, MapViewFile, + DuplicateHandle, ConnectTCPv4, ConnectTCPv6, AcceptTCPv4, AcceptTCPv6, + SendTCPv4, SendTCPv6, SendUDPv4, SendUDPv6: + clr = colorizer.Amber + + case ReadFile, EnumDirectory, LoadImage, RegOpenKey, RegQueryKey, RegQueryValue, OpenProcess, + OpenThread, CreateHandle, RecvTCPv4, RecvTCPv6, RecvUDPv4, RecvUDPv6: + clr = colorizer.Teal + + case QueryDNS, ReplyDNS: + clr = colorizer.Indigo + + default: + clr = colorizer.Gray + } + + return colorizer.SpanBold(clr, "› ") +} diff --git a/pkg/outputs/console/config.go b/pkg/outputs/console/config.go index d39a577b9..d80bf2710 100644 --- a/pkg/outputs/console/config.go +++ b/pkg/outputs/console/config.go @@ -25,6 +25,7 @@ const ( tmpl = "output.console.template" paramKVDelimiter = "output.console.kv-delimiter" enabled = "output.console.enabled" + colorize = "output.console.colorize" ) // Config contains the tweaks that influence the behaviour of the console output. @@ -33,6 +34,7 @@ type Config struct { Template string `mapstructure:"template"` ParamKVDelimiter string `mapstructure:"kv-delimiter"` Enabled bool `mapstructure:"enabled"` + Colorize bool `mapstructure:"colorize"` } // AddFlags registers persistent flags. @@ -41,4 +43,5 @@ func AddFlags(flags *pflag.FlagSet) { flags.String(paramKVDelimiter, "", "The delimiter symbol for the params key/value pairs") flags.String(tmpl, "", "Event formatting template") flags.Bool(enabled, true, "Indicates if the console output is enabled") + flags.Bool(colorize, true, "Indicates if the console output is colorized") } diff --git a/pkg/outputs/console/console.go b/pkg/outputs/console/console.go index 9eca570fb..2c91b97c5 100644 --- a/pkg/outputs/console/console.go +++ b/pkg/outputs/console/console.go @@ -21,9 +21,10 @@ package console import ( "bufio" "expvar" + "os" + "github.com/rabbitstack/fibratus/pkg/event" "github.com/rabbitstack/fibratus/pkg/outputs" - "os" ) var ( @@ -40,9 +41,10 @@ const ( ) type console struct { - writer *bufio.Writer - formatter *event.Formatter - format format + writer *bufio.Writer + formatter *event.Formatter + colorFormatter *event.ColorFormatter + format format } func init() { @@ -59,10 +61,12 @@ func initConsole(config outputs.Config) (outputs.OutputGroup, error) { if tmpl == "" { tmpl = template } + formatter, err := event.NewFormatter(tmpl) if err != nil { return outputs.Fail(err) } + if cfg.ParamKVDelimiter != "" { event.ParamKVDelimiter = cfg.ParamKVDelimiter } @@ -72,6 +76,11 @@ func initConsole(config outputs.Config) (outputs.OutputGroup, error) { formatter: formatter, format: format(cfg.Format), } + + if cfg.Colorize { + c.colorFormatter = event.NewColorFormatter(formatter) + } + return outputs.Success(c), nil } @@ -84,7 +93,11 @@ func (c *console) Publish(batch *event.Batch) error { case json: buf = evt.MarshalJSON() case pretty: - buf = c.formatter.Format(evt) + if c.colorFormatter != nil { + buf = c.colorFormatter.Format(evt) + } else { + buf = c.formatter.Format(evt) + } default: return nil } diff --git a/pkg/util/colorizer/colorizer.go b/pkg/util/colorizer/colorizer.go new file mode 100644 index 000000000..6f70c1996 --- /dev/null +++ b/pkg/util/colorizer/colorizer.go @@ -0,0 +1,141 @@ +/* + * Copyright 2021-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * 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 colorizer + +import ( + "os" + "runtime" + "strings" + + "golang.org/x/sys/windows" +) + +// escape sequences +const ( + reset = "\033[0m" + bold = "\033[1m" + dim = "\033[2m" + fg = "\033[38;5;" +) + +// maps logical colour names to 256-colour foreground codes. +// All codes are tuned for dark terminal backgrounds. +const ( + Gray uint8 = 244 + Blue uint8 = 75 + Cyan uint8 = 87 + Green uint8 = 114 + Yellow uint8 = 221 + Amber uint8 = 215 + Magenta uint8 = 177 + Red uint8 = 203 + Coral uint8 = 209 + Teal uint8 = 80 + White uint8 = 252 + Indigo uint8 = 99 + IndigoDim uint8 = 61 + Lavender uint8 = 183 + LavenderDim uint8 = 140 +) + +// Span wraps text with a 256-colour foreground escape and a trailing reset. +func Span(code uint8, text string) string { + var b strings.Builder + b.Grow(len(text) + 40) + b.WriteString(fg) + b.WriteString(itoa(code)) + b.WriteByte('m') + b.WriteString(text) + b.WriteString(reset) + return b.String() +} + +// SpanBold wraps text with bold + 256-colour foreground. +func SpanBold(code uint8, text string) string { + var b strings.Builder + b.Grow(len(text) + 40) + b.WriteString(fg) + b.WriteString(itoa(code)) + b.WriteByte('m') + b.WriteString(bold) + b.WriteString(text) + b.WriteString(reset) + return b.String() +} + +// SpanDim wraps text with dim intensity. +func SpanDim(text string) string { + var b strings.Builder + b.Grow(len(text) + 20) + b.WriteString(dim) + b.WriteString(text) + b.WriteString(reset) + return b.String() +} + +// itoa converts a uint8 to its decimal string without importing strconv, +// keeping this package free of extra dependencies on the hot render path. +func itoa(n uint8) string { + switch { + case n < 10: + return string([]byte{'0' + n}) + case n < 100: + return string([]byte{'0' + n/10, '0' + n%10}) + default: + return string([]byte{'0' + n/100, '0' + (n/10)%10, '0' + n%10}) + } +} + +// IsAnsiEnabled reports whether the current process should emit ANSI codes. +// It honours NO_COLOR (https://no-color.org), checks for a non-TTY stdout, +// and, on Windows, enables VT-processing mode so that the same code path +// works from Windows 10+ without any conditional compilation. +func IsAnsiEnabled() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + if strings.ToLower(os.Getenv("TERM")) == "dumb" { + return false + } + fi, err := os.Stdout.Stat() + if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 { + return false + } + if runtime.GOOS == "windows" { + return enableWindowsVT() + } + return true +} + +// enableWindowsVT activates ENABLE_VIRTUAL_TERMINAL_PROCESSING on the Windows +// console handle so that ANSI escape sequences are interpreted rather than +// printed verbatim. Returns false on pre-Windows 10 hosts where this flag +// is unavailable. +func enableWindowsVT() bool { + handle := windows.Handle(os.Stdout.Fd()) + var mode uint32 + if err := windows.GetConsoleMode(handle, &mode); err != nil { + return false + } + const vtFlag = 0x0004 // ENABLE_VIRTUAL_TERMINAL_PROCESSING + if mode&vtFlag != 0 { + return true + } + return windows.SetConsoleMode(handle, mode|vtFlag) == nil +}