diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8f4925..3e96aa1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -126,8 +126,8 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version-file: ${{ matrix.app }}/go.mod - cache-dependency-path: ${{ matrix.app }}/go.sum + go-version-file: src/go.mod + cache-dependency-path: src/go.sum - name: Cache qgo binary uses: actions/cache@v5 @@ -151,13 +151,13 @@ jobs: - name: Run qgo test shell: pwsh - working-directory: ${{ matrix.app }} + working-directory: src run: | qgo test - name: Run go test (cli/src) shell: pwsh - working-directory: ${{ matrix.app }}/src + working-directory: src run: | go test -p 1 -timeout 3m ./... diff --git a/src/bootstrap/migration.go b/src/bootstrap/migration.go index 5432280..d3f3502 100644 --- a/src/bootstrap/migration.go +++ b/src/bootstrap/migration.go @@ -27,6 +27,13 @@ var ( deleteScheduledTask = defaultScheduledTaskDelete ) +// RetireCommunityUserPayload removes the per-user community app payload and its +// HKCU uninstall/App Paths/shell registrations. Node installs and runtime dirs stay. +// No-op when this process is itself the LocalAppData community binary. +func RetireCommunityUserPayload(dataRoot string) error { + return cleanupLegacyUserPayload(dataRoot) +} + func cleanupLegacyUserPayload(dataRoot string) error { programRoot, err := ProgramRoot() if err != nil { diff --git a/src/cmd/main.go b/src/cmd/main.go index f872673..b8f8542 100644 --- a/src/cmd/main.go +++ b/src/cmd/main.go @@ -93,6 +93,24 @@ func main() { os.Exit(1) } return + case "--retire-community-payload": + // Impersonated MSI CA: drop community nvm.exe/utils/.icons and the HKCU + // uninstall entry during install, not on first nvm launch. + settings.Load() + dataRoot, err := bootstrap.DataRoot() + if err != nil || dataRoot == "" { + local := os.Getenv("LOCALAPPDATA") + if local == "" { + fmt.Fprint(os.Stderr, "LOCALAPPDATA is empty; cannot retire community payload\n") + os.Exit(1) + } + dataRoot = filepath.Join(local, "Author Software", "nvm") + } + if err := bootstrap.RetireCommunityUserPayload(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. diff --git a/src/commands/firewall/helpers.go b/src/commands/firewall/helpers.go index 3b6fcd8..97f4257 100644 --- a/src/commands/firewall/helpers.go +++ b/src/commands/firewall/helpers.go @@ -10,6 +10,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "net/http" neturl "net/url" "nvm/log" "os" @@ -57,6 +58,29 @@ func logRemoteTrustEvent(event string, code int, plain string, payload map[strin } } +// remoteFailureText is the sentence after "NVM Firewall: ". +// A live authority omits the URL. An unreachable host includes it. +func remoteFailureText(res modulefirewall.RemoteResult, endpoint, source string) (text string, code int, exitCode int) { + source = strings.TrimSpace(source) + if source == "" { + source = "your settings" + } + endpoint = strings.TrimSpace(endpoint) + if res.Unreachable { + return fmt.Sprintf("cannot reach %s (enforced by %s).", endpoint, source), CodeRemoteUnreachable, 2 + } + switch res.Status { + case http.StatusUnauthorized: + return fmt.Sprintf("blocked by %s.", source), CodeRemoteUnauthorized, 1 + case http.StatusForbidden: + return fmt.Sprintf("blocked by %s.", source), CodeModuleBlocked, 1 + } + if res.Status != 0 { + return fmt.Sprintf("remote authority returned HTTP %d (enforced by %s).", res.Status, source), CodeRemoteFailed, 2 + } + return fmt.Sprintf("cannot reach %s (enforced by %s).", endpoint, source), CodeRemoteFailed, 2 +} + func firewallUA() string { build := "certified" if license.IsCommunityBuild() { @@ -190,39 +214,31 @@ func (c *CheckRemote) Run() error { res = modulefirewall.EvaluateRemoteRequest(url, pkgs, reqOpts) } - if modulefirewall.RemoteTrustUnavailable(res) { - code := CodeRemoteFailed - if res.Unreachable { - code = CodeRemoteUnreachable + if !res.Allowed { + cfgName := "approved_modules" + if c.Global { + cfgName = "approved_global_modules" + } + source := settings.EnforcementSource(cfgName) + text, code, exitCode := remoteFailureText(res, url, source) + event := "firewall.remote_failed" + if exitCode == 1 { + event = "firewall.remote_blocked" + if res.Status == http.StatusUnauthorized { + event = "firewall.remote_unauthorized" + } } - msg := modulefirewall.FormatRemoteUserMessage(res) - logRemoteTrustEvent("firewall.remote_failed", code, "NVM Firewall: "+msg, map[string]any{ + logRemoteTrustEvent(event, code, "NVM Firewall: "+text, map[string]any{ "url": url, "error": res.ErrorMsg, "status": res.Status, "unreachable": res.Unreachable, + "source": source, "shim": shim, + "blocks": res.Blocks, }) - 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 { + fmt.Fprintf(os.Stderr, "NVM Firewall: %s (NVM%d)\n", text, code) + if exitCode == 1 && len(res.Blocks) > 0 { lines := make([]string, 0, len(res.Blocks)) for _, b := range res.Blocks { line := b.Name @@ -233,7 +249,7 @@ func (c *CheckRemote) Run() error { } modulefirewall.FormatHumanList(os.Stderr, lines) } - os.Exit(1) + os.Exit(exitCode) } logRemoteTrustEvent("firewall.remote_allowed", CodeRemoteAllowed, "NVM Firewall: remote policy allowed", map[string]any{ "url": url, @@ -298,52 +314,32 @@ func (c *CheckRemoteTrust) Run() error { 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) + if res.RemoteQueried && !res.Remote.Allowed { + source := settings.EnforcementSource("trusted_modules") + text, code, exitCode := remoteFailureText(res.Remote, endpoint, source) + event := "firewall.remote_trust_unavailable" + if exitCode == 1 { + event = "firewall.remote_blocked" + if res.Remote.Status == http.StatusUnauthorized { + event = "firewall.remote_unauthorized" + } } - logRemoteTrustEvent("firewall.remote_trust_unavailable", code, "NVM Firewall: "+msg, map[string]any{ - "error": msg, + logRemoteTrustEvent(event, code, "NVM Firewall: "+text, map[string]any{ + "error": text, "status": res.Remote.Status, "detail": res.Remote.ErrorMsg, "unreachable": res.Remote.Unreachable, + "source": source, "modules": packageNames(res.Untrusted), "shim": shim, }) - fmt.Fprintf(os.Stderr, "NVM Firewall: %s (NVM%d)\n", msg, code) - os.Exit(2) + fmt.Fprintf(os.Stderr, "NVM Firewall: %s (NVM%d)\n", text, code) + os.Exit(exitCode) 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)) diff --git a/src/commands/firewall/trust_test.go b/src/commands/firewall/trust_test.go index 75abeb2..697d64f 100644 --- a/src/commands/firewall/trust_test.go +++ b/src/commands/firewall/trust_test.go @@ -1,7 +1,9 @@ package firewall import ( + "common/modulefirewall" "reflect" + "strings" "testing" ) @@ -46,3 +48,24 @@ func TestStripNegation(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestRemoteFailureText(t *testing.T) { + url := "https://127.0.0.1:8443/module/trust" + text, code, exitCode := remoteFailureText(modulefirewall.RemoteResult{Unreachable: true}, url, "machine policy") + if text != "cannot reach https://127.0.0.1:8443/module/trust (enforced by machine policy)." || code != CodeRemoteUnreachable || exitCode != 2 { + t.Fatalf("unreachable text=%q code=%d exit=%d", text, code, exitCode) + } + + text, code, exitCode = remoteFailureText(modulefirewall.RemoteResult{Status: 403}, url, "machine settings") + if text != "blocked by machine settings." || code != CodeModuleBlocked || exitCode != 1 { + t.Fatalf("blocked text=%q code=%d exit=%d", text, code, exitCode) + } + + text, code, exitCode = remoteFailureText(modulefirewall.RemoteResult{Status: 401}, url, "your settings") + if text != "blocked by your settings." || code != CodeRemoteUnauthorized || exitCode != 1 { + t.Fatalf("unauthorized text=%q code=%d exit=%d", text, code, exitCode) + } + if strings.Contains(text, "https://") || strings.Contains(text, "127.0.0.1") { + t.Fatalf("working authority leaked url: %q", text) + } +}