From d13600883d0967563b3ca26352323b2bb85a9ff6 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:15:58 -0500 Subject: [PATCH 1/8] Add nvm firewall trust module CLI and prompt-trust helper. Co-authored-by: Cursor --- src/commands/firewall/helpers.go | 73 +++++++++++++++++++++++++ src/commands/firewall/root.go | 93 ++++++++++++++++++++++++++++++++ src/commands/root.go | 2 + src/go.mod | 3 ++ 4 files changed, 171 insertions(+) create mode 100644 src/commands/firewall/helpers.go create mode 100644 src/commands/firewall/root.go diff --git a/src/commands/firewall/helpers.go b/src/commands/firewall/helpers.go new file mode 100644 index 0000000..277af63 --- /dev/null +++ b/src/commands/firewall/helpers.go @@ -0,0 +1,73 @@ +package firewall + +import ( + "bufio" + "common/notify" + "common/settings" + "fmt" + "nvm/log" + "os" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// PromptTrust dual-channel (console + desktop MessageBox + toast). Either Yes advances. +type PromptTrust struct { + Module string `arg:"" name:"module" help:"Module / command name that changed."` +} + +func (p *PromptTrust) Run() error { + name := strings.TrimSpace(p.Module) + if name == "" { + return fmt.Errorf("module name required") + } + msg := fmt.Sprintf("Untrusted module '%s' changed after running. Approve and reshim?", name) + _ = notify.Send(settings.AppId, "NVM Firewall", msg+" Answer Yes in the dialog or type y in the console.") + + result := make(chan bool, 2) + + go func() { + fmt.Printf("%s [y/N]: ", msg) + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + result <- false + return + } + line = strings.TrimSpace(line) + result <- len(line) > 0 && (line[0] == 'y' || line[0] == 'Y') + }() + + go func() { + result <- messageBoxYesNo("NVM Firewall", msg) + }() + + ok := <-result + if ok { + log.LogStructured("firewall.trust_prompt_accepted", map[string]any{"module": name}, CodePolicyMutate) + return nil + } + log.LogStructured("firewall.trust_prompt_declined", map[string]any{"module": name}, CodePolicyMutate) + os.Exit(1) + return nil +} + +func messageBoxYesNo(title, body string) bool { + user32 := windows.NewLazySystemDLL("user32.dll") + proc := user32.NewProc("MessageBoxW") + t, err1 := syscall.UTF16PtrFromString(title) + b, err2 := syscall.UTF16PtrFromString(body) + if err1 != nil || err2 != nil { + return false + } + const mbYesNo = 0x00000004 + const mbIconQuestion = 0x00000020 + const mbSetForeground = 0x00010000 + const mbTopmost = 0x00040000 + const idYes = 6 + r, _, _ := proc.Call(0, uintptr(unsafe.Pointer(b)), uintptr(unsafe.Pointer(t)), uintptr(mbYesNo|mbIconQuestion|mbSetForeground|mbTopmost)) + return r == idYes +} diff --git a/src/commands/firewall/root.go b/src/commands/firewall/root.go new file mode 100644 index 0000000..0cc68d2 --- /dev/null +++ b/src/commands/firewall/root.go @@ -0,0 +1,93 @@ +package firewall + +import ( + "common/modulefirewall" + "common/settings" + "common/system" + "fmt" + "nvm/log" + "strings" +) + +// Event codes (NVM44xx firewall range). +const ( + CodePolicyMutate = 4401 + CodeElevationRequired = 4404 + CodeInvalidRule = 4405 +) + +type Root struct { + Trust TrustRoot `cmd:"trust" help:"Manage TrustedModules for self-updating global CLIs."` + PromptTrust PromptTrust `cmd:"prompt-trust" hidden:"true" help:"Internal: dual-channel trust prompt for proxy."` +} + +type TrustRoot struct { + Module TrustModule `cmd:"module" help:"Manage TrustedModules (self-update auto-reshim allow list)."` +} + +type TrustModule struct { + Entries []string `arg:"" name:"entry" help:"Module patterns to trust for auto-reshim (or NOT to revoke)."` +} + +func requireMachine() error { + if err := system.RequireAdministrator(); err != nil { + log.ErrorStructured("firewall.elevation_required", map[string]any{ + "error": err.Error(), + }, CodeElevationRequired) + return fmt.Errorf("firewall policy changes require an elevated administrator prompt (NVM%d): %w", CodeElevationRequired, err) + } + return nil +} + +func appendSettingsList(cfgKey, regLabel string, add []string, negate bool) error { + if err := requireMachine(); err != nil { + return err + } + cur, _ := settings.Get(cfgKey) + var list []string + switch v := cur.(type) { + case []string: + list = append([]string{}, v...) + case string: + if strings.TrimSpace(v) != "" { + list = []string{v} + } + } + for _, e := range add { + e = strings.TrimSpace(e) + if e == "" { + continue + } + if negate && !strings.HasPrefix(strings.ToLower(e), "not ") && !strings.HasPrefix(e, "!") { + e = "NOT " + e + } + if err := modulefirewall.ValidateRuleEntry(e); err != nil { + log.ErrorStructured("firewall.invalid_rule", map[string]any{ + "key": cfgKey, + "entry": e, + "error": err.Error(), + }, CodeInvalidRule) + return fmt.Errorf("invalid firewall entry %q (NVM%d): %w", e, CodeInvalidRule, err) + } + list = append(list, e) + } + if err := settings.PutMachine(cfgKey, list); err != nil { + log.ErrorStructured("firewall.policy_mutate_failed", map[string]any{ + "key": cfgKey, + "error": err.Error(), + }, CodePolicyMutate) + return err + } + log.Logf("firewall: updated %s (+%d entries)", regLabel, len(add)) + log.LogStructured("firewall.policy_mutated", map[string]any{ + "key": cfgKey, + "added": add, + "negate": negate, + "action": "append", + }, CodePolicyMutate) + return nil +} + +func (t *TrustModule) Run() error { + return appendSettingsList("trusted_modules", "TrustedModules", t.Entries, false) +} diff --git a/src/commands/root.go b/src/commands/root.go index 0cb7390..386a8e0 100644 --- a/src/commands/root.go +++ b/src/commands/root.go @@ -4,6 +4,7 @@ import ( "nvm/commands/alias" "nvm/commands/cache" "nvm/commands/cfg" + "nvm/commands/firewall" "nvm/commands/install" "nvm/commands/license" "nvm/commands/list" @@ -26,6 +27,7 @@ type RootCommand struct { Env Env `cmd:"env" help:"Display ${app} environment details."` 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" help:"Manage NVM trust firewall policy."` 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"` diff --git a/src/go.mod b/src/go.mod index f34a247..033a32f 100644 --- a/src/go.mod +++ b/src/go.mod @@ -44,6 +44,8 @@ replace common/cose v1.0.0 => ../../common/cose replace common/license v1.0.0 => ../../common/licensing +replace common/modulefirewall v1.0.0 => ../../common/modulefirewall + require ( common/acl v1.0.0 common/config v1.0.0 @@ -53,6 +55,7 @@ require ( common/inspect v1.0.0 common/license v1.0.0 common/mirrorauth v1.0.0 + common/modulefirewall v1.0.0 common/notify v1.0.0 common/preferences v1.0.0 common/registry v1.0.0 From 2faad7b8bae2128ae7d023a5b22eab3ef97df6d7 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:22:53 -0500 Subject: [PATCH 2/8] feat(doctor): add --update to force sync asset refresh Pass --update through to sync.exe doctor. Co-authored-by: Cursor --- src/commands/sync.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/commands/sync.go b/src/commands/sync.go index 5b1eff4..853280c 100644 --- a/src/commands/sync.go +++ b/src/commands/sync.go @@ -48,6 +48,7 @@ type Doctor struct { Checks []string `arg:"" optional:"" help:"Specific checks to run. If not specified, all checks will be run."` Autofix bool `flag:"autofix" help:"Automatically fix issues when possible."` List bool `flag:"list" help:"List all available checks without running them."` + Update bool `flag:"update" help:"Force sync utility asset update before running checks."` constant.FlagJSON } @@ -64,6 +65,9 @@ func (c *Doctor) Run() error { if c.Autofix { args = append(args, "--autofix") } + if c.Update { + args = append(args, "--update") + } if c.JSON { args = append(args, "--json") } From 9693400efde1f209b3867d13f80b12b6c363cd56 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:39:25 -0500 Subject: [PATCH 3/8] feat(firewall): align NVM44xx codes with certified Reserve 4401/4408/4409 and move policy mutate to 4410 so community event codes match the shared dictionary. --- src/bootstrap/activation.go | 19 +- src/cmd/layout_warn.go | 7 - src/cmd/main.go | 86 ++++++- src/commands/env.go | 134 +++++++++- src/commands/env_test.go | 31 +++ src/commands/firewall/helpers.go | 321 ++++++++++++++++++++--- src/commands/firewall/root.go | 387 +++++++++++++++++++++++++--- src/commands/firewall/trust_test.go | 55 ++++ src/commands/root.go | 2 +- src/commands/use/version.go | 7 +- src/go.mod | 2 +- src/installer/activation.go | 11 + src/log/audit.go | 22 +- src/log/log.go | 82 ++++++ src/manifest.json | 2 +- 15 files changed, 1040 insertions(+), 128 deletions(-) create mode 100644 src/commands/firewall/trust_test.go diff --git a/src/bootstrap/activation.go b/src/bootstrap/activation.go index ea315ec..bb48af0 100644 --- a/src/bootstrap/activation.go +++ b/src/bootstrap/activation.go @@ -21,12 +21,19 @@ var verifyActivationNode = func(path string) error { var logActivationBlocked = func(versionDir, nodePath, failureKind, detail string) { log.ErrorStructured("node.security.activation_blocked", log.StructuredPayload{ - "action": "activation_blocked", - "detail": detail, - "failure_kind": failureKind, - "node_path": nodePath, - "source": "link-mode", - "version_path": versionDir, + "action": "activation_blocked", + "detail": detail, + "failure_kind": failureKind, + "node_path": nodePath, + "source": "link-mode", + "version_path": versionDir, + "user": log.Actor(), + "sid": log.ActorSid(), + "hostname": log.Hostname(), + "correlation_id": log.NewCorrelationID(), + "parent_process": log.ParentProcess(), + "project_name": log.ProjectName(), + "project_path": log.ProjectPath(), }, activationBlockedEventCode) } diff --git a/src/cmd/layout_warn.go b/src/cmd/layout_warn.go index ac2e34e..ba34b65 100644 --- a/src/cmd/layout_warn.go +++ b/src/cmd/layout_warn.go @@ -32,10 +32,3 @@ func warnCommunityProgramRootIfNeeded() { _ = err } } - -func communityEditionWatermark() string { - if license.Edition() != "Community" { - return "" - } - return "Community (per-user LocalAppData install; see nvm doctor)" -} diff --git a/src/cmd/main.go b/src/cmd/main.go index d16e2cd..d13a0af 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -12,6 +12,7 @@ import ( "fmt" "nvm/bootstrap" "nvm/commands" + "nvm/commands/firewall" "nvm/installer" "nvm/legacy" "nvm/log" @@ -35,6 +36,16 @@ func main() { os.Args = append(os.Args, "--help") } + // Toast / protocol activation (e.g. nvm://firewall?action=trust&...). + if strings.HasPrefix(strings.ToLower(os.Args[1]), "nvm://") { + settings.Load() + if err := firewall.HandleProtocolURI(os.Args[1]); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + return + } + switch os.Args[1] { case "--register-eventlog": // Invoked by OSS installer to support event log registration without needing to run the entire CLI installer. @@ -67,12 +78,29 @@ func main() { case "--sign-version-scripts": // Invoked by detached reshim after global package installs so proxy // can trust newly written .cmd/.bat launchers without executing them first. - if len(os.Args) < 3 { + versionDir, wantSignChanged := parseSignVersionScriptsArgs(os.Args[2:]) + if versionDir == "" { fmt.Fprint(os.Stderr, "missing version directory for --sign-version-scripts\n") os.Exit(1) } settings.Load() - if err := verifycache.SignVersionScripts(os.Args[2]); err != nil { + _ = os.Unsetenv("NVM_SIGN_CHANGED_MODULES") + if wantSignChanged && verifycache.ParentIsNvmReshim() { + verifycache.SetAllowSignChanged(true) + } + if err := verifycache.SignVersionScripts(versionDir); err != nil { + fmt.Fprint(os.Stderr, err.Error()) + os.Exit(1) + } + return + case "--sign-script": + // Force-resign one launcher after trust prompt (bypass TrustedModules gate). + if len(os.Args) < 3 { + fmt.Fprint(os.Stderr, "missing script path for --sign-script\n") + os.Exit(1) + } + settings.Load() + if err := verifycache.SignScript(os.Args[2]); err != nil { fmt.Fprint(os.Stderr, err.Error()) os.Exit(1) } @@ -82,10 +110,13 @@ func main() { // ACL write window (RunWithRuntimeShimWrite); spawning reshim.exe alone // cannot create hardlinks against the locked directory. settings.Load() - args := []string{} - if len(os.Args) > 2 { - args = os.Args[2:] + args, readyEvent := splitReshimArgs(os.Args[2:]) + _ = os.Unsetenv("NVM_SIGN_CHANGED_MODULES") + if verifycache.AuthorizeSignChangedFromParent() { + verifycache.SetAllowSignChanged(true) + args = append(args, "--sign-changed") } + system.SignalNamedEvent(readyEvent) if err := bootstrap.RunReshim(args...); err != nil { fmt.Fprint(os.Stderr, err.Error()) os.Exit(1) @@ -205,9 +236,6 @@ func main() { case "-v", "--version", "version": settings.Load() fmt.Printf("v%s\n", version) - if mark := communityEditionWatermark(); mark != "" { - fmt.Println(mark) - } warnCommunityProgramRootIfNeeded() return case "-h", "--help", "help": @@ -235,9 +263,6 @@ func main() { warnCommunityProgramRootIfNeeded() desc := fmt.Sprintf("%s\nv%s (%s Edition).", description, version, license.Edition()) - if mark := communityEditionWatermark(); mark != "" { - desc = fmt.Sprintf("%s\nv%s (%s Edition).\n%s.", description, version, license.Edition(), mark) - } cli := kong.Parse( root, @@ -337,3 +362,42 @@ func capitalize(s string) string { } return strings.ToUpper(s[:1]) + s[1:] } + +func parseSignVersionScriptsArgs(args []string) (versionDir string, signChanged bool) { + for _, a := range args { + if a == "--sign-changed" { + signChanged = true + continue + } + if strings.HasPrefix(a, "--") { + continue + } + if versionDir == "" { + versionDir = a + } + } + return versionDir, signChanged +} + +func splitReshimArgs(args []string) (forward []string, readyEvent string) { + forward = make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + a := args[i] + if a == "--sign-changed" { + continue + } + if a == "--parent-ready-event" { + if i+1 < len(args) { + readyEvent = args[i+1] + i++ + } + continue + } + if strings.HasPrefix(a, "--parent-ready-event=") { + readyEvent = strings.TrimPrefix(a, "--parent-ready-event=") + continue + } + forward = append(forward, a) + } + return forward, readyEvent +} diff --git a/src/commands/env.go b/src/commands/env.go index e3ae9f6..52f44b8 100644 --- a/src/commands/env.go +++ b/src/commands/env.go @@ -4,6 +4,7 @@ import ( nvmhttp "common/http" "common/inspect" "common/license" + "common/modulefirewall" "common/registry" "common/settings" "common/system" @@ -18,6 +19,7 @@ import ( "os" "os/user" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -42,8 +44,9 @@ type Env struct { type installData struct { Version string `json:"version"` - BuildTime string `json:"build_time"` - InstallDir string `json:"path"` + BuildTime string `json:"build_time"` + BuildArchitecture string `json:"build_architecture"` + InstallDir string `json:"path"` Upgrade string `json:"upgrade"` Variables map[string]string `json:"variables"` } @@ -77,6 +80,15 @@ type nodeRuntimeFlags struct { EnforcementNote string `json:"enforcement_note,omitempty"` } +// packageManagersCfg reports npm / trust-firewall policy for `nvm env`. +type packageManagersCfg struct { + NpmMirror []string `json:"npm_mirror"` + PackageManagerMismatchAction string `json:"pm_mismatch_action"` + AutoInstallModules []string `json:"auto_installed_modules"` + UntrustedModuleHandlerAction string `json:"untrusted_module_handler_action"` + TrustedModules string `json:"trusted_modules"` // "ALL" or decimal count +} + type Computer struct { MajorLabel string `json:"windows_major_label"` MajorVersion int64 `json:"windows_major_version"` @@ -97,13 +109,14 @@ type Computer struct { } type data struct { - Installation installData `json:"installation"` - VersionManagement vmOps `json:"operations"` - Node nodeRuntimeFlags `json:"node"` - Computer Computer `json:"localhost"` - ActiveLicense *License `json:"license,omitempty"` - ReportStatus string `json:"report_status,omitempty"` - Help string `json:"help_url,omitempty"` + Installation installData `json:"installation"` + VersionManagement vmOps `json:"operations"` + Node nodeRuntimeFlags `json:"node"` + PackageManagers packageManagersCfg `json:"package_managers"` + Computer Computer `json:"localhost"` + ActiveLicense *License `json:"license,omitempty"` + ReportStatus string `json:"report_status,omitempty"` + Help string `json:"help_url,omitempty"` } type License struct { @@ -265,11 +278,17 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { nodeFlags.EnforcementNote = "Only enforced in shim mode" } + buildArchitecture := "amd64" + if runtime.GOARCH == "arm64" { + buildArchitecture = "arm64" + } + out := data{ Installation: installData{ - Version: vars["version"], - BuildTime: vars["buildTime"], - InstallDir: path(programRoot), + Version: vars["version"], + BuildTime: vars["buildTime"], + BuildArchitecture: buildArchitecture, + InstallDir: path(programRoot), Upgrade: map[bool]string{true: "blocked", false: "allowed"}[cfg.DisableUpgrade], // Variables: map[string]string{ // "NVM_HOME": getUserEnvVar("NVM_HOME"), @@ -295,6 +314,13 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { NpmModuleSizeMB: moduleSizeBytes / (1024 * 1024), }, Node: nodeFlags, + PackageManagers: packageManagersCfg{ + NpmMirror: append([]string(nil), cfg.NpmMirror...), + PackageManagerMismatchAction: strings.TrimSpace(cfg.PackageManagerMismatchAction), + AutoInstallModules: append([]string(nil), cfg.AutoInstallModuleList...), + UntrustedModuleHandlerAction: untrustedHandlerLabel(cfg.UntrustedModuleHandlerAction), + TrustedModules: summarizeTrustedModules(cfg.TrustedModules), + }, Computer: Computer{ MajorLabel: win_major_label, MajorVersion: int64(win_major_version.(uint64)), @@ -377,7 +403,7 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { fmt.Fprintf(t, "%s%s Version\t: %s\n", indent(1), branch, out.Installation.Version) // nvm build - fmt.Fprintf(t, "%s%s Build\t: %s\n", indent(1), branch, out.Installation.BuildTime) + fmt.Fprintf(t, "%s%s Build\t: %s (%s)\n", indent(1), branch, out.Installation.BuildTime, out.Installation.BuildArchitecture) // nvm install root hasActiveLicense := out.ActiveLicense != nil @@ -489,6 +515,29 @@ func (e *Env) Run(ctx *kong.Context, vars kong.Vars) error { fmt.Fprintf(t, "%s%s Disallow eval/string execution\t: %s\n", indent(1), end, enabledLabel(out.Node.DisableEvalAndStringExecution)) } + fmt.Fprint(t, br) + + // Package managers / trust firewall + fmt.Fprint(t, "Package Managers\t\n") + for i, mirror := range out.PackageManagers.NpmMirror { + if i == 0 { + fmt.Fprintf(t, "%s%s npm registry\t: %s\n", indent(1), branch, mirror) + } else { + fmt.Fprintf(t, "%s%s \t %s\n", indent(1), branch, mirror) + } + } + if len(out.PackageManagers.NpmMirror) == 0 { + fmt.Fprintf(t, "%s%s npm registry\t: (not set)\n", indent(1), branch) + } + fmt.Fprintf(t, "%s%s Mismatch action\t: %s\n", indent(1), branch, out.PackageManagers.PackageManagerMismatchAction) + autoMods := strings.Join(out.PackageManagers.AutoInstallModules, ", ") + if strings.TrimSpace(autoMods) == "" { + autoMods = "(none)" + } + fmt.Fprintf(t, "%s%s Auto-install modules\t: %s\n", indent(1), branch, autoMods) + fmt.Fprintf(t, "%s%s Untrusted module action\t: %s\n", indent(1), branch, out.PackageManagers.UntrustedModuleHandlerAction) + fmt.Fprintf(t, "%s%s Trusted modules\t: %s\n", indent(1), end, out.PackageManagers.TrustedModules) + // Identify EOL versions and those w%shich are supported by nvm // Announcements @@ -513,6 +562,65 @@ func enabledLabel(enabled bool) string { return "Disabled" } +func untrustedHandlerLabel(raw string) string { + v := strings.ToLower(strings.TrimSpace(raw)) + switch v { + case "deny", "prompt", "allow": + return v + case "": + return "prompt" + default: + return v + } +} + +// summarizeTrustedModules returns "ALL" when everything is trusted, otherwise +// the count of positive (allow) TrustedModules patterns. +func summarizeTrustedModules(entries []string) string { + rules := modulefirewall.NormalizeList(entries, modulefirewall.DefaultTrustedWhenEmpty) + if endpoint, ok := modulefirewall.ExtractHTTPSURL(rules); ok { + return endpoint + } + + hasNotAll := false + hasAll := false + positive := 0 + for _, raw := range rules { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + lower := strings.ToLower(entry) + negated := false + if strings.HasPrefix(lower, "not ") || strings.HasPrefix(lower, "not\t") { + negated = true + entry = strings.TrimSpace(entry[3:]) + } else if strings.HasPrefix(entry, "!") { + negated = true + entry = strings.TrimSpace(entry[1:]) + } + if entry == "" { + continue + } + if strings.EqualFold(entry, "all") { + if negated { + hasNotAll = true + } else { + hasAll = true + } + continue + } + if !negated { + positive++ + } + } + + if hasAll && !hasNotAll { + return "ALL" + } + return strconv.Itoa(positive) +} + func showDetail(t *tabwriter.Writer, problem *inspect.Problem) { content := strings.TrimSpace(fmt.Sprintf("%s \n%s %s", problem.Name, problem.Detail, problem.Help)) // content := strings.TrimSpace(fmt.Sprintf("%s %s \n%s %s", end, problem.Name, problem.Detail, problem.Help)) diff --git a/src/commands/env_test.go b/src/commands/env_test.go index b6e788e..38ec75b 100644 --- a/src/commands/env_test.go +++ b/src/commands/env_test.go @@ -6,6 +6,37 @@ import ( "testing" ) +func TestSummarizeTrustedModules(t *testing.T) { + tests := []struct { + name string + entries []string + want string + }{ + {name: "empty defaults to zero", entries: nil, want: "0"}, + {name: "not all alone", entries: []string{"NOT ALL"}, want: "0"}, + {name: "all", entries: []string{"ALL"}, want: "ALL"}, + {name: "exceptions", entries: []string{"NOT ALL", "opencode", "porthog"}, want: "2"}, + {name: "exclusive positives", entries: []string{"eslint", "prettier"}, want: "2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := summarizeTrustedModules(tt.entries) + if got != tt.want { + t.Fatalf("summarizeTrustedModules(%v) = %q, want %q", tt.entries, got, tt.want) + } + }) + } +} + +func TestUntrustedHandlerLabel(t *testing.T) { + if got := untrustedHandlerLabel(""); got != "prompt" { + t.Fatalf("empty = %q, want prompt", got) + } + if got := untrustedHandlerLabel("ALLOW"); got != "allow" { + t.Fatalf("ALLOW = %q, want allow", got) + } +} + func TestFormatSize(t *testing.T) { tests := []struct { name string diff --git a/src/commands/firewall/helpers.go b/src/commands/firewall/helpers.go index 277af63..626b670 100644 --- a/src/commands/firewall/helpers.go +++ b/src/commands/firewall/helpers.go @@ -2,19 +2,129 @@ package firewall import ( "bufio" + "common/modulefirewall" "common/notify" "common/settings" + "common/system" + "crypto/rand" + "encoding/hex" "fmt" + "net/url" "nvm/log" "os" + "path/filepath" "strings" - "syscall" - "unsafe" - - "golang.org/x/sys/windows" + "time" ) -// PromptTrust dual-channel (console + desktop MessageBox + toast). Either Yes advances. +func enrichSIEM(m map[string]any) { + m["user"] = log.Actor() + m["sid"] = log.ActorSid() + m["hostname"] = log.Hostname() + m["correlation_id"] = log.NewCorrelationID() +} + +func packageName(pkg modulefirewall.PackageSpec) string { + if pkg.Raw != "" { + return pkg.Raw + } + return pkg.Name +} + +func packageNames(pkgs []modulefirewall.PackageSpec) []string { + out := make([]string, 0, len(pkgs)) + for _, pkg := range pkgs { + out = append(out, packageName(pkg)) + } + return out +} + +// logRemoteTrustEvent writes operational plaintext (community) and structured +// payload (certified / licensed). Community LogStructured falls back to plaintext. +func logRemoteTrustEvent(event string, code int, plain string, payload map[string]any) { + enrichSIEM(payload) + if plain != "" { + log.Log(plain, code) + } + switch code { + case CodeRemoteFailed, CodeModuleBlocked: + log.ErrorStructured(event, payload, code) + default: + log.LogStructured(event, payload, code) + } +} + +// CheckRemoteTrust is an internal helper for proxy.exe HTTPS TrustedModules evaluation. +// Local list is checked first; HTTP runs only for modules not trusted locally. +// Exit: 0 trusted, 1 remote 403 / local untrusted, 2 request failed (NVM4402). +type CheckRemoteTrust struct { + Modules []string `arg:"" name:"module" help:"Package tokens to evaluate against TrustedModules."` +} + +func (c *CheckRemoteTrust) Run() error { + cfg := settings.Global() + pkgs := make([]modulefirewall.PackageSpec, 0, len(c.Modules)) + for _, m := range c.Modules { + pkg, err := modulefirewall.ParsePackageToken(m) + if err != nil { + log.ErrorStructured("firewall.invalid_rule", map[string]any{ + "entry": m, + "error": err.Error(), + }, CodeInvalidRule) + return fmt.Errorf("invalid module token %q (NVM%d): %w", m, CodeInvalidRule, err) + } + pkgs = append(pkgs, pkg) + } + if len(pkgs) == 0 { + return nil + } + + res := modulefirewall.EvaluateTrustedModules(pkgs, cfg.TrustedModules, modulefirewall.RemoteTLSOptions{ + TimeoutSec: cfg.FirewallHTTPTimeoutSeconds, + }) + if res.Trusted { + if res.RemoteQueried { + logRemoteTrustEvent("firewall.remote_trust_allowed", CodeRemoteAllowed, "NVM Firewall: remote policy allowed", map[string]any{ + "count": len(pkgs), + "remote": true, + "status": res.Remote.Status, + }) + } + return nil + } + + if res.Message != "" { + fmt.Fprintf(os.Stderr, "NVM Firewall: %s\n", res.Message) + } + if res.RemoteQueried && modulefirewall.RemoteTrustUnavailable(res.Remote) { + logRemoteTrustEvent("firewall.remote_trust_unavailable", CodeRemoteFailed, "NVM Firewall: "+res.Message, map[string]any{ + "error": res.Message, + "status": res.Remote.Status, + "detail": res.Remote.ErrorMsg, + "modules": packageNames(res.Untrusted), + }) + os.Exit(2) + return nil + } + if res.RemoteQueried && res.Remote.Status == 403 { + logRemoteTrustEvent("firewall.remote_blocked", CodeModuleBlocked, "NVM Firewall: blocked by remote policy", map[string]any{ + "status": 403, + "modules": packageNames(res.Untrusted), + "error": res.Message, + }) + os.Exit(1) + return nil + } + for _, u := range res.Untrusted { + fmt.Fprintf(os.Stderr, " untrusted: %s\n", packageName(u)) + } + os.Exit(1) + return nil +} + +// PromptTrust asks whether to trust a module after a self-update. +// Foreground console: interactive y/N only (no toast). +// Background: native toast with Trust / Cancel (protocol actions); wait for answer. type PromptTrust struct { Module string `arg:"" name:"module" help:"Module / command name that changed."` } @@ -24,50 +134,183 @@ func (p *PromptTrust) Run() error { if name == "" { return fmt.Errorf("module name required") } - msg := fmt.Sprintf("Untrusted module '%s' changed after running. Approve and reshim?", name) - _ = notify.Send(settings.AppId, "NVM Firewall", msg+" Answer Yes in the dialog or type y in the console.") - result := make(chan bool, 2) + if system.IsAppInForeground() { + return promptTrustConsole(name) + } + return promptTrustToast(name) +} - go func() { - fmt.Printf("%s [y/N]: ", msg) - reader := bufio.NewReader(os.Stdin) - line, err := reader.ReadString('\n') - if err != nil { - result <- false - return - } - line = strings.TrimSpace(line) - result <- len(line) > 0 && (line[0] == 'y' || line[0] == 'Y') - }() +// NotifyChanged fires a quiet toast when UntrustedModuleHandlerAction=allow +// auto-reshims after a module entrypoint change (no Trust/Cancel actions). +type NotifyChanged struct { + Module string `arg:"" name:"module" help:"Module / command name that changed."` +} - go func() { - result <- messageBoxYesNo("NVM Firewall", msg) - }() +func (n *NotifyChanged) Run() error { + name := strings.TrimSpace(n.Module) + if name == "" { + return fmt.Errorf("module name required") + } + msg := fmt.Sprintf("A change to module '%s' was automatically trusted.", name) + _ = notify.Send(settings.AppId, "NVM Firewall", msg) + // NVM4406 audit emitted by proxy at decision site. + return nil +} - ok := <-result - if ok { - log.LogStructured("firewall.trust_prompt_accepted", map[string]any{"module": name}, CodePolicyMutate) +// logUntrustedModuleChanged emits NVM4406 for an untrusted module change +// (plain text + structured). Used when CLI is the decision site (tests / future). +// outcome: deny|allow|prompt_accepted|prompt_declined. +func logUntrustedModuleChanged(module, outcome, handler, via string) { + plain := fmt.Sprintf( + "NVM%d Untrusted module '%s' changed (outcome=%s, handler=%s, via=%s)", + CodeUntrustedModuleChanged, module, outcome, handler, via, + ) + log.Log(plain, CodeUntrustedModuleChanged) + log.LogStructured("firewall.untrusted_module_changed", map[string]any{ + "module": module, + "outcome": outcome, + "handler": handler, + "via": via, + }, CodeUntrustedModuleChanged) +} + +func promptTrustConsole(name string) error { + msg := fmt.Sprintf("Untrusted module '%s' changed after running. Do you trust this module?", name) + fmt.Fprintf(os.Stderr, "NVM Firewall: %s [y/N]: ", msg) + reader := bufio.NewReader(os.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + fmt.Fprintf(os.Stderr, "%s is not trusted\n", name) + os.Exit(1) + return nil + } + line = strings.TrimSpace(line) + ok := len(line) > 0 && (line[0] == 'y' || line[0] == 'Y') + if !ok { + fmt.Fprintf(os.Stderr, "%s is not trusted\n", name) + os.Exit(1) return nil } - log.LogStructured("firewall.trust_prompt_declined", map[string]any{"module": name}, CodePolicyMutate) + if err := appendTrusted([]string{name}, false); err != nil { + return err + } + return nil +} + +func promptTrustToast(name string) error { + token, err := newTrustToken() + if err != nil { + return err + } + rspPath := trustResponsePath(token) + if err := os.WriteFile(rspPath, []byte("pending\n"), 0o600); err != nil { + return err + } + defer os.Remove(rspPath) + + msg := fmt.Sprintf("Untrusted module '%s' changed after running. Do you trust this module?", name) + _ = notify.Send( + settings.AppId, + "NVM Firewall", + msg, + notify.Action{ + Label: "Trust", + URL: fmt.Sprintf("nvm://firewall?action=trust&module=%s&token=%s", url.QueryEscape(name), token), + }, + notify.Action{ + Label: "Cancel", + URL: fmt.Sprintf("nvm://firewall?action=cancel&module=%s&token=%s", url.QueryEscape(name), token), + }, + ) + + deadline := time.Now().Add(10 * time.Minute) + for time.Now().Before(deadline) { + raw, readErr := os.ReadFile(rspPath) + if readErr == nil { + switch strings.TrimSpace(string(raw)) { + case "accept": + return nil + case "decline": + fmt.Fprintf(os.Stderr, "%s is not trusted\n", name) + os.Exit(1) + return nil + } + } + time.Sleep(400 * time.Millisecond) + } + + fmt.Fprintf(os.Stderr, "Trust prompt timed out for %s; reshim skipped.\n", name) os.Exit(1) return nil } -func messageBoxYesNo(title, body string) bool { - user32 := windows.NewLazySystemDLL("user32.dll") - proc := user32.NewProc("MessageBoxW") - t, err1 := syscall.UTF16PtrFromString(title) - b, err2 := syscall.UTF16PtrFromString(body) - if err1 != nil || err2 != nil { +// HandleProtocolURI handles nvm://firewall?action=trust|cancel&module=&token= from toast buttons. +func HandleProtocolURI(raw string) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("invalid nvm protocol URL: %w", err) + } + if !strings.EqualFold(u.Scheme, "nvm") { + return fmt.Errorf("unsupported protocol %q", u.Scheme) + } + host := strings.ToLower(strings.TrimSpace(u.Host)) + if host == "" { + host = strings.ToLower(strings.Trim(u.Path, "/")) + } + if host != "firewall" { + return fmt.Errorf("unsupported nvm:// host %q", host) + } + + q := u.Query() + action := strings.ToLower(strings.TrimSpace(q.Get("action"))) + module := strings.TrimSpace(q.Get("module")) + token := strings.TrimSpace(q.Get("token")) + if !validTrustToken(token) { + return fmt.Errorf("invalid trust token") + } + rspPath := trustResponsePath(token) + + switch action { + case "trust": + if module == "" { + return fmt.Errorf("module required") + } + if err := appendTrusted([]string{module}, false); err != nil { + return err + } + _ = os.WriteFile(rspPath, []byte("accept\n"), 0o600) + // NVM4406 emitted by prompt-trust waiter when it reads accept. + return nil + case "cancel", "decline": + _ = os.WriteFile(rspPath, []byte("decline\n"), 0o600) + // NVM4406 emitted by prompt-trust waiter when it reads decline. + return nil + default: + return fmt.Errorf("unknown firewall action %q", action) + } +} + +func newTrustToken() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +func validTrustToken(token string) bool { + if len(token) < 16 || len(token) > 64 { return false } - const mbYesNo = 0x00000004 - const mbIconQuestion = 0x00000020 - const mbSetForeground = 0x00010000 - const mbTopmost = 0x00040000 - const idYes = 6 - r, _, _ := proc.Call(0, uintptr(unsafe.Pointer(b)), uintptr(unsafe.Pointer(t)), uintptr(mbYesNo|mbIconQuestion|mbSetForeground|mbTopmost)) - return r == idYes + for _, c := range token { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +func trustResponsePath(token string) string { + return filepath.Join(os.TempDir(), "nvm-fw-trust-"+token+".rsp") } diff --git a/src/commands/firewall/root.go b/src/commands/firewall/root.go index 0cc68d2..7b88d05 100644 --- a/src/commands/firewall/root.go +++ b/src/commands/firewall/root.go @@ -2,92 +2,401 @@ package firewall import ( "common/modulefirewall" + "common/preferences" + "common/registry" "common/settings" "common/system" + "encoding/json" "fmt" + "nvm/constant" "nvm/log" + "os" "strings" ) // Event codes (NVM44xx firewall range). const ( - CodePolicyMutate = 4401 - CodeElevationRequired = 4404 - CodeInvalidRule = 4405 + CodeRemoteUnauthorized = 4401 // unused in community; reserved to match certified + CodeRemoteFailed = 4402 + CodeModuleBlocked = 4403 + CodeElevationRequired = 4404 + CodeInvalidRule = 4405 + CodeUntrustedModuleChanged = 4406 + CodeRemoteAllowed = 4407 + CodeModuleAllowed = 4408 // unused in community; reserved to match certified + CodeRemoteUnreachable = 4409 // unused in community; reserved to match certified + CodePolicyMutate = 4410 ) +const trustedModulesCfg = "trusted_modules" +const trustedModulesReg = "TrustedModules" + type Root struct { - Trust TrustRoot `cmd:"trust" help:"Manage TrustedModules for self-updating global CLIs."` - PromptTrust PromptTrust `cmd:"prompt-trust" hidden:"true" help:"Internal: dual-channel trust prompt for proxy."` + Trust TrustRoot `cmd:"trust" help:"Trust global module installations."` + Distrust DistrustRoot `cmd:"distrust" help:"Distrust global module installations."` + PromptTrust PromptTrust `cmd:"prompt-trust" hidden:"true" help:"Internal: dual-channel trust prompt for proxy."` + NotifyChanged NotifyChanged `cmd:"notify-changed" hidden:"true" help:"Internal: quiet toast when allow-mode module changes."` + CheckRemoteTrust CheckRemoteTrust `cmd:"check-remote-trust" hidden:"true" help:"Internal: HTTPS TrustedModules evaluation for proxy."` } type TrustRoot struct { Module TrustModule `cmd:"module" help:"Manage TrustedModules (self-update auto-reshim allow list)."` } +type DistrustRoot struct { + Module DistrustModuleRoot `cmd:"module" help:"Remove modules from TrustedModules / list current TrustedModules."` +} + +type DistrustModuleRoot struct { + List DistrustModuleList `cmd:"list" aliases:"ls" help:"List TrustedModules (same registry as trust module list)."` + Remove DistrustModule `cmd:"" default:"withargs" help:"Remove modules from TrustedModules."` +} + +type DistrustModuleList struct { + constant.FlagJSON +} + +type DistrustModule struct { + Machine bool `name:"machine" help:"Distrust modules for the entire machine."` + Entries []string `arg:"" optional:"" name:"entry" help:"Module patterns to remove from TrustedModules."` +} + type TrustModule struct { - Entries []string `arg:"" name:"entry" help:"Module patterns to trust for auto-reshim (or NOT to revoke)."` + List TrustModuleList `cmd:"list" aliases:"ls" help:"List trusted modules."` + Add TrustModuleAdd `cmd:"add" default:"withargs" help:"Add modules to TrustedModules (self-update auto-reshim allow list)."` +} + +type TrustModuleAdd struct { + Machine bool `name:"machine" help:"Trust modules for the entire machine."` + Entries []string `arg:"" name:"entry" help:"Module patterns to trust for auto-reshim."` +} + +type TrustModuleList struct { + constant.FlagJSON } func requireMachine() error { if err := system.RequireAdministrator(); err != nil { - log.ErrorStructured("firewall.elevation_required", map[string]any{ - "error": err.Error(), - }, CodeElevationRequired) + payload := map[string]any{"error": err.Error()} + enrichSIEM(payload) + log.ErrorStructured("firewall.elevation_required", payload, CodeElevationRequired) return fmt.Errorf("firewall policy changes require an elevated administrator prompt (NVM%d): %w", CodeElevationRequired, err) } return nil } -func appendSettingsList(cfgKey, regLabel string, add []string, negate bool) error { - if err := requireMachine(); err != nil { - return err +func normalizeEntry(e string) string { + return strings.TrimSpace(e) +} + +func entryKey(e string) string { + return strings.ToLower(normalizeEntry(e)) +} + +func stripNegation(e string) string { + e = normalizeEntry(e) + lower := strings.ToLower(e) + if strings.HasPrefix(lower, "not ") || strings.HasPrefix(lower, "not\t") { + return strings.TrimSpace(e[3:]) + } + if strings.HasPrefix(e, "!") { + return strings.TrimSpace(e[1:]) } - cur, _ := settings.Get(cfgKey) - var list []string + return e +} + +func dedupeList(list []string) []string { + seen := make(map[string]struct{}, len(list)) + out := make([]string, 0, len(list)) + for _, e := range list { + e = normalizeEntry(e) + if e == "" { + continue + } + k := entryKey(e) + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, e) + } + return out +} + +func valueToStringList(cur interface{}) []string { switch v := cur.(type) { case []string: - list = append([]string{}, v...) + return append([]string{}, v...) case string: - if strings.TrimSpace(v) != "" { - list = []string{v} + v = strings.TrimSpace(v) + if v == "" { + return nil + } + parts := strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' + }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = normalizeEntry(p) + if p != "" { + out = append(out, p) + } } + return out + default: + return nil + } +} + +func readTrustedList(machine bool) []string { + if machine { + list, _ := readTrustedAtRoot(preferences.MACHINE_PREFERENCE_ROOT) + return list + } + list, _ := readTrustedAtRoot(preferences.USER_PREFERENCE_ROOT) + if list != nil { + return list + } + // Fallback when USER_PREFERENCE_ROOT unset. + root := strings.TrimRight(strings.TrimSpace(preferences.ROOT), "/") + if root == "" { + cur, _ := settings.Get(trustedModulesCfg) + return valueToStringList(cur) + } + list, _ = readTrustedAtRoot(root) + return list +} + +// readEffectiveTrustedList returns the active trust list: HKLM overrides HKCU when present. +func readEffectiveTrustedList() []string { + if list, ok := readTrustedAtRoot(preferences.MACHINE_PREFERENCE_ROOT); ok { + return list + } + return readTrustedList(false) +} + +func readTrustedAtRoot(root string) ([]string, bool) { + root = strings.TrimRight(strings.TrimSpace(root), "/") + if root == "" { + return nil, false + } + value, exists, err := registry.Get(root + "/" + trustedModulesReg) + if err != nil || !exists || value == nil { + return nil, false } - for _, e := range add { - e = strings.TrimSpace(e) + return valueToStringList(value), true +} + +func writeTrustedList(list []string, machine bool) error { + list = dedupeList(list) + if machine { + if err := requireMachine(); err != nil { + return err + } + if len(list) == 0 { + if err := settings.DelMachine(trustedModulesCfg); err != nil { + payload := map[string]any{ + "key": trustedModulesCfg, + "error": err.Error(), + } + enrichSIEM(payload) + log.ErrorStructured("firewall.policy_mutate_failed", payload, CodePolicyMutate) + return err + } + } else if err := settings.PutMachine(trustedModulesCfg, strings.Join(list, ",")); err != nil { + payload := map[string]any{ + "key": trustedModulesCfg, + "error": err.Error(), + } + enrichSIEM(payload) + log.ErrorStructured("firewall.policy_mutate_failed", payload, CodePolicyMutate) + return err + } + } else { + if len(list) == 0 { + if err := settings.Del(trustedModulesCfg); err != nil { + payload := map[string]any{ + "key": trustedModulesCfg, + "error": err.Error(), + } + enrichSIEM(payload) + log.ErrorStructured("firewall.policy_mutate_failed", payload, CodePolicyMutate) + return err + } + } else if err := settings.Put(trustedModulesCfg, strings.Join(list, ",")); err != nil { + payload := map[string]any{ + "key": trustedModulesCfg, + "error": err.Error(), + } + enrichSIEM(payload) + log.ErrorStructured("firewall.policy_mutate_failed", payload, CodePolicyMutate) + return err + } + } + return nil +} + +func appendTrusted(entries []string, machine bool) error { + list := readTrustedList(machine) + added := 0 + for _, e := range entries { + e = normalizeEntry(e) if e == "" { continue } - if negate && !strings.HasPrefix(strings.ToLower(e), "not ") && !strings.HasPrefix(e, "!") { - e = "NOT " + e - } if err := modulefirewall.ValidateRuleEntry(e); err != nil { - log.ErrorStructured("firewall.invalid_rule", map[string]any{ - "key": cfgKey, + payload := map[string]any{ + "key": trustedModulesCfg, "entry": e, "error": err.Error(), - }, CodeInvalidRule) + } + enrichSIEM(payload) + log.ErrorStructured("firewall.invalid_rule", payload, CodeInvalidRule) return fmt.Errorf("invalid firewall entry %q (NVM%d): %w", e, CodeInvalidRule, err) } list = append(list, e) + added++ + } + list = dedupeList(list) + if err := writeTrustedList(list, machine); err != nil { + return err + } + for _, e := range entries { + e = normalizeEntry(e) + if e == "" { + continue + } + fmt.Printf("%s is now trusted\n", e) + } + scope := "user" + if machine { + scope = "machine" + } + log.Logf("firewall: updated %s (+%d entries, %s)", trustedModulesReg, added, scope) + payload := map[string]any{ + "key": trustedModulesCfg, + "added": entries, + "action": "append", + "machine": machine, + } + enrichSIEM(payload) + log.LogStructured("firewall.policy_mutated", payload, CodePolicyMutate) + return nil +} + +func entryMatchesRemove(stored, want string) bool { + stored = normalizeEntry(stored) + want = normalizeEntry(want) + if stored == "" || want == "" { + return false + } + if entryKey(stored) == entryKey(want) { + return true + } + return entryKey(stripNegation(stored)) == entryKey(stripNegation(want)) +} + +func removeTrusted(entries []string, machine bool) error { + list := readTrustedList(machine) + removeSet := make([]string, 0, len(entries)) + for _, e := range entries { + e = normalizeEntry(e) + if e == "" { + continue + } + // Prefer ValidateRuleEntry; bare names still proceed even if loose. + if err := modulefirewall.ValidateRuleEntry(e); err != nil { + bare := stripNegation(e) + if bare == "" || strings.ContainsAny(bare, " \t") { + payload := map[string]any{ + "key": trustedModulesCfg, + "entry": e, + "error": err.Error(), + } + enrichSIEM(payload) + log.ErrorStructured("firewall.invalid_rule", payload, CodeInvalidRule) + return fmt.Errorf("invalid firewall entry %q (NVM%d): %w", e, CodeInvalidRule, err) + } + // simple name — still attempt removal + } + removeSet = append(removeSet, e) + } + + kept := make([]string, 0, len(list)) + removed := 0 + for _, stored := range list { + drop := false + for _, want := range removeSet { + if entryMatchesRemove(stored, want) { + drop = true + break + } + } + if drop { + removed++ + continue + } + kept = append(kept, stored) } - if err := settings.PutMachine(cfgKey, list); err != nil { - log.ErrorStructured("firewall.policy_mutate_failed", map[string]any{ - "key": cfgKey, - "error": err.Error(), - }, CodePolicyMutate) + kept = dedupeList(kept) + if err := writeTrustedList(kept, machine); err != nil { return err } - log.Logf("firewall: updated %s (+%d entries)", regLabel, len(add)) - log.LogStructured("firewall.policy_mutated", map[string]any{ - "key": cfgKey, - "added": add, - "negate": negate, - "action": "append", - }, CodePolicyMutate) + scope := "user" + if machine { + scope = "machine" + } + log.Logf("firewall: updated %s (-%d entries, %s)", trustedModulesReg, removed, scope) + payload := map[string]any{ + "key": trustedModulesCfg, + "removed": entries, + "action": "remove", + "machine": machine, + } + enrichSIEM(payload) + log.LogStructured("firewall.policy_mutated", payload, CodePolicyMutate) + return nil +} + +func (t *TrustModuleAdd) Run() error { + if len(t.Entries) == 0 { + return fmt.Errorf("entry required (or use: nvm firewall trust module list)") + } + return appendTrusted(t.Entries, t.Machine) +} + +func (t *TrustModuleList) Run() error { + return printTrustedModulesList(t.JSON) +} + +func (d *DistrustModuleList) Run() error { + return printTrustedModulesList(d.JSON) +} + +func printTrustedModulesList(asJSON bool) error { + list := readEffectiveTrustedList() + if asJSON { + if list == nil { + list = []string{} + } + out, err := json.MarshalIndent(list, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal TrustedModules to JSON: %w", err) + } + fmt.Println(string(out)) + return nil + } + if len(list) == 0 { + fmt.Println("no trusted modules") + return nil + } + modulefirewall.FormatHumanList(os.Stdout, list) return nil } -func (t *TrustModule) Run() error { - return appendSettingsList("trusted_modules", "TrustedModules", t.Entries, false) +func (u *DistrustModule) Run() error { + if len(u.Entries) == 0 { + return fmt.Errorf("entry required (or use: nvm firewall distrust module list)") + } + return removeTrusted(u.Entries, u.Machine) } diff --git a/src/commands/firewall/trust_test.go b/src/commands/firewall/trust_test.go new file mode 100644 index 0000000..c7b469c --- /dev/null +++ b/src/commands/firewall/trust_test.go @@ -0,0 +1,55 @@ +package firewall + +import ( + "reflect" + "testing" +) + +func TestCodeConstants(t *testing.T) { + if CodeRemoteFailed != 4402 { + t.Fatalf("CodeRemoteFailed=%d, want 4402", CodeRemoteFailed) + } + if CodeModuleBlocked != 4403 { + t.Fatalf("CodeModuleBlocked=%d, want 4403", CodeModuleBlocked) + } + if CodeRemoteAllowed != 4407 { + t.Fatalf("CodeRemoteAllowed=%d, want 4407", CodeRemoteAllowed) + } +} + +func TestDedupeList(t *testing.T) { + got := dedupeList([]string{"eslint", "ESLint", " porthog ", "porthog", "", "NOT ALL", "not all"}) + want := []string{"eslint", "porthog", "NOT ALL"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestEntryMatchesRemove(t *testing.T) { + tests := []struct { + stored, want string + match bool + }{ + {"porthog", "porthog", true}, + {"Porthog", "porthog", true}, + {"NOT porthog", "porthog", true}, + {"!porthog", "porthog", true}, + {"NOT porthog", "NOT porthog", true}, + {"!porthog", "NOT porthog", true}, + {"eslint", "porthog", false}, + {"NOT ALL", "ALL", true}, + {"ALL", "NOT ALL", true}, + } + for _, tt := range tests { + if got := entryMatchesRemove(tt.stored, tt.want); got != tt.match { + t.Fatalf("entryMatchesRemove(%q,%q)=%v, want %v", tt.stored, tt.want, got, tt.match) + } + } +} + +func TestReadTrustedAtRootAbsent(t *testing.T) { + list, ok := readTrustedAtRoot("") + if ok || list != nil { + t.Fatalf("empty root: list=%v ok=%v", list, ok) + } +} diff --git a/src/commands/root.go b/src/commands/root.go index 386a8e0..1983648 100644 --- a/src/commands/root.go +++ b/src/commands/root.go @@ -27,7 +27,7 @@ type RootCommand struct { Env Env `cmd:"env" help:"Display ${app} environment details."` 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" help:"Manage NVM trust firewall policy."` + Firewall firewall.Root `cmd:"firewall" aliases:"fw" help:"Manage NVM trust firewall policy."` 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"` diff --git a/src/commands/use/version.go b/src/commands/use/version.go index 1ded31b..1f19616 100644 --- a/src/commands/use/version.go +++ b/src/commands/use/version.go @@ -15,8 +15,8 @@ import ( type Version struct { constant.FlagInstall constant.FlagNoInstall - constant.ArgVersion - Local bool `flag:"local" short:"l" help:"Use the latest installed version matching the specified partial version."` + Version []string `arg:"" name:"version" optional:"" help:"Node.js version to activate (e.g. latest, lts, x.x.x)."` + Local bool `flag:"local" short:"l" help:"Use the latest installed version matching the specified partial version."` } func getStringSetting(name string) (string, error) { @@ -61,6 +61,9 @@ func notInstalledUseError(version, mode string, autoInstallDisabled bool) error } func (s *Version) Run() error { + if len(s.Version) == 0 { + return fmt.Errorf("Missing version. Auto-detection is used by shims (node/npm), not by nvm use.") + } requestedVersion := s.Version[0] cfg := settings.Global() diff --git a/src/go.mod b/src/go.mod index 033a32f..5beffcc 100644 --- a/src/go.mod +++ b/src/go.mod @@ -62,6 +62,7 @@ require ( common/resolver v1.0.0 common/settings v1.0.0 common/system v1.0.0 + common/token v1.0.0 common/verify v1.0.0 common/verifycache v1.0.0 common/version_support v1.0.0 @@ -76,7 +77,6 @@ require ( require ( common/cose v1.0.0 // indirect common/proxy v1.0.0 // indirect - common/token v1.0.0 // indirect common/urlguard v1.0.0 // indirect github.com/akavel/rsrc v0.10.2 // indirect github.com/andybalholm/brotli v1.1.1 // indirect diff --git a/src/installer/activation.go b/src/installer/activation.go index ce07ea3..7fe1848 100644 --- a/src/installer/activation.go +++ b/src/installer/activation.go @@ -117,6 +117,17 @@ func ActivateVersion(version string) error { go notify.Send(settings.AppId, "", msg) } + log.LogStructured("nvm.version.activated", log.StructuredPayload{ + "action": "activated", + "version": version, + "previous_version": lastVersion, + "mode": mode, + "user": log.Actor(), + "sid": log.ActorSid(), + "hostname": log.Hostname(), + "correlation_id": log.NewCorrelationID(), + }) + return nil } diff --git a/src/log/audit.go b/src/log/audit.go index 166f356..c8b83c8 100644 --- a/src/log/audit.go +++ b/src/log/audit.go @@ -18,10 +18,13 @@ func LogSystemChanged(action, nodeVersion, resolvedPath, outcome, detail string, } payload := StructuredPayload{ - "Action": action, - "NodeVersion": nodeVersion, - "Outcome": outcome, - "User": Actor(), + "Action": action, + "NodeVersion": nodeVersion, + "Outcome": outcome, + "User": Actor(), + "sid": ActorSid(), + "hostname": Hostname(), + "correlation_id": NewCorrelationID(), } if strings.TrimSpace(resolvedPath) != "" { payload["ResolvedPath"] = resolvedPath @@ -52,10 +55,13 @@ func LogConfigurationChanged(key, value, oldValue, outcome, detail string) { } payload := StructuredPayload{ - "Action": "Modified", - "Configuration": key, - "Outcome": outcome, - "User": Actor(), + "Action": "Modified", + "Configuration": key, + "Outcome": outcome, + "User": Actor(), + "sid": ActorSid(), + "hostname": Hostname(), + "correlation_id": NewCorrelationID(), } if strings.TrimSpace(value) != "" { payload["Value"] = value diff --git a/src/log/log.go b/src/log/log.go index 42db8a2..809e864 100644 --- a/src/log/log.go +++ b/src/log/log.go @@ -3,9 +3,13 @@ package log import ( "common/eventlog" "common/license" + "common/system" + "crypto/rand" + "encoding/hex" "encoding/json" "os" "os/user" + "path/filepath" "strings" ) @@ -114,6 +118,84 @@ func Actor() string { return "unknown" } +// ActorSid returns the current user's security identifier (Windows: user.Current().Uid). +func ActorSid() string { + if current, err := user.Current(); err == nil { + sid := strings.TrimSpace(current.Uid) + if sid != "" { + return sid + } + } + return "unknown" +} + +// Hostname returns the local machine name for audit correlation. +func Hostname() string { + name, err := os.Hostname() + if err != nil { + return "unknown" + } + name = strings.TrimSpace(name) + if name == "" { + return "unknown" + } + return name +} + +// NewCorrelationID returns a short random hex identifier for correlating related audit events. +func NewCorrelationID() string { + var b [12]byte + if _, err := rand.Read(b[:]); err != nil { + return "unknown" + } + return hex.EncodeToString(b[:]) +} + +// ParentProcess returns the immediate parent process executable file name. +func ParentProcess() string { + return system.ParentProcessExecutable() +} + +// ProjectName returns the nearest package.json "name" walking up from cwd. +func ProjectName() string { + name, _ := projectNameAndPath() + return name +} + +// ProjectPath returns the absolute path to the nearest package.json from cwd. +func ProjectPath() string { + _, path := projectNameAndPath() + return path +} + +func projectNameAndPath() (string, string) { + dir, err := os.Getwd() + if err != nil { + return "", "" + } + for { + pkg := filepath.Join(dir, "package.json") + raw, err := os.ReadFile(pkg) + if err == nil { + var meta struct { + Name string `json:"name"` + } + if json.Unmarshal(raw, &meta) == nil { + name := strings.TrimSpace(meta.Name) + if name != "" { + return name, pkg + } + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", "" +} + // ExampleStructuredUsage demonstrates how to send a custom structured event. // Keep this as an inline reference while structured event adoption rolls out. func ExampleStructuredUsage() { diff --git a/src/manifest.json b/src/manifest.json index 5fe90bc..a0d38f2 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -35,6 +35,7 @@ "common/http.appname": "manifest.appLabel", "common/http.version": "manifest.version", "common/http.edition": "community", + "common/license.buildChannel": "community", "common/http.httpCacheRoot": "metadata", "common/eventlog.providerName": "manifest.appLabel", "common/eventlog.providerDisplayName": "manifest.appLabel", @@ -55,4 +56,3 @@ "github.com/nvm-windows/sync": "1.0.0" } } - From 34bfcfdb2726cfbd7fc33c42a4de7e7af7e3f57a Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:39:23 -0500 Subject: [PATCH 4/8] chore: bump version to 2.0.1-beta.1 --- src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.json b/src/manifest.json index a0d38f2..ccc41a2 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,6 +1,6 @@ { "name": "nvm", - "version": "2.0.1", + "version": "2.0.1-beta.1", "description": "Node Version Manager for Windows", "appLabel": "NVM for Windows", "appId": "40078385-F676-4C61-9A9C-F9028599D6D3", From 82e8f65a2573e60ae404089b09787bda6a9f589a Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:39:23 -0500 Subject: [PATCH 5/8] chore: revert manifest to 2.0.1 Prerelease stamps come from GHA prerelease input, not git. --- src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.json b/src/manifest.json index ccc41a2..a0d38f2 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,6 +1,6 @@ { "name": "nvm", - "version": "2.0.1-beta.1", + "version": "2.0.1", "description": "Node Version Manager for Windows", "appLabel": "NVM for Windows", "appId": "40078385-F676-4C61-9A9C-F9028599D6D3", From 016eae5f795779c3e49d3a5774566c116be91866 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:46:35 -0500 Subject: [PATCH 6/8] fix(cli): sign every install on upgrade Installer needs a direct signer because reshim drops nvm.exe errors. Refs #1412 Co-authored-by: Cursor --- src/cmd/main.go | 8 ++++++++ src/commands/env.go | 2 +- src/commands/env_test.go | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cmd/main.go b/src/cmd/main.go index d13a0af..8ada4ad 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -75,6 +75,14 @@ func main() { os.Exit(1) } return + case "--sign-installed-versions": + // Installer upgrade from 2.0.0: backfill script trust for every installs\v*. + settings.Load() + if err := verifycache.PrewarmVerifyCache(true); err != nil { + fmt.Fprint(os.Stderr, err.Error()) + os.Exit(1) + } + return case "--sign-version-scripts": // Invoked by detached reshim after global package installs so proxy // can trust newly written .cmd/.bat launchers without executing them first. diff --git a/src/commands/env.go b/src/commands/env.go index 52f44b8..1b08f17 100644 --- a/src/commands/env.go +++ b/src/commands/env.go @@ -577,7 +577,7 @@ func untrustedHandlerLabel(raw string) string { // summarizeTrustedModules returns "ALL" when everything is trusted, otherwise // the count of positive (allow) TrustedModules patterns. func summarizeTrustedModules(entries []string) string { - rules := modulefirewall.NormalizeList(entries, modulefirewall.DefaultTrustedWhenEmpty) + rules := modulefirewall.NormalizeTrustedModules(entries) if endpoint, ok := modulefirewall.ExtractHTTPSURL(rules); ok { return endpoint } diff --git a/src/commands/env_test.go b/src/commands/env_test.go index 38ec75b..2334394 100644 --- a/src/commands/env_test.go +++ b/src/commands/env_test.go @@ -12,7 +12,7 @@ func TestSummarizeTrustedModules(t *testing.T) { entries []string want string }{ - {name: "empty defaults to zero", entries: nil, want: "0"}, + {name: "empty defaults to npm and npx", entries: nil, want: "2"}, {name: "not all alone", entries: []string{"NOT ALL"}, want: "0"}, {name: "all", entries: []string{"ALL"}, want: "ALL"}, {name: "exceptions", entries: []string{"NOT ALL", "opencode", "porthog"}, want: "2"}, From c58fbaa8645ef14152ca63dce72b11130e9c2688 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:09:12 -0500 Subject: [PATCH 7/8] test(env): expect six default trusted PMs Refs #1415 Co-authored-by: Cursor --- src/commands/env_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/env_test.go b/src/commands/env_test.go index 2334394..cd41214 100644 --- a/src/commands/env_test.go +++ b/src/commands/env_test.go @@ -12,7 +12,7 @@ func TestSummarizeTrustedModules(t *testing.T) { entries []string want string }{ - {name: "empty defaults to npm and npx", entries: nil, want: "2"}, + {name: "empty defaults to bundled package managers", entries: nil, want: "6"}, {name: "not all alone", entries: []string{"NOT ALL"}, want: "0"}, {name: "all", entries: []string{"ALL"}, want: "ALL"}, {name: "exceptions", entries: []string{"NOT ALL", "opencode", "porthog"}, want: "2"}, From 0eab8a71c0a56281b0992ab2edcb4f2993341b62 Mon Sep 17 00:00:00 2001 From: coreybutler <770982+coreybutler@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:58:22 -0500 Subject: [PATCH 8/8] fix(cli): show license/layout advisories on help/env only Avoid NVM4101/NVM4102 noise on routine commands; certified packages without a token get NVM4102 instead of layout warn. Co-authored-by: Cursor --- src/cmd/layout_warn.go | 30 ++++++++++++++++++++++++++++++ src/cmd/main.go | 6 ++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/cmd/layout_warn.go b/src/cmd/layout_warn.go index ba34b65..2849b09 100644 --- a/src/cmd/layout_warn.go +++ b/src/cmd/layout_warn.go @@ -10,6 +10,14 @@ import ( "time" ) +func warnStartupAdvisoriesIfNeeded() { + if license.IsCommunityBuild() { + warnCommunityProgramRootIfNeeded() + return + } + warnCertifiedCommunityFeatureModeIfNeeded() +} + func warnCommunityProgramRootIfNeeded() { root, err := bootstrap.ProgramRoot() if err != nil { @@ -32,3 +40,25 @@ func warnCommunityProgramRootIfNeeded() { _ = err } } + +func warnCertifiedCommunityFeatureModeIfNeeded() { + if !license.InCommunityFeatureMode() { + return + } + msg := license.CommunityFeatureModeWarning() + fmt.Fprintln(os.Stderr, msg) + + root, err := bootstrap.ProgramRoot() + if err != nil { + return + } + stamp := filepath.Join(root, ".cache", "community-feature-mode-warn.stamp") + if _, err := eventlog.WriteApplicationWarningThrottled( + uint32(license.FeatureModeWarnEventID), + msg, + stamp, + time.Hour, + ); err != nil { + _ = err + } +} diff --git a/src/cmd/main.go b/src/cmd/main.go index 8ada4ad..46f2e6c 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -244,7 +244,6 @@ func main() { case "-v", "--version", "version": settings.Load() fmt.Printf("v%s\n", version) - warnCommunityProgramRootIfNeeded() return case "-h", "--help", "help": // Handled by kong after lightweight init (no shim/reshim/ARP). @@ -268,7 +267,10 @@ func main() { } settings.Load() - warnCommunityProgramRootIfNeeded() + // Layout / license advisories only on help and env — not every command. + if metaHelp || strings.EqualFold(os.Args[1], "env") { + warnStartupAdvisoriesIfNeeded() + } desc := fmt.Sprintf("%s\nv%s (%s Edition).", description, version, license.Edition())