Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ./...

Expand Down
7 changes: 7 additions & 0 deletions src/bootstrap/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions src/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
118 changes: 57 additions & 61 deletions src/commands/firewall/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
neturl "net/url"
"nvm/log"
"os"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
23 changes: 23 additions & 0 deletions src/commands/firewall/trust_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package firewall

import (
"common/modulefirewall"
"reflect"
"strings"
"testing"
)

Expand Down Expand Up @@ -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)
}
}
Loading