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/bootstrap/init_test.go b/src/bootstrap/init_test.go index cb660a5..b113e2a 100644 --- a/src/bootstrap/init_test.go +++ b/src/bootstrap/init_test.go @@ -368,8 +368,16 @@ func TestEnsureUserProfileInitializedCleansLegacyPayload(t *testing.T) { createRegistryKey(t, legacyShellRegistrationBase+`\shell\open\command`, map[string]string{ "": `"` + legacyNvmExe + `" "%1"`, }) + nodejsPath := filepath.Join(root, ".nodejs") createRegistryKey(t, `Environment`, map[string]string{ - "NVM_HOME": root, + "NVM_HOME": root, + "NVM_SYMLINK": nodejsPath, + "Path": strings.Join([]string{ + `C:\Windows\system32`, + root, + nodejsPath, + `C:\Tools`, + }, ";"), }) if err := EnsureUserProfileInitialized(); err != nil { @@ -386,6 +394,11 @@ func TestEnsureUserProfileInitializedCleansLegacyPayload(t *testing.T) { assertRegistryKeyMissing(t, legacySyncAppPathKey) assertRegistryKeyMissing(t, legacyShellRegistrationBase+`\shell\open\command`) assertRegistryValueMissing(t, `Environment`, "NVM_HOME") + assertRegistryValueMissing(t, `Environment`, "NVM_SYMLINK") + assertUserPathContains(t, `C:\Windows\system32`) + assertUserPathContains(t, nodejsPath) + assertUserPathContains(t, `C:\Tools`) + assertUserPathMissing(t, root) if len(deletedTasks) != 1 || deletedTasks[0] != "NVM Sync" { t.Fatalf("deleted tasks = %#v, want [\"NVM Sync\"]", deletedTasks) } @@ -687,6 +700,43 @@ func assertRegistryValueMissing(t *testing.T, keyPath, valueName string) { } } +func readUserPath(t *testing.T) string { + t.Helper() + key, err := winreg.OpenKey(winreg.CURRENT_USER, `Environment`, winreg.QUERY_VALUE) + if err != nil { + t.Fatalf("OpenKey(Environment) error = %v", err) + } + defer key.Close() + value, _, err := key.GetStringValue("Path") + if err != nil { + t.Fatalf("GetStringValue(Path) error = %v", err) + } + return value +} + +func assertUserPathContains(t *testing.T, segment string) { + t.Helper() + path := readUserPath(t) + normSeg := normalizePathMatch(segment) + for _, part := range strings.Split(path, ";") { + if normalizePathMatch(part) == normSeg { + return + } + } + t.Fatalf("user Path %q missing segment %q", path, segment) +} + +func assertUserPathMissing(t *testing.T, segment string) { + t.Helper() + path := readUserPath(t) + normSeg := normalizePathMatch(segment) + for _, part := range strings.Split(path, ";") { + if normalizePathMatch(part) == normSeg { + t.Fatalf("user Path %q still contains segment %q", path, segment) + } + } +} + func createProgramSyncSeed(t *testing.T, relPath string, content []byte) string { t.Helper() diff --git a/src/bootstrap/migration.go b/src/bootstrap/migration.go index 3382581..5432280 100644 --- a/src/bootstrap/migration.go +++ b/src/bootstrap/migration.go @@ -9,6 +9,8 @@ import ( "strings" "syscall" + "nvm/legacy" + winreg "golang.org/x/sys/windows/registry" ) @@ -88,24 +90,127 @@ func removeLegacyCurrentUserEnv(dataRoot string) error { } defer key.Close() - nvmHome, _, valueErr := key.GetStringValue("NVM_HOME") - if valueErr != nil { - if valueErr == winreg.ErrNotExist { - return nil + changed := false + + nvmHome, _, homeErr := key.GetStringValue("NVM_HOME") + if homeErr != nil && homeErr != winreg.ErrNotExist { + return fmt.Errorf("failed to read current-user NVM_HOME: %w", homeErr) + } + nvmSymlink, _, linkErr := key.GetStringValue("NVM_SYMLINK") + if linkErr != nil && linkErr != winreg.ErrNotExist { + return fmt.Errorf("failed to read current-user NVM_SYMLINK: %w", linkErr) + } + + forceRemove := map[string]bool{} + if homeErr == nil && (valueReferencesPath(nvmHome, dataRoot) || looksLikeAuthorNvmHome(nvmHome)) { + if err := key.DeleteValue("NVM_HOME"); err != nil && err != winreg.ErrNotExist { + return fmt.Errorf("failed to delete current-user NVM_HOME: %w", err) } - return fmt.Errorf("failed to read current-user NVM_HOME: %w", valueErr) + forceRemove[normalizePathMatch(nvmHome)] = true + forceRemove[strings.ToLower("%NVM_HOME%")] = true + changed = true } - if !valueReferencesPath(nvmHome, dataRoot) { - return nil + if linkErr == nil && (valueReferencesPath(nvmSymlink, dataRoot) || looksLikeLegacyNvmSymlink(nvmSymlink)) { + if err := key.DeleteValue("NVM_SYMLINK"); err != nil && err != winreg.ErrNotExist { + return fmt.Errorf("failed to delete current-user NVM_SYMLINK: %w", err) + } + forceRemove[normalizePathMatch(nvmSymlink)] = true + forceRemove[strings.ToLower("%NVM_SYMLINK%")] = true + changed = true } - if err := key.DeleteValue("NVM_HOME"); err != nil && err != winreg.ErrNotExist { - return fmt.Errorf("failed to delete current-user NVM_HOME: %w", err) + userPath, _, pathErr := key.GetStringValue("Path") + if pathErr != nil { + if pathErr == winreg.ErrNotExist { + if changed { + legacy.BroadcastEnvironmentChange() + } + return nil + } + return fmt.Errorf("failed to read current-user Path: %w", pathErr) } + // Also strip community program-root PATH entries (keep .nodejs). + cleaned := filterUserPath(userPath, dataRoot, forceRemove) + if cleaned != userPath { + if err := key.SetExpandStringValue("Path", cleaned); err != nil { + return fmt.Errorf("failed to rewrite current-user Path: %w", err) + } + changed = true + } + if changed { + legacy.BroadcastEnvironmentChange() + } return nil } +// RemoveLegacyCurrentUserEnv clears leftover HKCU NVM_HOME/NVM_SYMLINK and community +// program-root user PATH segments while keeping dataRoot\.nodejs. Used by MSI +// impersonated install CA and first-launch bootstrap. +func RemoveLegacyCurrentUserEnv(dataRoot string) error { + return removeLegacyCurrentUserEnv(dataRoot) +} + +func looksLikeLegacyNvmSymlink(value string) bool { + norm := normalizePathMatch(value) + if norm == "" { + return false + } + trimmed := strings.TrimSpace(value) + return strings.EqualFold(norm, `c:\nodejs`) || + strings.EqualFold(trimmed, `%NVM_SYMLINK%`) || + strings.HasSuffix(norm, `\author software\nvm\.link`) || + strings.HasSuffix(norm, `\author software\nvm\.nodejs`) +} + +func looksLikeAuthorNvmHome(value string) bool { + norm := normalizePathMatch(value) + if norm == "" { + return false + } + return strings.HasSuffix(norm, `\author software\nvm`) +} + +// filterUserPath drops legacy NVM segments and the community program root for dataRoot +// while keeping dataRoot\.nodejs. +func filterUserPath(userPath, dataRoot string, forceRemove map[string]bool) string { + if forceRemove == nil { + forceRemove = map[string]bool{} + } + dataNorm := normalizePathMatch(dataRoot) + nodejsNorm := normalizePathMatch(filepath.Join(dataRoot, ".nodejs")) + + segments := strings.Split(userPath, ";") + kept := make([]string, 0, len(segments)) + for _, seg := range segments { + trimmed := strings.TrimSpace(seg) + if trimmed == "" { + continue + } + norm := normalizePathMatch(trimmed) + expanded := normalizePathMatch(os.ExpandEnv(trimmed)) + + // Keep .nodejs shim path even when NVM_SYMLINK pointed at it. + if norm == nodejsNorm || expanded == nodejsNorm || + strings.HasSuffix(norm, `\author software\nvm\.nodejs`) || + strings.HasSuffix(expanded, `\author software\nvm\.nodejs`) { + kept = append(kept, seg) + continue + } + if forceRemove[norm] || forceRemove[expanded] { + continue + } + // Drop community program root (data root itself). + if (dataNorm != "" && (norm == dataNorm || expanded == dataNorm)) || + strings.HasSuffix(norm, `\author software\nvm`) || + strings.HasSuffix(expanded, `\author software\nvm`) { + continue + } + kept = append(kept, seg) + } + return strings.Join(kept, ";") +} + func removeLegacyPath(path string) error { if _, err := os.Lstat(path); err != nil { if os.IsNotExist(err) { diff --git a/src/bootstrap/migration_path_test.go b/src/bootstrap/migration_path_test.go new file mode 100644 index 0000000..88e0636 --- /dev/null +++ b/src/bootstrap/migration_path_test.go @@ -0,0 +1,58 @@ +package bootstrap + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestFilterUserPath_KeepsNodejsDropsProgramRoot(t *testing.T) { + root := `C:\Users\a\AppData\Local\Author Software\nvm` + nodejs := filepath.Join(root, ".nodejs") + in := strings.Join([]string{ + `C:\Windows\system32`, + root, + nodejs, + `C:\Tools`, + `%NVM_HOME%`, + }, ";") + force := map[string]bool{ + strings.ToLower("%NVM_HOME%"): true, + normalizePathMatch(root): true, + } + + got := filterUserPath(in, root, force) + + for _, keep := range []string{`C:\Windows\system32`, nodejs, `C:\Tools`} { + if !pathHasSegment(got, keep) { + t.Fatalf("expected keep %q in %q", keep, got) + } + } + for _, drop := range []string{root, `%NVM_HOME%`} { + if pathHasSegment(got, drop) { + t.Fatalf("expected drop %q from %q", drop, got) + } + } +} + +func pathHasSegment(path, segment string) bool { + want := normalizePathMatch(segment) + for _, part := range strings.Split(path, ";") { + if normalizePathMatch(part) == want { + return true + } + } + return false +} + +func TestLooksLikeLegacyNvmSymlink(t *testing.T) { + if !looksLikeLegacyNvmSymlink(`C:\nodejs`) { + t.Fatal("want classic C:\\nodejs") + } + if !looksLikeLegacyNvmSymlink(`C:\Users\a\AppData\Local\Author Software\nvm\.nodejs`) { + t.Fatal("want Author Software .nodejs") + } + if looksLikeLegacyNvmSymlink(`D:\custom\node-link`) { + t.Fatal("custom link must not match") + } +} diff --git a/src/cmd/layout_warn.go b/src/cmd/layout_warn.go index ac2e34e..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 { @@ -33,9 +41,24 @@ func warnCommunityProgramRootIfNeeded() { } } -func communityEditionWatermark() string { - if license.Edition() != "Community" { - return "" +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 } - return "Community (per-user LocalAppData install; see nvm doctor)" } diff --git a/src/cmd/main.go b/src/cmd/main.go index d16e2cd..f872673 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. @@ -55,6 +66,33 @@ func main() { log.Log("Event source registered successfully.") return + case "--remove-legacy-system-env": + // Invoked by the certified MSI (elevated) after InstallFiles to clear v1 + // SYSTEM NVM_HOME/NVM_SYMLINK and strip community program-root PATH entries + // without re-registering the ETW provider (MSI uses wevtutil for that). + if err := legacy.RemoveSystemEnvVars(); err != nil { + fmt.Fprint(os.Stderr, err.Error()) + os.Exit(1) + } + return + case "--remove-legacy-user-env": + // Impersonated MSI CA: clear leftover HKCU NVM_* and community user PATH + // so shells pick Program Files nvm without waiting for first bootstrap. + settings.Load() + dataRoot, err := bootstrap.DataRoot() + if err != nil || dataRoot == "" { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + fmt.Fprint(os.Stderr, "LOCALAPPDATA is empty; cannot clean user env\n") + os.Exit(1) + } + dataRoot = filepath.Join(local, "Author Software", "nvm") + } + if err := bootstrap.RemoveLegacyCurrentUserEnv(dataRoot); err != nil { + fmt.Fprint(os.Stderr, err.Error()) + os.Exit(1) + } + return case "--register-installed-versions": // Invoked by the installer after migration to ensure all migrated // versions are registered in Windows Apps the same way normal installs are. @@ -64,15 +102,40 @@ 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. - 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 +145,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,10 +271,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": // Handled by kong after lightweight init (no shim/reshim/ARP). @@ -232,12 +294,13 @@ func main() { } settings.Load() - warnCommunityProgramRootIfNeeded() + settings.ProductVersion = version + // 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()) - if mark := communityEditionWatermark(); mark != "" { - desc = fmt.Sprintf("%s\nv%s (%s Edition).\n%s.", description, version, license.Edition(), mark) - } cli := kong.Parse( root, @@ -310,6 +373,13 @@ func commandNeedsBootstrap(commandPath string) bool { if cmd == "" || cmd == "help" { return false } + // Proxy firewall helpers run on every npm i. Skip bootstrap here so an MSI + // overwrite does not pay MaintainShimDirectory sync+reshim on the install hot path. + // Shim sync still runs on the next user-facing nvm command. + if strings.HasPrefix(cmd, "firewall check-remote") || + strings.HasPrefix(cmd, "firewall refresh-npm-identity") { + return false + } // First path segment only (e.g. "install " → "install"). if i := strings.IndexByte(cmd, ' '); i >= 0 { cmd = cmd[:i] @@ -337,3 +407,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..1b08f17 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.NormalizeTrustedModules(entries) + 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..cd41214 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 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"}, + {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 new file mode 100644 index 0000000..3b6fcd8 --- /dev/null +++ b/src/commands/firewall/helpers.go @@ -0,0 +1,545 @@ +package firewall + +import ( + "bufio" + "common/mirrorauth" + "common/modulefirewall" + "common/notify" + "common/settings" + "common/system" + "crypto/rand" + "encoding/hex" + "fmt" + neturl "net/url" + "nvm/log" + "os" + "path/filepath" + "strings" + "time" + + license "common/license" +) + +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 and structured payload. +// Certified LogStructured/ErrorStructured emit ETW structured events. +func logRemoteTrustEvent(event string, code int, plain string, payload map[string]any) { + enrichSIEM(payload) + if plain != "" { + log.Log(plain, code) + } + switch code { + case CodeRemoteFailed, CodeRemoteUnreachable, CodeRemoteUnauthorized, CodeModuleBlocked: + log.ErrorStructured(event, payload, code) + default: + log.LogStructured(event, payload, code) + } +} + +func firewallUA() string { + build := "certified" + if license.IsCommunityBuild() { + build = "community" + } + return modulefirewall.FirewallUserAgent(settings.ProductVersion, build) +} + +func buildFirewallRemoteOpts(endpoint, shim string, art modulefirewall.ManifestArtifact, tls modulefirewall.RemoteTLSOptions) modulefirewall.RemoteRequestOptions { + cwd, _ := os.Getwd() + cfg := settings.Global() + ctx := modulefirewall.BuildRequestContextWithRoot(cwd, shim, cfg.ActiveVersion, settings.Expand(cfg.Root)) + aud := mirrorauth.FirewallAudienceHost(endpoint) + token, err := mirrorauth.MintFirewallJWT(aud, ctx) + if err != nil { + log.Logf("firewall jwt mint failed: %v", err) + } + + opts := modulefirewall.RemoteRequestOptions{ + RemoteTLSOptions: tls, + UserAgent: firewallUA(), + SpinnerAfter: 200 * time.Millisecond, + } + if strings.TrimSpace(token) != "" { + opts.Authorization = token + } + if art.Source == "package.json" && len(art.Body) > 0 { + opts.Body = art.Body + opts.ContentType = art.ContentType + if art.PackageShasum != "" { + opts.ExtraHeaders = map[string]string{"x-nvm-package-shasum": art.PackageShasum} + } + } else if art.Source == "lock" && len(art.Body) > 0 { + opts.Body = art.Body + opts.ContentType = art.ContentType + } + return opts +} + +// CheckRemote is an internal helper for proxy.exe HTTPS module-firewall evaluation. +// Exit semantics: 0 allow, 1 deny/block, 2 transport/config error (NVM4402 / NVM4409). +type CheckRemote struct { + Global bool `flag:"global" help:"Evaluate ApprovedGlobalModules instead of ApprovedModules."` + Shim string `flag:"shim" help:"Proxied entrypoint name (npm, npx, pnpm, yarn, vlt)."` + Cwd string `flag:"cwd" help:"Working directory for manifest resolution (default: process cwd)."` + OmitDev bool `flag:"omit-dev" help:"Exclude package.json devDependencies (production-like install)."` + Modules []string `arg:"" optional:"" name:"module" help:"Package tokens to POST (name or name@version). Empty = expand from package.json/lock."` +} + +func (c *CheckRemote) Run() error { + cfg := settings.Global() + list := cfg.ApprovedModules + keyName := "ApprovedModules" + if c.Global { + list = cfg.ApprovedGlobalModules + keyName = "ApprovedGlobalModules" + } + url, ok := modulefirewall.ExtractHTTPSURL(list) + if !ok { + log.Logf("firewall check-remote: %s has no HTTPS URL; allowing (local eval expected in proxy)", keyName) + return nil + } + + shim := strings.TrimSpace(c.Shim) + if shim == "" { + shim = "npm" + } + cwd := strings.TrimSpace(c.Cwd) + if cwd == "" { + cwd, _ = os.Getwd() + } + + installArgs := []string{"install"} + if c.OmitDev { + installArgs = append(installArgs, "--production") + } + + 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) + } + + var art modulefirewall.ManifestArtifact + if len(pkgs) == 0 { + // Bare install: expand from lock (default) or package.json. + var err error + art, err = modulefirewall.BuildManifestArtifact(modulefirewall.CollectOptions{ + Cwd: cwd, + Command: shim, + Args: installArgs, + SkipLockfile: cfg.FirewallSkipLockfile, + }) + if err != nil { + return err + } + pkgs = art.Modules + if len(pkgs) == 0 && art.Source != "package.json" { + return nil + } + } else { + // Explicit CLI packages win — newline module list body (no lock replace). + art = modulefirewall.ManifestArtifact{Source: "cli", Modules: pkgs} + } + + tlsOpts := modulefirewall.RemoteTLSOptions{ + TimeoutSec: cfg.FirewallHTTPTimeoutSeconds, + AllowedOrgs: cfg.TrustedFirewallSigners, + AllowedThumbprints: cfg.TrustedFirewallThumbprint, + } + reqOpts := buildFirewallRemoteOpts(url, shim, art, tlsOpts) + + logRemoteTrustEvent("firewall.remote_request", CodeRemoteAllowed, "", map[string]any{ + "url_host": mirrorauth.FirewallAudienceHost(url), + "shim": shim, + "source": art.Source, + "count": len(pkgs), + }) + + var res modulefirewall.RemoteResult + if art.Source == "package.json" || art.Source == "lock" { + res = modulefirewall.EvaluateRemoteRequest(url, pkgs, reqOpts) + } else { + res = modulefirewall.EvaluateRemoteRequest(url, pkgs, reqOpts) + } + + if modulefirewall.RemoteTrustUnavailable(res) { + code := CodeRemoteFailed + if res.Unreachable { + code = CodeRemoteUnreachable + } + msg := modulefirewall.FormatRemoteUserMessage(res) + logRemoteTrustEvent("firewall.remote_failed", code, "NVM Firewall: "+msg, map[string]any{ + "url": url, + "error": res.ErrorMsg, + "status": res.Status, + "unreachable": res.Unreachable, + "shim": shim, + }) + fmt.Fprintf(os.Stderr, "NVM Firewall: %s (NVM%d)\n", msg, code) + os.Exit(2) + } + if !res.Allowed { + code := CodeModuleBlocked + event := "firewall.remote_blocked" + if res.Status == 401 { + code = CodeRemoteUnauthorized + event = "firewall.remote_unauthorized" + } + msg := modulefirewall.FormatRemoteUserMessage(res) + logRemoteTrustEvent(event, code, "NVM Firewall: "+msg, map[string]any{ + "url": url, + "status": res.Status, + "blocks": res.Blocks, + "error": res.ErrorMsg, + "shim": shim, + }) + fmt.Fprintf(os.Stderr, "NVM Firewall: %s (NVM%d)\n", msg, code) + if len(res.Blocks) > 0 { + lines := make([]string, 0, len(res.Blocks)) + for _, b := range res.Blocks { + line := b.Name + if b.Reason != "" { + line = b.Name + "\t" + b.Reason + } + lines = append(lines, line) + } + modulefirewall.FormatHumanList(os.Stderr, lines) + } + os.Exit(1) + } + logRemoteTrustEvent("firewall.remote_allowed", CodeRemoteAllowed, "NVM Firewall: remote policy allowed", map[string]any{ + "url": url, + "status": res.Status, + "count": len(pkgs), + "shim": shim, + "source": art.Source, + }) + return nil +} + +// 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/401 / local untrusted, 2 request failed (NVM4402 / NVM4409). +type CheckRemoteTrust struct { + Shim string `flag:"shim" help:"Proxied entrypoint that triggered trust evaluation."` + 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 + } + + shim := strings.TrimSpace(c.Shim) + if shim == "" { + shim = pkgs[0].Name + } + + tlsOpts := modulefirewall.RemoteTLSOptions{ + TimeoutSec: cfg.FirewallHTTPTimeoutSeconds, + AllowedOrgs: cfg.TrustedFirewallSigners, + AllowedThumbprints: cfg.TrustedFirewallThumbprint, + } + endpoint, _ := modulefirewall.ExtractHTTPSURL(cfg.TrustedModules) + art := modulefirewall.ManifestArtifact{Source: "cli", Modules: pkgs} + reqOpts := buildFirewallRemoteOpts(endpoint, shim, art, tlsOpts) + + res := modulefirewall.EvaluateTrustedModulesRequest(pkgs, cfg.TrustedModules, reqOpts) + 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, + "shim": shim, + }) + } + return nil + } + + if res.RemoteQueried && modulefirewall.RemoteTrustUnavailable(res.Remote) { + code := CodeRemoteFailed + if res.Remote.Unreachable { + code = CodeRemoteUnreachable + } + msg := res.Message + if strings.TrimSpace(msg) == "" { + msg = modulefirewall.FormatRemoteUserMessage(res.Remote) + } + logRemoteTrustEvent("firewall.remote_trust_unavailable", code, "NVM Firewall: "+msg, map[string]any{ + "error": msg, + "status": res.Remote.Status, + "detail": res.Remote.ErrorMsg, + "unreachable": res.Remote.Unreachable, + "modules": packageNames(res.Untrusted), + "shim": shim, + }) + fmt.Fprintf(os.Stderr, "NVM Firewall: %s (NVM%d)\n", msg, code) + os.Exit(2) + return nil + } + if res.Message != "" { + fmt.Fprintf(os.Stderr, "NVM Firewall: %s\n", res.Message) + } + if res.RemoteQueried && res.Remote.Status == 401 { + msg := modulefirewall.FormatRemoteUserMessage(res.Remote) + logRemoteTrustEvent("firewall.remote_unauthorized", CodeRemoteUnauthorized, "NVM Firewall: "+msg, map[string]any{ + "status": 401, + "modules": packageNames(res.Untrusted), + "error": msg, + "shim": shim, + }) + fmt.Fprintf(os.Stderr, "NVM Firewall: %s (NVM%d)\n", msg, CodeRemoteUnauthorized) + os.Exit(1) + 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, + "shim": shim, + }) + os.Exit(1) + return nil + } + lines := make([]string, 0, len(res.Untrusted)) + for _, u := range res.Untrusted { + lines = append(lines, " untrusted: "+packageName(u)) + } + modulefirewall.FormatHumanList(os.Stderr, lines) + 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."` +} + +func (p *PromptTrust) Run() error { + name := strings.TrimSpace(p.Module) + if name == "" { + return fmt.Errorf("module name required") + } + + if system.IsAppInForeground() { + return promptTrustConsole(name) + } + return promptTrustToast(name) +} + +// 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."` +} + +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 +} + +// 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 + } + 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", neturl.QueryEscape(name), token), + }, + notify.Action{ + Label: "Cancel", + URL: fmt.Sprintf("nvm://firewall?action=cancel&module=%s&token=%s", neturl.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 +} + +// HandleProtocolURI handles nvm://firewall?action=trust|cancel&module=&token= from toast buttons. +func HandleProtocolURI(raw string) error { + u, err := neturl.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) + return nil + case "cancel", "decline": + _ = os.WriteFile(rspPath, []byte("decline\n"), 0o600) + 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 + } + 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/refresh_npm_identity.go b/src/commands/firewall/refresh_npm_identity.go new file mode 100644 index 0000000..b523c4a --- /dev/null +++ b/src/commands/firewall/refresh_npm_identity.go @@ -0,0 +1,20 @@ +package firewall + +import ( + "common/modulefirewall" + "common/settings" + "os" +) + +// RefreshNpmIdentity refreshes the local npm login identity cache after +// npm/pnpm login, adduser, logout, or whoami (proxy-invoked). +// When a credential is present but username is unknown, captures via `npm whoami` +// once (network allowed only on this path — not during install JWT mint). +type RefreshNpmIdentity struct{} + +func (c *RefreshNpmIdentity) Run() error { + cfg := settings.Global() + cwd, _ := os.Getwd() + _ = modulefirewall.CaptureNpmIdentity(cwd, settings.Expand(cfg.Root), cfg.ActiveVersion) + return nil +} diff --git a/src/commands/firewall/root.go b/src/commands/firewall/root.go new file mode 100644 index 0000000..eacb74b --- /dev/null +++ b/src/commands/firewall/root.go @@ -0,0 +1,613 @@ +package firewall + +import ( + "common/acl" + "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 ( + CodeRemoteUnauthorized = 4401 // remote HTTP 401 + CodeRemoteFailed = 4402 // remote transport/TLS/unexpected HTTP/helper failure + CodeModuleBlocked = 4403 // local or remote 403 policy deny + CodeElevationRequired = 4404 + CodeInvalidRule = 4405 + CodeUntrustedModuleChanged = 4406 + CodeRemoteAllowed = 4407 + CodeModuleAllowed = 4408 + CodeRemoteUnreachable = 4409 + CodePolicyMutate = 4410 +) + +const trustedModulesCfg = "trusted_modules" +const trustedModulesReg = "TrustedModules" + +type Root struct { + Allow AllowRoot `cmd:"allow" help:"Allow versions or modules through the NVM firewall."` + Deny DenyRoot `cmd:"deny" help:"Deny versions or modules through the NVM firewall."` + Trust TrustRoot `cmd:"trust" help:"Trust global module installations."` + Distrust DistrustRoot `cmd:"distrust" help:"Distrust global module installations."` + CheckRemote CheckRemote `cmd:"check-remote" hidden:"true" help:"Internal: HTTPS ApprovedModules evaluation for proxy."` + CheckRemoteTrust CheckRemoteTrust `cmd:"check-remote-trust" hidden:"true" help:"Internal: HTTPS TrustedModules evaluation for proxy."` + RefreshNpmIdentity RefreshNpmIdentity `cmd:"refresh-npm-identity" hidden:"true" help:"Internal: refresh cached npm login identity after login/logout."` + 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."` +} + +type AllowRoot struct { + Version AllowVersion `cmd:"version" help:"Allow the installation of Node.js versions."` + Module AllowModule `cmd:"module" help:"Allow specific module installations."` +} + +type DenyRoot struct { + Version DenyVersion `cmd:"version" help:"Deny the installation of Node.js versions."` + Module DenyModule `cmd:"module" help:"Deny specific module installations."` +} + +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 AllowVersion struct { + Entries []string `arg:"" name:"entry" help:"Version rules (semver, 20.x, aliases, ALL)."` +} + +type DenyVersion struct { + Entries []string `arg:"" name:"entry" help:"Version rules to deny (stored as NOT on VersionAllowList)."` +} + +type AllowModule struct { + Entries []string `arg:"" name:"entry" help:"Module patterns (name, @org/*, name@1.*, name@>=1.0.0)."` + Global bool `name:"global" help:"Allow installation as a global module."` +} + +type DenyModule struct { + Entries []string `arg:"" name:"entry" help:"Module patterns to deny (stored as NOT )."` + Global bool `name:"global" help:"Disallow installation as a global module."` +} + +type TrustModule struct { + 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 { + 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 requireCertified() error { + if acl.Implementation() != "policy" { + return fmt.Errorf("module/version firewall allow|deny requires a Certified Build") + } + return nil +} + +func policyKey(name string) string { + root := strings.TrimSpace(preferences.MACHINE_POLICY_ROOT) + if root == "" { + root = "HKLM/SOFTWARE/Policies/Author Software/nvm" + } + return root + "/" + name +} + +func readStringList(regPath string) ([]string, error) { + value, exists, err := registry.Get(regPath) + if err != nil { + return nil, err + } + if !exists || value == nil { + return nil, nil + } + switch v := value.(type) { + case []string: + return append([]string{}, v...), nil + case string: + if strings.TrimSpace(v) == "" { + return nil, 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 = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out, nil + default: + return nil, nil + } +} + +func appendPolicyMulti(regName string, add []string, negate bool) error { + if err := requireMachine(); err != nil { + return err + } + path := policyKey(regName) + list, err := readStringList(path) + if err != nil { + return err + } + 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 + } + list = append(list, e) + } + if err := registry.Put(list, path); err != nil { + payload := map[string]any{ + "key": regName, + "error": err.Error(), + } + enrichSIEM(payload) + log.ErrorStructured("firewall.policy_mutate_failed", payload, CodePolicyMutate) + return err + } + log.Logf("firewall: updated %s (+%d entries)", regName, len(add)) + payload := map[string]any{ + "key": regName, + "added": add, + "negate": negate, + "action": "append", + } + enrichSIEM(payload) + log.LogStructured("firewall.policy_mutated", payload, CodePolicyMutate) + return nil +} + +// appendSettingsList is used by certified allow/deny module paths (always elevated + PutMachine). +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 { + payload := map[string]any{ + "key": cfgKey, + "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) + } + list = append(list, e) + } + if err := settings.PutMachine(cfgKey, strings.Join(list, ",")); err != nil { + payload := map[string]any{ + "key": cfgKey, + "error": err.Error(), + } + enrichSIEM(payload) + log.ErrorStructured("firewall.policy_mutate_failed", payload, CodePolicyMutate) + return err + } + log.Logf("firewall: updated %s (+%d entries)", regLabel, len(add)) + payload := map[string]any{ + "key": cfgKey, + "added": add, + "negate": negate, + "action": "append", + } + enrichSIEM(payload) + log.LogStructured("firewall.policy_mutated", payload, CodePolicyMutate) + fmt.Fprintf(os.Stdout, "updated %s (+%d):\n", regLabel, len(add)) + modulefirewall.FormatHumanList(os.Stdout, add) + return nil +} + +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:]) + } + 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: + return append([]string{}, v...) + case string: + 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 + } + 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 err := modulefirewall.ValidateRuleEntry(e); err != nil { + 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) + } + 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 + } + 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) + } + } + 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) + } + kept = dedupeList(kept) + if err := writeTrustedList(kept, machine); err != nil { + return err + } + 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 (a *AllowVersion) Run() error { + if err := requireCertified(); err != nil { + return err + } + return appendPolicyMulti("VersionAllowList", a.Entries, false) +} + +func (d *DenyVersion) Run() error { + if err := requireCertified(); err != nil { + return err + } + return appendPolicyMulti("VersionAllowList", d.Entries, true) +} + +func (a *AllowModule) Run() error { + if err := requireCertified(); err != nil { + return err + } + key, label := "approved_modules", "ApprovedModules" + if a.Global { + key, label = "approved_global_modules", "ApprovedGlobalModules" + } + return appendSettingsList(key, label, a.Entries, false) +} + +func (d *DenyModule) Run() error { + if err := requireCertified(); err != nil { + return err + } + key, label := "approved_modules", "ApprovedModules" + if d.Global { + key, label = "approved_global_modules", "ApprovedGlobalModules" + } + return appendSettingsList(key, label, d.Entries, true) +} + +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 (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..75abeb2 --- /dev/null +++ b/src/commands/firewall/trust_test.go @@ -0,0 +1,48 @@ +package firewall + +import ( + "reflect" + "testing" +) + +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 TestStripNegation(t *testing.T) { + if got := stripNegation("NOT eslint"); got != "eslint" { + t.Fatalf("got %q", got) + } + if got := stripNegation("!eslint"); got != "eslint" { + t.Fatalf("got %q", got) + } + if got := stripNegation("eslint"); got != "eslint" { + t.Fatalf("got %q", got) + } +} diff --git a/src/commands/firewall/validate_test.go b/src/commands/firewall/validate_test.go new file mode 100644 index 0000000..7f784fd --- /dev/null +++ b/src/commands/firewall/validate_test.go @@ -0,0 +1,71 @@ +package firewall + +import ( + "common/modulefirewall" + "strings" + "testing" +) + +func TestCodeConstants(t *testing.T) { + if CodeRemoteUnauthorized != 4401 { + t.Fatalf("CodeRemoteUnauthorized=%d, want 4401", CodeRemoteUnauthorized) + } + if CodeRemoteFailed != 4402 { + t.Fatalf("CodeRemoteFailed=%d, want 4402", CodeRemoteFailed) + } + if CodeModuleBlocked != 4403 { + t.Fatalf("CodeModuleBlocked=%d, want 4403", CodeModuleBlocked) + } + if CodeInvalidRule != 4405 { + t.Fatalf("CodeInvalidRule=%d, want 4405", CodeInvalidRule) + } + if CodeRemoteAllowed != 4407 { + t.Fatalf("CodeRemoteAllowed=%d, want 4407", CodeRemoteAllowed) + } + if CodeModuleAllowed != 4408 { + t.Fatalf("CodeModuleAllowed=%d, want 4408", CodeModuleAllowed) + } + if CodeRemoteUnreachable != 4409 { + t.Fatalf("CodeRemoteUnreachable=%d, want 4409", CodeRemoteUnreachable) + } + if CodePolicyMutate != 4410 { + t.Fatalf("CodePolicyMutate=%d, want 4410", CodePolicyMutate) + } +} + +func TestValidateRuleEntry_CLIPaths(t *testing.T) { + tests := []struct { + name string + entry string + wantErr bool + errSub string + }{ + {"allow ALL", "ALL", false, ""}, + {"deny NOT ALL", "NOT ALL", false, ""}, + {"module", "eslint", false, ""}, + {"org", "@org/*", false, ""}, + {"version pin", "eslint@1.*", false, ""}, + {"https", "https://policy.example/fw", false, ""}, + {"http reject", "http://evil.example/fw", true, "HTTPS"}, + {"empty", "", true, ""}, + {"bang alone", "!", true, ""}, + {"bad org", "org/*", true, "invalid org wildcard"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := modulefirewall.ValidateRuleEntry(tt.entry) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + if tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) { + t.Fatalf("err=%v, want %q", err, tt.errSub) + } + return + } + if err != nil { + t.Fatalf("unexpected err=%v", err) + } + }) + } +} diff --git a/src/commands/root.go b/src/commands/root.go index 0cb7390..931464c 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,11 +27,12 @@ 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" aliases:"fw" help:"Manage NVM version/module/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"` Debug Doctor `cmd:"debug" hidden:"true"` - Reshim Reshim `cmd:"reshim" hidden:"true"` + Reshim Reshim `cmd:"reshim" help:"Rebuild shims / re-trust package-manager entrypoints."` Upgrade Upgrade `cmd:"upgrade" help:"Upgrade ${app}." hidden:"true"` Subscribe Subscribe `cmd:"subscribe" help:"Subscribe to ${app} updates." hidden:"true"` License license.Root `cmd:"license" help:"Set/clear ${app} license." hidden:"true"` 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") } 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 f34a247..427a169 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,16 +55,18 @@ 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 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 - github.com/Masterminds/semver/v3 v3.4.0 + github.com/Masterminds/semver/v3 v3.5.0 github.com/alecthomas/kong v1.14.0 github.com/bodgit/sevenzip v1.6.1 github.com/iancoleman/orderedmap v0.3.0 @@ -73,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 @@ -96,3 +99,4 @@ require ( golang.org/x/image v0.20.0 // indirect golang.org/x/text v0.35.0 // indirect ) + diff --git a/src/go.sum b/src/go.sum index b4ef2d5..cb5a21b 100644 --- a/src/go.sum +++ b/src/go.sum @@ -17,8 +17,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 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/legacy/envars.go b/src/legacy/envars.go index f104e3f..1c4da62 100644 --- a/src/legacy/envars.go +++ b/src/legacy/envars.go @@ -2,16 +2,26 @@ package legacy import ( "common/registry" + "os" "strings" + "unsafe" + "golang.org/x/sys/windows" winreg "golang.org/x/sys/windows/registry" ) const systemEnvKeyPath = `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment` -// RemoveLegacySystemEnvVars removes the NVM v1 SYSTEM-level environment variables +const ( + authorNvmSuffix = `\author software\nvm` + authorNvmNodejsSuffix = `\author software\nvm\.nodejs` +) + +// RemoveSystemEnvVars removes the NVM v1 SYSTEM-level environment variables // (NVM_HOME, NVM_SYMLINK) and any references to them in the SYSTEM PATH. -// Must be called from an elevated process such as admin remediation tooling. +// It also drops community program-root PATH segments (LocalAppData\Author Software\nvm) +// while keeping certified Program Files\Author Software\nvm and the .nodejs shim path. +// Must be called from an elevated process such as MSI custom action or admin remediation. func RemoveSystemEnvVars() error { // Read the variable values before deleting so we can also strip their literal // paths from PATH (in case PATH contains expanded paths rather than %VAR% refs). @@ -23,19 +33,19 @@ func RemoveSystemEnvVars() error { systemEnvKeyPath+`\NVM_SYMLINK`, ) - // Build the set of segments to strip. + // Build the set of segments to strip from var refs / expanded values. remove := map[string]bool{ strings.ToLower("%NVM_HOME%"): true, strings.ToLower("%NVM_SYMLINK%"): true, } if v, ok := nvmHome.(string); ok && v != "" { - remove[strings.ToLower(strings.TrimRight(v, `\/`))] = true + remove[normalizePathSeg(v)] = true } if v, ok := nvmSymlink.(string); ok && v != "" { - remove[strings.ToLower(strings.TrimRight(v, `\/`))] = true + remove[normalizePathSeg(v)] = true } - // Read and rewrite the SYSTEM PATH. Path is REG_EXPAND_SZ, so common/registry + // Read and rewrite the SYSTEM PATH. Path is REG_EXPAND_SZ, so common/registry // cannot write it back correctly — use winreg directly for the SET_VALUE call. k, err := winreg.OpenKey(winreg.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, @@ -50,19 +60,116 @@ func RemoveSystemEnvVars() error { return nil // PATH unreadable; var deletion already succeeded } + cleaned := FilterSystemPath(sysPath, remove) + if cleaned == sysPath { + return nil + } + + if err := k.SetExpandStringValue("Path", cleaned); err != nil { + return err + } + broadcastEnvironmentChange() + return nil +} + +// FilterSystemPath removes legacy NVM segments and community program-root entries +// from a PATH string. keepMap entries force-remove specific normalized segments +// (e.g. old NVM_HOME values). Pure function for unit tests. +func FilterSystemPath(sysPath string, forceRemove map[string]bool) string { + if forceRemove == nil { + forceRemove = map[string]bool{} + } segments := strings.Split(sysPath, ";") - kept := segments[:0] + kept := make([]string, 0, len(segments)) for _, seg := range segments { - norm := strings.ToLower(strings.TrimRight(strings.TrimSpace(seg), `\/`)) - if !remove[norm] { - kept = append(kept, seg) + trimmed := strings.TrimSpace(seg) + if trimmed == "" { + continue } + if shouldDropSystemPathSegment(trimmed, forceRemove) { + continue + } + kept = append(kept, seg) } + return strings.Join(kept, ";") +} - cleaned := strings.Join(kept, ";") - if cleaned == sysPath { - return nil +func shouldDropSystemPathSegment(seg string, forceRemove map[string]bool) bool { + norm := normalizePathSeg(seg) + expanded := normalizePathSeg(os.ExpandEnv(seg)) + + // Always keep the per-user .nodejs shim path (certified MSI sets this on Machine PATH), + // even when legacy NVM_SYMLINK pointed at the same location. + if isAuthorNvmNodejsPath(norm) || isAuthorNvmNodejsPath(expanded) { + return false + } + + // Always keep Program Files certified install root. + if isProgramFilesAuthorNvmPath(norm) || isProgramFilesAuthorNvmPath(expanded) { + return false + } + + if forceRemove[norm] || forceRemove[expanded] { + return true } - return k.SetExpandStringValue("Path", cleaned) + // Drop community / per-user program root: ...\Author Software\nvm (not .nodejs). + if isAuthorNvmProgramRoot(norm) || isAuthorNvmProgramRoot(expanded) { + return true + } + + return false +} + +func isAuthorNvmNodejsPath(norm string) bool { + return strings.HasSuffix(norm, authorNvmNodejsSuffix) || + strings.Contains(norm, authorNvmNodejsSuffix+`\`) +} + +func isProgramFilesAuthorNvmPath(norm string) bool { + if !strings.Contains(norm, authorNvmSuffix) { + return false + } + return strings.Contains(norm, `program files`) || + strings.Contains(norm, `%programfiles%`) || + strings.Contains(norm, `%programfiles(x86)%`) +} + +func isAuthorNvmProgramRoot(norm string) bool { + if isAuthorNvmNodejsPath(norm) { + return false + } + return strings.HasSuffix(norm, authorNvmSuffix) +} + +func normalizePathSeg(value string) string { + normalized := strings.TrimSpace(strings.ToLower(value)) + normalized = strings.ReplaceAll(normalized, "/", `\`) + for strings.Contains(normalized, `\\`) { + normalized = strings.ReplaceAll(normalized, `\\`, `\`) + } + normalized = strings.Trim(normalized, `"`) + return strings.TrimRight(normalized, `\`) +} + +func broadcastEnvironmentChange() { + BroadcastEnvironmentChange() +} + +// BroadcastEnvironmentChange notifies running processes that environment variables changed. +func BroadcastEnvironmentChange() { + var hwndBroadcast uintptr = 0xffff // HWND_BROADCAST + const wmSettingChange = 0x001A + env, _ := windows.UTF16PtrFromString("Environment") + user32 := windows.NewLazySystemDLL("user32.dll") + proc := user32.NewProc("SendMessageTimeoutW") + _, _, _ = proc.Call( + hwndBroadcast, + uintptr(wmSettingChange), + 0, + uintptr(unsafe.Pointer(env)), + 0x0002, // SMTO_ABORTIFHUNG + 5000, + 0, + ) } diff --git a/src/legacy/envars_test.go b/src/legacy/envars_test.go new file mode 100644 index 0000000..3cfd07e --- /dev/null +++ b/src/legacy/envars_test.go @@ -0,0 +1,63 @@ +package legacy + +import ( + "strings" + "testing" +) + +func TestFilterSystemPath_KeepsProgramFilesAndNodejs(t *testing.T) { + in := strings.Join([]string{ + `C:\Windows\system32`, + `C:\Program Files\Author Software\nvm`, + `%LOCALAPPDATA%\Author Software\nvm\.nodejs`, + `C:\Users\corey\AppData\Local\Author Software\nvm`, + `%NVM_HOME%`, + }, ";") + + force := map[string]bool{ + strings.ToLower("%NVM_HOME%"): true, + `c:\old\nvm`: true, + } + got := FilterSystemPath(in, force) + + wantKeep := []string{ + `C:\Windows\system32`, + `C:\Program Files\Author Software\nvm`, + `%LOCALAPPDATA%\Author Software\nvm\.nodejs`, + } + wantDrop := []string{ + `C:\Users\corey\AppData\Local\Author Software\nvm`, + `%NVM_HOME%`, + } + for _, s := range wantKeep { + if !strings.Contains(got, s) { + t.Fatalf("expected keep %q in %q", s, got) + } + } + for _, s := range wantDrop { + if strings.Contains(got, s) { + t.Fatalf("expected drop %q from %q", s, got) + } + } +} + +func TestFilterSystemPath_ForceRemoveExpandedHome(t *testing.T) { + in := `C:\Windows;C:\legacy\nvm-home;C:\Program Files\Author Software\nvm` + force := map[string]bool{normalizePathSeg(`C:\legacy\nvm-home`): true} + got := FilterSystemPath(in, force) + if strings.Contains(strings.ToLower(got), `legacy\nvm-home`) { + t.Fatalf("force remove failed: %q", got) + } + if !strings.Contains(got, `Program Files\Author Software\nvm`) { + t.Fatalf("lost program files: %q", got) + } +} + +func TestIsAuthorNvmProgramRoot(t *testing.T) { + if !isAuthorNvmProgramRoot(normalizePathSeg(`C:\Users\a\AppData\Local\Author Software\nvm`)) { + t.Fatal("expected community root drop") + } + if isAuthorNvmProgramRoot(normalizePathSeg(`C:\Users\a\AppData\Local\Author Software\nvm\.nodejs`)) { + t.Fatal(".nodejs must not be treated as program root") + } +} 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..d6bc2c9 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -34,7 +34,8 @@ "common/notify.DisplayName": "manifest.appLabel", "common/http.appname": "manifest.appLabel", "common/http.version": "manifest.version", - "common/http.edition": "community", + "common/http.edition": "certified", + "common/license.buildChannel": "certified", "common/http.httpCacheRoot": "metadata", "common/eventlog.providerName": "manifest.appLabel", "common/eventlog.providerDisplayName": "manifest.appLabel",