diff --git a/src/commands/malware/malware.go b/src/commands/malware/malware.go index 4fbf78d..4d4cbac 100644 --- a/src/commands/malware/malware.go +++ b/src/commands/malware/malware.go @@ -3,23 +3,81 @@ package malware import ( "bufio" "common/license" + "common/malwaredb" "common/modulefirewall" "common/notify" "common/settings" "common/system" "encoding/json" "fmt" + "nvm/constant" "os" + "path/filepath" "strings" + "text/tabwriter" winreg "golang.org/x/sys/windows/registry" ) type Root struct { + Status Status `cmd:"status" help:"Show the local malware verdict for one or more packages."` Evaluate Evaluate `cmd:"evaluate" hidden:"true" help:"Internal: POST a malware hit list to the organization block-list URL."` NotifyBlock NotifyBlock `cmd:"notify-block" hidden:"true" help:"Internal: background toast after a malware block."` } +type Status struct { + Modules []string `arg:"" name:"module" help:"Package name or name@version."` + constant.FlagJSON +} + +func (c *Status) Run() error { + if len(c.Modules) == 0 { + return fmt.Errorf("specify one or more modules") + } + cfg := settings.Global() + in := malwaredb.Input{ + Certified: !license.IsCommunityBuild(), + Skip: cfg.SkipMalwareChecks, + AllowList: cfg.MalwareAllowList, + BlockList: cfg.MalwareBlockList, + TrustConflict: cfg.MalwareTrustConflict, + } + if in.Certified { + if _, ok := modulefirewall.ExtractHTTPSURL(cfg.MalwareBlockList); ok { + in.RemoteConfigured = true + } + } + dbPath := filepath.Join(settings.Expand(cfg.Root), malwaredb.DBFileName) + db, err := malwaredb.Open(dbPath) + if err != nil { + in.DBErr = err + } else { + in.DB = db + } + + rows := make([]malwaredb.Row, 0, len(c.Modules)) + for _, raw := range c.Modules { + mod := malwaredb.ParseModule(raw) + grant := modulefirewall.PackageSpec{Name: mod.Name, Version: mod.Version, Raw: mod.Raw} + rowIn := in + rowIn.Trusted = modulefirewall.ExplicitModuleGrant(grant, cfg.TrustedModules) + rowIn.Approved = modulefirewall.ExplicitModuleGrant(grant, cfg.ApprovedModules) + rowIn.ApprovedGlobal = modulefirewall.ExplicitModuleGrant(grant, cfg.ApprovedGlobalModules) + rows = append(rows, malwaredb.Check(mod, rowIn)) + } + + if c.JSON { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + return enc.Encode(rows) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0) + for _, row := range rows { + fmt.Fprintln(tw, malwaredb.FormatStatusLine(row)) + } + return tw.Flush() +} + type Evaluate struct { URL string `arg:"" name:"url" help:"Organization malware policy URL."` } @@ -52,7 +110,7 @@ func (c *Evaluate) Run() error { } body, err := json.Marshal(hits) if err != nil { - fmt.Println("0") + fmt.Print(formatEvaluateStdout(modulefirewall.RemoteResult{Status: 0})) return nil } cfg := settings.Global() @@ -65,21 +123,24 @@ func (c *Evaluate) Run() error { Body: body, ContentType: "application/json", }) - fmt.Println(res.Status) - names := make([]string, 0, len(res.Blocks)) - for _, block := range res.Blocks { - if block.Name != "" { - names = append(names, block.Name) - } - } - fmt.Println(strings.Join(names, "\t")) + fmt.Print(formatEvaluateStdout(res)) if res.Status == 0 { os.Exit(2) } return nil } +func formatEvaluateStdout(res modulefirewall.RemoteResult) string { + var b strings.Builder + fmt.Fprintf(&b, "%d\n", res.Status) + for _, block := range res.Blocks { + fmt.Fprintf(&b, "%s\t%s\t%s\n", block.Name, block.Date, block.Reason) + } + return b.String() +} + type NotifyBlock struct { + Title string `flag:"title" hidden:"true" help:"Toast title."` Packages []string `arg:"" optional:"" name:"package" help:"Blocked package names."` } @@ -87,12 +148,16 @@ func (c *NotifyBlock) Run() error { if system.IsAppInForeground() || len(c.Packages) == 0 { return nil } + title := strings.TrimSpace(c.Title) + if title == "" { + title = "Malicious package blocked" + } body := blockBody(c.Packages) if license.IsCommunityBuild() && corporatePC() { body += " Certified keeps a record of blocks like this for your security tools." } // Critical security toast: no collection (reserved nvm.security). - return notify.SendWithAudio(settings.AppId, "Malicious package blocked", body, "ms-winsoundevent:Notification.Reminder") + return notify.SendWithAudio(settings.AppId, title, body, "ms-winsoundevent:Notification.Reminder") } func blockBody(packages []string) string { diff --git a/src/commands/malware/malware_test.go b/src/commands/malware/malware_test.go new file mode 100644 index 0000000..d40f43f --- /dev/null +++ b/src/commands/malware/malware_test.go @@ -0,0 +1,35 @@ +package malware + +import ( + "common/modulefirewall" + "strings" + "testing" +) + +func TestFormatEvaluateStdoutIncludesReason(t *testing.T) { + got := formatEvaluateStdout(modulefirewall.RemoteResult{ + Status: 403, + Blocks: []modulefirewall.RemoteBlock{ + {Name: "eslint", Date: "2026-01-01", Reason: "cve"}, + {Name: "lodash", Date: "", Reason: ""}, + }, + }) + want := "403\neslint\t2026-01-01\tcve\nlodash\t\t\n" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestFormatEvaluateStdoutStatusOnly(t *testing.T) { + got := formatEvaluateStdout(modulefirewall.RemoteResult{Status: 200}) + if got != "200\n" { + t.Fatalf("%q", got) + } +} + +func TestBlockBodyTruncates(t *testing.T) { + got := blockBody([]string{"a", "b", "c", "d"}) + if !strings.Contains(got, "and 1 more") { + t.Fatalf("%q", got) + } +} diff --git a/src/commands/root.go b/src/commands/root.go index 7a6963c..9a15249 100644 --- a/src/commands/root.go +++ b/src/commands/root.go @@ -29,7 +29,7 @@ type RootCommand struct { Cache cache.Root `cmd:"cache" help:"View and manage the ${app} cache."` Config cfg.Root `cmd:"config" aliases:"cfg" help:"View and manage the ${app} configuration."` Firewall firewall.Root `cmd:"firewall" aliases:"fw" help:"Manage NVM version/module/trust firewall policy."` - Malware malware.Root `cmd:"malware" hidden:"true" help:"Internal malware helpers."` + Malware malware.Root `cmd:"malware" help:"Show local malware verdicts."` On Toggle `cmd:"on" help:"Manage Node.js with ${app}."` Off Toggle `cmd:"off" help:"Stop managing Node.js with ${app}."` Doctor Doctor `cmd:"doctor" help:"Detect and fix common ${app} issues." hidden:"true"`