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: + - "<!DOCTYPE" + - "<!doctype" + - "<html" + - "<HTML" + - "<head>" + - "<title>" + + extractors: + - type: regex + name: verdaccio_package + part: body + regex: + - '"name"\s*:\s*"([^"]+)"' + group: 1