mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 14:37:01 -07:00
feat(modules): add chartmuseum and verdaccio registry exposure modules (#300)
* feat(modules): add chartmuseum and verdaccio registry exposure modules * chore(modules): trim redundant module header comments drop the top-line comments on the new package registry exposure modules that just restated the id/name fields already in the info block
This commit is contained in:
@@ -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 := `<!DOCTYPE html><html><head><title>404</title></head><body>apiVersion entries: serverInfo not found</body></html>`
|
||||
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 := `<!DOCTYPE html><html><head><title>Verdaccio</title></head><body>[app root]</body></html>`
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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:
|
||||
- "<!DOCTYPE"
|
||||
- "<!doctype"
|
||||
- "<html"
|
||||
- "<HTML"
|
||||
- "<head>"
|
||||
- "<title>"
|
||||
|
||||
extractors:
|
||||
- type: regex
|
||||
name: chartmuseum_generated
|
||||
part: body
|
||||
regex:
|
||||
- 'generated:\s*"?([0-9TZ:.+-]+)"?'
|
||||
group: 1
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user