Skip to content

Commit ea55c5b

Browse files
committed
feat(package-firewall): add uninstall command and fill ecosystem docs
Add `vulnetix package-firewall uninstall` to reverse configure across all supported ecosystems. Targeted by default (name ecosystems), with `--all`, `--except <csv>`, and `--purge` (= all + credential removal) for bulk. The shared netrc credential is kept unless `--remove-credentials`/`--purge` is given, and a warning fires if it is dropped while other ecosystems remain configured. Reversal is auth-free and mirrors each write mode: managed-block strip (delete if empty), structured-file delete (host-gated), merge restore from `.vulnetix.bak` (or key strip), and Go shell/env/project reversal. Docs: new uninstall page, cli-reference flag table, troubleshooting Start over update. Also fill the pre-existing ecosystem gaps — new homebrew, aur, and go-dev pages; correct the ecosystem count (21 -> 23) and tier/enforcement tables; remove the dead shadowed package-firewall.md that never rendered.
1 parent af2ada2 commit ea55c5b

11 files changed

Lines changed: 1141 additions & 125 deletions

File tree

cmd/package_firewall.go

Lines changed: 549 additions & 7 deletions
Large diffs are not rendered by default.

cmd/package_firewall_test.go

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,305 @@ func TestUpsertPackageFirewallConfigFile(t *testing.T) {
114114
t.Fatalf("managed config was not replaced:\n%s", got)
115115
}
116116
}
117+
118+
func TestRemoveNetrcMachine(t *testing.T) {
119+
existing := "machine example.com\nlogin keep\npassword keep\n\nmachine packages.vulnetix.com\nlogin org\npassword key\n"
120+
got := removeNetrcMachine(existing, "packages.vulnetix.com")
121+
if strings.Contains(got, "packages.vulnetix.com") || strings.Contains(got, "org") {
122+
t.Fatalf("firewall entry not removed:\n%s", got)
123+
}
124+
if !strings.Contains(got, "machine example.com\nlogin keep\npassword keep") {
125+
t.Fatalf("unrelated entry not preserved:\n%s", got)
126+
}
127+
128+
// Round-trip with upsert: adding then removing yields the original neighbours.
129+
if only := removeNetrcMachine("machine packages.vulnetix.com\nlogin org\npassword key\n", "packages.vulnetix.com"); strings.TrimSpace(only) != "" {
130+
t.Fatalf("removing the sole entry should empty the file, got:\n%q", only)
131+
}
132+
}
133+
134+
func TestRemoveManagedBlock(t *testing.T) {
135+
existing := "before\n\n# Vulnetix Package Firewall\nold\n# End Vulnetix Package Firewall\n\nafter\n"
136+
got, changed := removeManagedBlock(existing)
137+
if !changed {
138+
t.Fatal("expected block to be found")
139+
}
140+
if strings.Contains(got, "old") || strings.Contains(got, vulnetixBlockStart) {
141+
t.Fatalf("managed block remained:\n%s", got)
142+
}
143+
if !strings.Contains(got, "before") || !strings.Contains(got, "after") {
144+
t.Fatalf("surrounding content lost:\n%s", got)
145+
}
146+
147+
onlyBlock := "# Vulnetix Package Firewall\nx\n# End Vulnetix Package Firewall\n"
148+
got, changed = removeManagedBlock(onlyBlock)
149+
if !changed || strings.TrimSpace(got) != "" {
150+
t.Fatalf("block-only file should become empty, got %q changed=%v", got, changed)
151+
}
152+
153+
if _, changed := removeManagedBlock("no block here\n"); changed {
154+
t.Fatal("expected no change when block absent")
155+
}
156+
}
157+
158+
func TestRemoveGoEnvValues(t *testing.T) {
159+
existing := "FOO=bar\nexport GOPROXY=\"https://packages.vulnetix.com\"\nexport GOAUTH=\"netrc\"\n"
160+
got, changed := removeGoEnvValues(existing)
161+
if !changed {
162+
t.Fatal("expected change")
163+
}
164+
if strings.Contains(got, "GOPROXY") || strings.Contains(got, "GOAUTH") {
165+
t.Fatalf("Go env values remained:\n%s", got)
166+
}
167+
if !strings.Contains(got, "FOO=bar") {
168+
t.Fatalf("unrelated env lost:\n%s", got)
169+
}
170+
171+
got, changed = removeGoEnvValues("GOPROXY=x\nGOAUTH=netrc\n")
172+
if !changed || got != "" {
173+
t.Fatalf("file with only Go env should empty, got %q", got)
174+
}
175+
}
176+
177+
func TestRemovePackageFirewallConfigFile_ManagedBlock(t *testing.T) {
178+
dir := t.TempDir()
179+
180+
// File that held ONLY our block -> deleted.
181+
solo := filepath.Join(dir, ".npmrc")
182+
writeFile(t, solo, "# Vulnetix Package Firewall\nregistry=x\n# End Vulnetix Package Firewall\n")
183+
res, err := removePackageFirewallConfigFile(pfw.ConfigFile{Path: solo}, "packages.vulnetix.com", false)
184+
if err != nil {
185+
t.Fatal(err)
186+
}
187+
if res != "deleted file" {
188+
t.Fatalf("result = %q", res)
189+
}
190+
if _, err := os.Stat(solo); !os.IsNotExist(err) {
191+
t.Fatal("expected file deleted")
192+
}
193+
194+
// File with user content around our block -> block removed, file kept.
195+
mixed := filepath.Join(dir, ".gemrc")
196+
writeFile(t, mixed, "keep=1\n\n# Vulnetix Package Firewall\n:sources:\n# End Vulnetix Package Firewall\n")
197+
res, err = removePackageFirewallConfigFile(pfw.ConfigFile{Path: mixed}, "packages.vulnetix.com", false)
198+
if err != nil {
199+
t.Fatal(err)
200+
}
201+
if res != "removed managed block" {
202+
t.Fatalf("result = %q", res)
203+
}
204+
if got := readFile(t, mixed); !strings.Contains(got, "keep=1") || strings.Contains(got, vulnetixBlockStart) {
205+
t.Fatalf("mixed file wrong after removal:\n%s", got)
206+
}
207+
208+
// Not configured.
209+
none := filepath.Join(dir, ".other")
210+
writeFile(t, none, "unrelated\n")
211+
if res, _ := removePackageFirewallConfigFile(pfw.ConfigFile{Path: none}, "packages.vulnetix.com", false); res != "not configured" {
212+
t.Fatalf("result = %q", res)
213+
}
214+
215+
// Dry-run must not modify.
216+
dry := filepath.Join(dir, ".dry")
217+
before := "# Vulnetix Package Firewall\nx\n# End Vulnetix Package Firewall\n"
218+
writeFile(t, dry, before)
219+
if res, _ := removePackageFirewallConfigFile(pfw.ConfigFile{Path: dry}, "packages.vulnetix.com", true); res != "would delete file" {
220+
t.Fatalf("dry result = %q", res)
221+
}
222+
if readFile(t, dry) != before {
223+
t.Fatal("dry-run modified the file")
224+
}
225+
}
226+
227+
func TestRemovePackageFirewallConfigFile_Structured(t *testing.T) {
228+
dir := t.TempDir()
229+
230+
ours := filepath.Join(dir, "settings.xml")
231+
writeFile(t, ours, "<settings><url>https://packages.vulnetix.com/maven/</url></settings>\n")
232+
res, err := removePackageFirewallConfigFile(pfw.ConfigFile{Path: ours, Structured: true}, "packages.vulnetix.com", false)
233+
if err != nil {
234+
t.Fatal(err)
235+
}
236+
if res != "deleted file" {
237+
t.Fatalf("result = %q", res)
238+
}
239+
if _, err := os.Stat(ours); !os.IsNotExist(err) {
240+
t.Fatal("expected structured file deleted")
241+
}
242+
243+
foreign := filepath.Join(dir, "other.xml")
244+
writeFile(t, foreign, "<settings><url>https://repo.maven.apache.org</url></settings>\n")
245+
res, _ = removePackageFirewallConfigFile(pfw.ConfigFile{Path: foreign, Structured: true}, "packages.vulnetix.com", false)
246+
if res != "not firewall-configured, skipped" {
247+
t.Fatalf("result = %q", res)
248+
}
249+
if _, err := os.Stat(foreign); err != nil {
250+
t.Fatal("foreign structured file should be preserved")
251+
}
252+
}
253+
254+
func TestRemovePackageFirewallConfigFile_MergeRestoresBackup(t *testing.T) {
255+
dir := t.TempDir()
256+
path := filepath.Join(dir, "paru.conf")
257+
writeFile(t, path, "[options]\nAurUrl = https://packages.vulnetix.com/aur\nColor\n")
258+
writeFile(t, path+".vulnetix.bak", "[options]\nColor\n")
259+
260+
res, err := removePackageFirewallConfigFile(pfw.ConfigFile{Path: path, Merge: func(s string) (string, error) { return s, nil }}, "packages.vulnetix.com", false)
261+
if err != nil {
262+
t.Fatal(err)
263+
}
264+
if res != "restored from backup" {
265+
t.Fatalf("result = %q", res)
266+
}
267+
if got := readFile(t, path); strings.Contains(got, "vulnetix.com") || !strings.Contains(got, "Color") {
268+
t.Fatalf("backup not restored:\n%s", got)
269+
}
270+
if _, err := os.Stat(path + ".vulnetix.bak"); !os.IsNotExist(err) {
271+
t.Fatal("backup should be removed after restore")
272+
}
273+
}
274+
275+
func TestStripMergeKeys(t *testing.T) {
276+
// paru.conf INI
277+
paru, changed := stripMergeKeys("/x/paru.conf", "[options]\nColor\nAurUrl = https://packages.vulnetix.com/aur\nAurRpcUrl = https://packages.vulnetix.com/aur/rpc\n")
278+
if !changed || strings.Contains(paru, "AurUrl") || !strings.Contains(paru, "Color") {
279+
t.Fatalf("paru strip wrong (changed=%v):\n%s", changed, paru)
280+
}
281+
282+
// yay config.json
283+
yay, changed := stripMergeKeys("/x/config.json", `{"aururl":"https://packages.vulnetix.com/aur","aurrpcurl":"x","editor":"vim"}`)
284+
if !changed || strings.Contains(yay, "aururl") || !strings.Contains(yay, "editor") {
285+
t.Fatalf("yay strip wrong (changed=%v):\n%s", changed, yay)
286+
}
287+
}
288+
289+
func TestRemoveNetrcCredential(t *testing.T) {
290+
dir := t.TempDir()
291+
path := filepath.Join(dir, ".netrc")
292+
writeFile(t, path, "machine example.com\nlogin a\npassword b\n\nmachine packages.vulnetix.com\nlogin org\npassword key\n")
293+
294+
if res, _ := removeNetrcCredential(path, "packages.vulnetix.com", true); res != "would remove netrc credential" {
295+
t.Fatalf("dry result = %q", res)
296+
}
297+
if !strings.Contains(readFile(t, path), "packages.vulnetix.com") {
298+
t.Fatal("dry-run modified netrc")
299+
}
300+
301+
res, err := removeNetrcCredential(path, "packages.vulnetix.com", false)
302+
if err != nil {
303+
t.Fatal(err)
304+
}
305+
if res != "removed netrc credential" {
306+
t.Fatalf("result = %q", res)
307+
}
308+
got := readFile(t, path)
309+
if strings.Contains(got, "packages.vulnetix.com") || !strings.Contains(got, "example.com") {
310+
t.Fatalf("netrc wrong after removal:\n%s", got)
311+
}
312+
313+
// Sole entry -> file deleted.
314+
solo := filepath.Join(dir, "solo")
315+
writeFile(t, solo, "machine packages.vulnetix.com\nlogin org\npassword key\n")
316+
res, _ = removeNetrcCredential(solo, "packages.vulnetix.com", false)
317+
if res != "removed netrc credential (file deleted)" {
318+
t.Fatalf("result = %q", res)
319+
}
320+
if _, err := os.Stat(solo); !os.IsNotExist(err) {
321+
t.Fatal("expected netrc deleted")
322+
}
323+
324+
// Absent.
325+
if res, _ := removeNetrcCredential(filepath.Join(dir, "nope"), "packages.vulnetix.com", false); res != "not configured" {
326+
t.Fatalf("result = %q", res)
327+
}
328+
}
329+
330+
func TestResolveUninstallTargets(t *testing.T) {
331+
if _, err := resolveUninstallTargets(nil, nil, false); err == nil {
332+
t.Fatal("expected error with no selector")
333+
}
334+
if _, err := resolveUninstallTargets([]string{"npm"}, nil, true); err == nil {
335+
t.Fatal("expected error with multiple selectors")
336+
}
337+
338+
all, err := resolveUninstallTargets(nil, nil, true)
339+
if err != nil {
340+
t.Fatal(err)
341+
}
342+
if len(all) != len(pfw.All()) {
343+
t.Fatalf("--all returned %d, want %d", len(all), len(pfw.All()))
344+
}
345+
346+
args, err := resolveUninstallTargets([]string{"npm", "pypi"}, nil, false)
347+
if err != nil {
348+
t.Fatal(err)
349+
}
350+
if len(args) != 2 || args[0].Command != "npm" || args[1].Command != "pypi" {
351+
t.Fatalf("targeted returned %+v", args)
352+
}
353+
354+
except, err := resolveUninstallTargets(nil, []string{"aur"}, false)
355+
if err != nil {
356+
t.Fatal(err)
357+
}
358+
if len(except) != len(pfw.All())-1 {
359+
t.Fatalf("--except returned %d, want %d", len(except), len(pfw.All())-1)
360+
}
361+
for _, e := range except {
362+
if e.ID == "aur" {
363+
t.Fatal("aur should be excluded")
364+
}
365+
}
366+
367+
if _, err := resolveUninstallTargets([]string{"bogus"}, nil, false); err == nil {
368+
t.Fatal("expected error for unknown ecosystem")
369+
}
370+
371+
if eco, ok := resolveEcosystem("go-dev"); !ok || eco.ID != "go-dev" {
372+
t.Fatal("go-dev should resolve")
373+
}
374+
}
375+
376+
// TestUninstallRoundTripNpm proves configure and uninstall line up: writing the
377+
// real npm config files then removing them leaves nothing behind.
378+
func TestUninstallRoundTripNpm(t *testing.T) {
379+
home := t.TempDir()
380+
eco, _ := pfw.ByCommand("npm")
381+
files, err := pfw.ConfigFiles(eco, pfw.ConfigOptions{HomeDir: home, ProxyURL: "https://packages.vulnetix.com", OrgID: "org", APIKey: "key"})
382+
if err != nil {
383+
t.Fatal(err)
384+
}
385+
for _, f := range files {
386+
if _, err := upsertPackageFirewallConfigFile(f, false); err != nil {
387+
t.Fatal(err)
388+
}
389+
}
390+
for _, f := range files {
391+
res, err := removePackageFirewallConfigFile(f, "packages.vulnetix.com", false)
392+
if err != nil {
393+
t.Fatal(err)
394+
}
395+
if res != "deleted file" {
396+
t.Fatalf("%s: result = %q", f.Path, res)
397+
}
398+
if _, err := os.Stat(f.Path); !os.IsNotExist(err) {
399+
t.Fatalf("%s not removed", f.Path)
400+
}
401+
}
402+
}
403+
404+
func writeFile(t *testing.T, path, content string) {
405+
t.Helper()
406+
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
407+
t.Fatal(err)
408+
}
409+
}
410+
411+
func readFile(t *testing.T, path string) string {
412+
t.Helper()
413+
data, err := os.ReadFile(path)
414+
if err != nil {
415+
t.Fatal(err)
416+
}
417+
return string(data)
418+
}

website/content/docs/cli-reference/_index.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,30 @@ This command writes a `machine packages.vulnetix.com` entry to `.netrc`, persist
124124
| `--proxy-url` | string | `https://packages.vulnetix.com` | Package Firewall Go proxy URL |
125125
| `--dry-run` | bool | `false` | Show planned changes without writing files |
126126

127+
#### package-firewall uninstall
128+
129+
Remove the configuration written for one, some, or every ecosystem. Needs no authentication — it operates on local files only. See [Uninstall](/docs/enterprise/package-firewall/uninstall/).
130+
131+
```bash
132+
vulnetix package-firewall uninstall npm pypi # named ecosystems
133+
vulnetix package-firewall uninstall --all # every supported ecosystem
134+
vulnetix package-firewall uninstall --except aur # all but the named ones
135+
vulnetix package-firewall uninstall --purge # every ecosystem + the shared netrc credential
136+
```
137+
138+
Exactly one selector is required: positional ecosystem(s), `--all`, or `--except`. The shared `~/.netrc` credential is kept unless `--remove-credentials` or `--purge` is given.
139+
140+
**Flags:**
141+
142+
| Flag | Type | Default | Description |
143+
|------|------|---------|-------------|
144+
| `--all` | bool | `false` | Unconfigure every supported ecosystem |
145+
| `--except` | strings || Unconfigure all supported ecosystems except these |
146+
| `--remove-credentials` | bool | `false` | Also remove the shared netrc credential (`machine packages.vulnetix.com`) |
147+
| `--purge` | bool | `false` | Remove the shared netrc credential and every supported ecosystem |
148+
| `--proxy-url` | string | `https://packages.vulnetix.com` | Package Firewall proxy URL (host to detect and strip) |
149+
| `--dry-run` | bool | `false` | Show planned changes without writing files |
150+
127151
---
128152

129153
### vulnetix config

website/content/docs/enterprise/_index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Enterprise features for corporate environments, distribution, and advanced deplo
77

88
{{< cards >}}
99
{{< card link="corporate-proxy" title="Corporate Proxy" subtitle="Proxy servers, firewalls, and restricted networks." >}}
10-
{{< card link="package-firewall" title="Package Firewall" subtitle="Proxy and policy-enforce dependencies across 21 ecosystems — npm, PyPI, Cargo, Go, Maven, Docker/OCI and more." >}}
10+
{{< card link="package-firewall" title="Package Firewall" subtitle="Proxy and policy-enforce dependencies across 23 ecosystems — npm, PyPI, Cargo, Go, Maven, Homebrew, Arch Linux, Docker/OCI and more." >}}
1111
{{< card link="quality-gates" title="Quality Gates" subtitle="Org-wide scan enforcement — severity, exploits, EOL, unpinned deps, SCA autofix." >}}
1212
{{< card link="publishing" title="Publishing & Distribution" subtitle="How Vulnetix CLI is published and distributed." >}}
1313
{{< /cards >}}

0 commit comments

Comments
 (0)