Skip to content

Commit b4dec11

Browse files
authored
feat(modules): add dagster and mage exposure modules (#253)
add recon modules for self-hosted data-orchestration webservers that ship no built-in authentication: dagster /server_info discloses the webserver and core versions, and mage /api/status discloses the scheduler status and server repository path; both reach an editor or graphql api on the same instance that can execute arbitrary code.
1 parent c69818e commit b4dec11

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 runDataOrchModule(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 dataOrchExtract(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 TestDataOrchestrationExposureModules(t *testing.T) {
45+
const dagster = "../../modules/recon/dagster-webserver-exposure.yaml"
46+
const mage = "../../modules/recon/mage-status-exposure.yaml"
47+
48+
t.Run("a dagster server_info is flagged with its version", func(t *testing.T) {
49+
body := `{"dagster_webserver_version":"1.7.0","dagster_version":"1.7.0","dagster_graphql_version":"1.7.0"}`
50+
res := runDataOrchModule(t, dagster, 200, body)
51+
if len(res.Findings) == 0 {
52+
t.Fatal("expected a dagster finding")
53+
}
54+
if v := dataOrchExtract(res, "dagster_version"); v != "1.7.0" {
55+
t.Errorf("dagster_version=%q, want 1.7.0", v)
56+
}
57+
})
58+
59+
t.Run("a bare core-version body is not flagged as dagster", func(t *testing.T) {
60+
if res := runDataOrchModule(t, dagster, 200, `{"dagster_version":"1.7.0"}`); len(res.Findings) > 0 {
61+
t.Errorf("a body without dagster_webserver_version should not match, got %d findings", len(res.Findings))
62+
}
63+
})
64+
65+
t.Run("a mage status is flagged with its repo path", func(t *testing.T) {
66+
body := `{"statuses":[{"is_instance_manager":false,"repo_path":"/home/src/default_repo",` +
67+
`"repo_path_relative":"default_repo","scheduler_status":"running","project_type":"standalone",` +
68+
`"project_uuid":"abc-123"}]}`
69+
res := runDataOrchModule(t, mage, 200, body)
70+
if len(res.Findings) == 0 {
71+
t.Fatal("expected a mage finding")
72+
}
73+
if v := dataOrchExtract(res, "mage_repo_path"); v != "/home/src/default_repo" {
74+
t.Errorf("mage_repo_path=%q, want /home/src/default_repo", v)
75+
}
76+
})
77+
78+
t.Run("a statuses collection without scheduler fields is not flagged as mage", func(t *testing.T) {
79+
if res := runDataOrchModule(t, mage, 200, `{"statuses":[{"id":1,"name":"ok"}]}`); len(res.Findings) > 0 {
80+
t.Errorf("a generic statuses array should not match mage, got %d findings", len(res.Findings))
81+
}
82+
})
83+
84+
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
85+
for _, file := range []string{dagster, mage} {
86+
if res := runDataOrchModule(t, file, 200, "ok"); len(res.Findings) > 0 {
87+
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
88+
}
89+
}
90+
})
91+
92+
t.Run("a 404 is not a leak", func(t *testing.T) {
93+
for _, file := range []string{dagster, mage} {
94+
if res := runDataOrchModule(t, file, 404, "not found"); len(res.Findings) > 0 {
95+
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
96+
}
97+
}
98+
})
99+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Dagster Webserver Exposure Detection Module
2+
3+
id: dagster-webserver-exposure
4+
info:
5+
name: Dagster Webserver Exposure
6+
author: sif
7+
severity: medium
8+
description: Detects a Dagster webserver that discloses its version and exposes a run-launching graphql api without authentication
9+
tags: [dagster, dagit, data-orchestration, pipeline, mlops, exposure, unauth, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/server_info"
17+
18+
matchers:
19+
- type: word
20+
part: body
21+
words:
22+
- "\"dagster_webserver_version\""
23+
- "\"dagster_version\""
24+
condition: and
25+
26+
- type: status
27+
status:
28+
- 200
29+
30+
extractors:
31+
- type: regex
32+
name: dagster_version
33+
part: body
34+
regex:
35+
- '"dagster_version"\s*:\s*"([^"]+)"'
36+
group: 1
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Mage Data Pipeline Status Exposure Detection Module
2+
3+
id: mage-status-exposure
4+
info:
5+
name: Mage Data Pipeline Status Exposure
6+
author: sif
7+
severity: medium
8+
description: Detects a Mage data-pipeline server with auth disabled that leaks the scheduler status and the server-side repository path
9+
tags: [mage, mage-ai, data-orchestration, pipeline, mlops, exposure, unauth, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/api/status"
17+
18+
matchers:
19+
- type: word
20+
part: body
21+
words:
22+
- "\"statuses\""
23+
- "\"scheduler_status\""
24+
- "\"repo_path\""
25+
condition: and
26+
27+
- type: status
28+
status:
29+
- 200
30+
31+
extractors:
32+
- type: regex
33+
name: mage_repo_path
34+
part: body
35+
regex:
36+
- '"repo_path"\s*:\s*"([^"]+)"'
37+
group: 1

0 commit comments

Comments
 (0)