diff --git a/internal/modules/pkgreg_exposure_test.go b/internal/modules/pkgreg_exposure_test.go
new file mode 100644
index 0000000..475b992
--- /dev/null
+++ b/internal/modules/pkgreg_exposure_test.go
@@ -0,0 +1,102 @@
+package modules_test
+
+import "testing"
+
+// these tests reuse runRegistryModule/registryExtract from registry_exposure_test.go.
+
+func TestPackageRegistryExposureModules(t *testing.T) {
+ const chartmuseum = "../../modules/recon/chartmuseum-index-exposure.yaml"
+ const verdaccio = "../../modules/recon/verdaccio-packages-exposure.yaml"
+
+ chartIndex := `apiVersion: v1
+entries:
+ mychart:
+ - apiVersion: v2
+ appVersion: "1.0.0"
+ created: "2026-01-01T00:00:00.000000000Z"
+ description: a helm chart
+ digest: a1b2c3d4e5f6
+ name: mychart
+ urls:
+ - charts/mychart-0.1.0.tgz
+ version: 0.1.0
+generated: "2026-01-01T00:00:00.000000000Z"
+serverInfo: {}
+`
+
+ verdaccioBody := `[{"name":"my-internal-pkg","version":"1.2.3","description":"internal tool"}]`
+ verdaccioJSONHeader := map[string]string{"Content-Type": "application/json"}
+
+ t.Run("an exposed chartmuseum index is flagged and the generated timestamp is extracted", func(t *testing.T) {
+ res := runRegistryModule(t, chartmuseum, 200, nil, chartIndex)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a chartmuseum finding")
+ }
+ if v := registryExtract(res, "chartmuseum_generated"); v != "2026-01-01T00:00:00.000000000Z" {
+ t.Errorf("chartmuseum_generated=%q, want 2026-01-01T00:00:00.000000000Z", v)
+ }
+ })
+
+ t.Run("a stock helm repo index without serverInfo is not flagged", func(t *testing.T) {
+ stockIndex := `apiVersion: v1
+entries:
+ mychart:
+ - name: mychart
+ version: 0.1.0
+generated: "2026-01-01T00:00:00.000000000Z"
+`
+ if res := runRegistryModule(t, chartmuseum, 200, nil, stockIndex); len(res.Findings) > 0 {
+ t.Errorf("a stock helm index (no serverInfo) should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a chartmuseum instance behind auth returning a 401 is not flagged", func(t *testing.T) {
+ if res := runRegistryModule(t, chartmuseum, 401, nil, ""); len(res.Findings) > 0 {
+ t.Errorf("a 401 should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("an html error page mentioning chartmuseum is not flagged", func(t *testing.T) {
+ htmlBody := `
404apiVersion entries: serverInfo not found`
+ if res := runRegistryModule(t, chartmuseum, 200, nil, htmlBody); len(res.Findings) > 0 {
+ t.Errorf("an html page should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("an exposed verdaccio package list is flagged and the package name is extracted", func(t *testing.T) {
+ res := runRegistryModule(t, verdaccio, 200, verdaccioJSONHeader, verdaccioBody)
+ if len(res.Findings) == 0 {
+ t.Fatal("expected a verdaccio finding")
+ }
+ if v := registryExtract(res, "verdaccio_package"); v != "my-internal-pkg" {
+ t.Errorf("verdaccio_package=%q, want my-internal-pkg", v)
+ }
+ })
+
+ t.Run("an empty verdaccio package list still fires since the enumeration endpoint is open", func(t *testing.T) {
+ res := runRegistryModule(t, verdaccio, 200, verdaccioJSONHeader, "[]")
+ if len(res.Findings) == 0 {
+ t.Error("expected a finding for an empty but reachable verdaccio packages endpoint")
+ }
+ })
+
+ t.Run("a json array without the verdaccio json content-type header is not flagged", func(t *testing.T) {
+ if res := runRegistryModule(t, verdaccio, 200, nil, verdaccioBody); len(res.Findings) > 0 {
+ t.Errorf("a missing json content-type should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("a verdaccio login-gated response is not flagged", func(t *testing.T) {
+ if res := runRegistryModule(t, verdaccio, 401, verdaccioJSONHeader, `{"error":"not authorized"}`); len(res.Findings) > 0 {
+ t.Errorf("a 401 should not match, got %d findings", len(res.Findings))
+ }
+ })
+
+ t.Run("an spa fallback html page served for the verdaccio path is not flagged", func(t *testing.T) {
+ htmlBody := `Verdaccio[app root]`
+ htmlHeader := map[string]string{"Content-Type": "text/html"}
+ if res := runRegistryModule(t, verdaccio, 200, htmlHeader, htmlBody); len(res.Findings) > 0 {
+ t.Errorf("an html fallback page should not match, got %d findings", len(res.Findings))
+ }
+ })
+}
diff --git a/modules/recon/chartmuseum-index-exposure.yaml b/modules/recon/chartmuseum-index-exposure.yaml
new file mode 100644
index 0000000..fcfb7c1
--- /dev/null
+++ b/modules/recon/chartmuseum-index-exposure.yaml
@@ -0,0 +1,47 @@
+id: chartmuseum-index-exposure
+info:
+ name: ChartMuseum Index Exposure
+ author: sif
+ severity: medium
+ description: Detects an exposed ChartMuseum helm chart repository that lets anyone list and download every stored chart through its index
+ tags: [chartmuseum, helm, kubernetes, package-registry, information-disclosure, exposure, unauth, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/index.yaml"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: word
+ part: body
+ words:
+ - "apiVersion"
+ - "entries:"
+ - "serverInfo"
+ condition: and
+
+ - type: word
+ part: body
+ negative: true
+ condition: or
+ words:
+ - ""
+ - ""
+
+ extractors:
+ - type: regex
+ name: chartmuseum_generated
+ part: body
+ regex:
+ - 'generated:\s*"?([0-9TZ:.+-]+)"?'
+ group: 1
diff --git a/modules/recon/verdaccio-packages-exposure.yaml b/modules/recon/verdaccio-packages-exposure.yaml
new file mode 100644
index 0000000..6afa867
--- /dev/null
+++ b/modules/recon/verdaccio-packages-exposure.yaml
@@ -0,0 +1,49 @@
+id: verdaccio-packages-exposure
+info:
+ name: Verdaccio Packages Exposure
+ author: sif
+ severity: medium
+ description: Detects an exposed Verdaccio private npm registry that lets anyone enumerate every locally published package through its web api
+ tags: [verdaccio, npm, registry, package-registry, information-disclosure, exposure, unauth, recon]
+
+type: http
+
+http:
+ method: GET
+ paths:
+ - "{{BaseURL}}/-/verdaccio/data/packages"
+
+ matchers:
+ - type: status
+ status:
+ - 200
+
+ - type: regex
+ part: header
+ regex:
+ - '(?i)content-type:\s*application/json'
+
+ - type: regex
+ part: body
+ regex:
+ - '^\s*\['
+
+ - type: word
+ part: body
+ negative: true
+ condition: or
+ words:
+ - ""
+ - ""
+
+ extractors:
+ - type: regex
+ name: verdaccio_package
+ part: body
+ regex:
+ - '"name"\s*:\s*"([^"]+)"'
+ group: 1