Skip to content

Commit 473c74d

Browse files
authored
feat(modules): add nsq and pulsar exposure modules (#297)
* feat(modules): add nsq and pulsar exposure modules * chore(modules): trim redundant module header comments drop the leading comment restating id/name on nsq-stats-exposure and pulsar-metrics-exposure
1 parent f658fc2 commit 473c74d

4 files changed

Lines changed: 263 additions & 0 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package modules_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/vmfunc/sif/internal/modules"
11+
)
12+
13+
func runNSQModule(t *testing.T, file string, status int, body string) *modules.Result {
14+
t.Helper()
15+
def, err := modules.ParseYAMLModule(file)
16+
if err != nil {
17+
t.Fatalf("parse %s: %v", file, err)
18+
}
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
w.WriteHeader(status)
21+
_, _ = w.Write([]byte(body))
22+
}))
23+
defer srv.Close()
24+
25+
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
26+
Timeout: 5 * time.Second,
27+
Threads: 2,
28+
})
29+
if err != nil {
30+
t.Fatalf("execute %s: %v", file, err)
31+
}
32+
return res
33+
}
34+
35+
func nsqExtract(res *modules.Result, key string) string {
36+
for _, f := range res.Findings {
37+
if v := f.Extracted[key]; v != "" {
38+
return v
39+
}
40+
}
41+
return ""
42+
}
43+
44+
func TestNSQStatsExposureModule(t *testing.T) {
45+
const nsq = "../../modules/recon/nsq-stats-exposure.yaml"
46+
47+
t.Run("an nsqd stats response is flagged with its version", func(t *testing.T) {
48+
body := `{"version":"1.3.0","health":"OK","start_time":1717000000,"topics":[` +
49+
`{"topic_name":"orders","channels":[{"channel_name":"billing","depth":0,` +
50+
`"backend_depth":0,"in_flight_count":0,"message_count":1204,"clients":[` +
51+
`{"client_id":"worker-1","remote_address":"10.0.4.12:51322"}]}],` +
52+
`"depth":0,"message_count":1204}],"producers":[],` +
53+
`"memory":{"heap_objects":1024}}`
54+
res := runNSQModule(t, nsq, 200, body)
55+
if len(res.Findings) == 0 {
56+
t.Fatal("expected an nsq finding")
57+
}
58+
if v := nsqExtract(res, "nsq_version"); v != "1.3.0" {
59+
t.Errorf("nsq_version=%q, want 1.3.0", v)
60+
}
61+
})
62+
63+
t.Run("a version and topics without health and start_time is not flagged", func(t *testing.T) {
64+
body := `{"version":"1.3.0","topics":[{"topic_name":"orders"}]}`
65+
if res := runNSQModule(t, nsq, 200, body); len(res.Findings) > 0 {
66+
t.Errorf("a partial body should not match nsq, got %d findings", len(res.Findings))
67+
}
68+
})
69+
70+
t.Run("an unhealthy status value is not flagged", func(t *testing.T) {
71+
body := `{"version":"1.3.0","health":"NOK: no topics","start_time":1717000000,"topics":[]}`
72+
if res := runNSQModule(t, nsq, 200, body); len(res.Findings) > 0 {
73+
t.Errorf("a non-OK health value should not match, got %d findings", len(res.Findings))
74+
}
75+
})
76+
77+
t.Run("a generic monitoring body is not a leak", func(t *testing.T) {
78+
body := `{"status":"healthy","version":"3.1.0","uptime_seconds":1234}`
79+
if res := runNSQModule(t, nsq, 200, body); len(res.Findings) > 0 {
80+
t.Errorf("a generic health body should not match nsq, got %d findings", len(res.Findings))
81+
}
82+
})
83+
84+
t.Run("a 401 login page is not a leak", func(t *testing.T) {
85+
if res := runNSQModule(t, nsq, 401, "Unauthorized"); len(res.Findings) > 0 {
86+
t.Errorf("a 401 should not match, got %d findings", len(res.Findings))
87+
}
88+
})
89+
90+
t.Run("a 404 is not a leak", func(t *testing.T) {
91+
if res := runNSQModule(t, nsq, 404, "not found"); len(res.Findings) > 0 {
92+
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
93+
}
94+
})
95+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package modules_test
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
"time"
9+
10+
"github.com/vmfunc/sif/internal/modules"
11+
)
12+
13+
func runPulsarModule(t *testing.T, file string, status int, body string) *modules.Result {
14+
t.Helper()
15+
def, err := modules.ParseYAMLModule(file)
16+
if err != nil {
17+
t.Fatalf("parse %s: %v", file, err)
18+
}
19+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+
w.WriteHeader(status)
21+
_, _ = w.Write([]byte(body))
22+
}))
23+
defer srv.Close()
24+
25+
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
26+
Timeout: 5 * time.Second,
27+
Threads: 2,
28+
})
29+
if err != nil {
30+
t.Fatalf("execute %s: %v", file, err)
31+
}
32+
return res
33+
}
34+
35+
func TestPulsarMetricsExposureModule(t *testing.T) {
36+
const pulsar = "../../modules/recon/pulsar-metrics-exposure.yaml"
37+
38+
t.Run("a pulsar broker metrics page is flagged", func(t *testing.T) {
39+
body := "# HELP pulsar_topics_count number of topics owned by this broker\n" +
40+
"# TYPE pulsar_topics_count gauge\n" +
41+
"pulsar_topics_count{cluster=\"standalone\"} 12\n" +
42+
"# HELP pulsar_subscriptions_count number of pulsar subscriptions\n" +
43+
"# TYPE pulsar_subscriptions_count gauge\n" +
44+
"pulsar_subscriptions_count{cluster=\"standalone\"} 4\n"
45+
res := runPulsarModule(t, pulsar, 200, body)
46+
if len(res.Findings) == 0 {
47+
t.Fatal("expected a pulsar finding")
48+
}
49+
})
50+
51+
t.Run("topics count without a TYPE line is not flagged", func(t *testing.T) {
52+
body := "pulsar_topics_count{cluster=\"standalone\"} 12\n" +
53+
"pulsar_subscriptions_count{cluster=\"standalone\"} 4\n"
54+
if res := runPulsarModule(t, pulsar, 200, body); len(res.Findings) > 0 {
55+
t.Errorf("a body without a prometheus TYPE line should not match, got %d findings", len(res.Findings))
56+
}
57+
})
58+
59+
t.Run("an unrelated prometheus exporter is not flagged", func(t *testing.T) {
60+
body := "# HELP node_cpu_seconds_total seconds the cpus spent in each mode\n" +
61+
"# TYPE node_cpu_seconds_total counter\n" +
62+
"node_cpu_seconds_total{cpu=\"0\",mode=\"idle\"} 12345\n"
63+
if res := runPulsarModule(t, pulsar, 200, body); len(res.Findings) > 0 {
64+
t.Errorf("node_exporter output should not match pulsar, got %d findings", len(res.Findings))
65+
}
66+
})
67+
68+
t.Run("only subscriptions count without topics count is not flagged", func(t *testing.T) {
69+
body := "# TYPE pulsar_subscriptions_count gauge\n" +
70+
"pulsar_subscriptions_count{cluster=\"standalone\"} 4\n"
71+
if res := runPulsarModule(t, pulsar, 200, body); len(res.Findings) > 0 {
72+
t.Errorf("a partial metric set should not match pulsar, got %d findings", len(res.Findings))
73+
}
74+
})
75+
76+
t.Run("a 401 is not a leak", func(t *testing.T) {
77+
if res := runPulsarModule(t, pulsar, 401, "Unauthorized"); len(res.Findings) > 0 {
78+
t.Errorf("a 401 should not match, got %d findings", len(res.Findings))
79+
}
80+
})
81+
82+
t.Run("a 404 is not a leak", func(t *testing.T) {
83+
if res := runPulsarModule(t, pulsar, 404, "not found"); len(res.Findings) > 0 {
84+
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
85+
}
86+
})
87+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
id: nsq-stats-exposure
2+
info:
3+
name: NSQ Stats API Exposure
4+
author: sif
5+
severity: high
6+
description: Detects an exposed nsqd HTTP API through its unauthenticated stats endpoint. nsqd ships without any authentication mechanism, so the same reachable API also exposes topic/channel pause, delete and publish endpoints
7+
tags: [nsq, nsqd, messaging, streaming, api, exposure, unauth, recon]
8+
9+
type: http
10+
11+
http:
12+
method: GET
13+
paths:
14+
- "{{BaseURL}}/stats?format=json"
15+
16+
matchers:
17+
- type: status
18+
status:
19+
- 200
20+
21+
- type: word
22+
part: body
23+
words:
24+
- "\"topics\""
25+
26+
- type: regex
27+
part: body
28+
regex:
29+
- '"health"\s*:\s*"OK"'
30+
31+
- type: word
32+
part: body
33+
words:
34+
- "\"start_time\""
35+
36+
- type: word
37+
part: body
38+
words:
39+
- "\"version\""
40+
41+
extractors:
42+
- type: regex
43+
name: nsq_version
44+
part: body
45+
regex:
46+
- '"version"\s*:\s*"([^"]+)"'
47+
group: 1
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
id: pulsar-metrics-exposure
2+
info:
3+
name: Apache Pulsar Broker Metrics Exposure
4+
author: sif
5+
severity: medium
6+
description: Detects an exposed Apache Pulsar broker Prometheus metrics endpoint that leaks per-topic and per-subscription throughput and backlog figures without authentication
7+
tags: [pulsar, apache, messaging, streaming, metrics, prometheus, exposure, unauth, recon]
8+
9+
type: http
10+
11+
http:
12+
method: GET
13+
paths:
14+
- "{{BaseURL}}/metrics"
15+
16+
matchers:
17+
- type: status
18+
status:
19+
- 200
20+
21+
- type: word
22+
part: body
23+
words:
24+
- "pulsar_topics_count"
25+
26+
- type: word
27+
part: body
28+
words:
29+
- "pulsar_subscriptions_count"
30+
31+
- type: regex
32+
part: body
33+
regex:
34+
- '(?m)^# TYPE pulsar_'

0 commit comments

Comments
 (0)