Skip to content

Commit 6e022d5

Browse files
authored
feat(modules): detect exposed ai orchestration platforms (#242)
add recon modules for self-hosted agent builders and compute orchestration. ray exposes an unauthenticated compute dashboard whose job api allows code execution, skypilot exposes an open cloud and kubernetes control plane when basic auth is disabled, dify flags a console that allows open registration, and langflow fingerprints a reachable instance over its public version api.
1 parent 1bb5439 commit 6e022d5

5 files changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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 runOrchestrationModule(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 orchestrationExtract(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 TestAIOrchestrationExposureModules(t *testing.T) {
45+
const langflow = "../../modules/recon/langflow-exposure.yaml"
46+
const dify = "../../modules/recon/dify-console-exposure.yaml"
47+
const ray = "../../modules/recon/ray-dashboard-exposure.yaml"
48+
const skypilot = "../../modules/recon/skypilot-api-exposure.yaml"
49+
50+
langflowVersion := `{"version":"1.0.19","main_version":"1.0.19","package":"Langflow"}`
51+
52+
difyFeatures := `{"enable_app_deploy":true,"sso_enforced_for_signin":false,` +
53+
`"sso_enforced_for_signin_protocol":"","enable_marketplace":true,"enable_email_code_login":false,` +
54+
`"enable_email_password_login":true,"enable_social_oauth_login":false,"is_allow_register":true,` +
55+
`"is_allow_create_workspace":false,"is_email_setup":true,"license":{"status":"none","expired_at":""}}`
56+
57+
rayVersion := `{"version":"4","ray_version":"2.9.3","ray_commit":"a1b2c3d4e5","session_name":"session_2024"}`
58+
59+
skypilotHealth := `{"status":"healthy","api_version":"14","version":"0.9.3","version_on_disk":"0.9.3",` +
60+
`"commit":"abc1234def","basic_auth_enabled":false}`
61+
62+
t.Run("a langflow version api is flagged", func(t *testing.T) {
63+
res := runOrchestrationModule(t, langflow, 200, langflowVersion)
64+
if len(res.Findings) == 0 {
65+
t.Fatal("expected a langflow finding")
66+
}
67+
if v := orchestrationExtract(res, "langflow_version"); v != "1.0.19" {
68+
t.Errorf("langflow_version=%q, want 1.0.19", v)
69+
}
70+
})
71+
72+
t.Run("a langflow base build is still flagged", func(t *testing.T) {
73+
body := `{"version":"1.0.19","main_version":"1.0.19","package":"Langflow Base"}`
74+
if res := runOrchestrationModule(t, langflow, 200, body); len(res.Findings) == 0 {
75+
t.Fatal("expected a finding for a langflow base build")
76+
}
77+
})
78+
79+
t.Run("a version api from another package is not flagged as langflow", func(t *testing.T) {
80+
body := `{"version":"1.0","main_version":"1.0","package":"SomeApp"}`
81+
if res := runOrchestrationModule(t, langflow, 200, body); len(res.Findings) > 0 {
82+
t.Errorf("another package should not match langflow, got %d findings", len(res.Findings))
83+
}
84+
})
85+
86+
t.Run("a langflow package without main_version is not flagged", func(t *testing.T) {
87+
if res := runOrchestrationModule(t, langflow, 200, `{"package":"Langflow"}`); len(res.Findings) > 0 {
88+
t.Errorf("a package-only body should not match langflow, got %d findings", len(res.Findings))
89+
}
90+
})
91+
92+
t.Run("a dify system-features is flagged and reports open registration", func(t *testing.T) {
93+
res := runOrchestrationModule(t, dify, 200, difyFeatures)
94+
if len(res.Findings) == 0 {
95+
t.Fatal("expected a dify finding")
96+
}
97+
if v := orchestrationExtract(res, "dify_allow_register"); v != "true" {
98+
t.Errorf("dify_allow_register=%q, want true", v)
99+
}
100+
})
101+
102+
t.Run("a body without sso_enforced_for_signin is not flagged as dify", func(t *testing.T) {
103+
body := `{"enable_email_password_login":true,"is_allow_create_workspace":false,"is_allow_register":true}`
104+
if res := runOrchestrationModule(t, dify, 200, body); len(res.Findings) > 0 {
105+
t.Errorf("a body without sso_enforced_for_signin should not match dify, got %d findings", len(res.Findings))
106+
}
107+
})
108+
109+
t.Run("a body without enable_email_password_login is not flagged as dify", func(t *testing.T) {
110+
body := `{"sso_enforced_for_signin":false,"is_allow_create_workspace":false}`
111+
if res := runOrchestrationModule(t, dify, 200, body); len(res.Findings) > 0 {
112+
t.Errorf("a body without enable_email_password_login should not match dify, got %d findings", len(res.Findings))
113+
}
114+
})
115+
116+
t.Run("a body without is_allow_create_workspace is not flagged as dify", func(t *testing.T) {
117+
body := `{"sso_enforced_for_signin":false,"enable_email_password_login":true}`
118+
if res := runOrchestrationModule(t, dify, 200, body); len(res.Findings) > 0 {
119+
t.Errorf("a body without is_allow_create_workspace should not match dify, got %d findings", len(res.Findings))
120+
}
121+
})
122+
123+
t.Run("a dify with registration disabled is not flagged", func(t *testing.T) {
124+
body := `{"sso_enforced_for_signin":false,"enable_email_password_login":true,"is_allow_create_workspace":true,"is_allow_register":false}`
125+
if res := runOrchestrationModule(t, dify, 200, body); len(res.Findings) > 0 {
126+
t.Errorf("a closed-registration dify should not be flagged, got %d findings", len(res.Findings))
127+
}
128+
})
129+
130+
t.Run("a ray dashboard version is flagged", func(t *testing.T) {
131+
res := runOrchestrationModule(t, ray, 200, rayVersion)
132+
if len(res.Findings) == 0 {
133+
t.Fatal("expected a ray finding")
134+
}
135+
if v := orchestrationExtract(res, "ray_version"); v != "2.9.3" {
136+
t.Errorf("ray_version=%q, want 2.9.3", v)
137+
}
138+
})
139+
140+
t.Run("a generic version api is not flagged as ray", func(t *testing.T) {
141+
body := `{"version":"4","api_version":"v1","build":"123"}`
142+
if res := runOrchestrationModule(t, ray, 200, body); len(res.Findings) > 0 {
143+
t.Errorf("a generic version api should not match ray, got %d findings", len(res.Findings))
144+
}
145+
})
146+
147+
t.Run("a ray_version without a ray_commit is not flagged", func(t *testing.T) {
148+
body := `{"version":"4","ray_version":"2.9.3"}`
149+
if res := runOrchestrationModule(t, ray, 200, body); len(res.Findings) > 0 {
150+
t.Errorf("ray_version alone should not match ray, got %d findings", len(res.Findings))
151+
}
152+
})
153+
154+
t.Run("a skypilot health is flagged with its version and auth state", func(t *testing.T) {
155+
res := runOrchestrationModule(t, skypilot, 200, skypilotHealth)
156+
if len(res.Findings) == 0 {
157+
t.Fatal("expected a skypilot finding")
158+
}
159+
if v := orchestrationExtract(res, "skypilot_version"); v != "0.9.3" {
160+
t.Errorf("skypilot_version=%q, want 0.9.3", v)
161+
}
162+
if v := orchestrationExtract(res, "skypilot_basic_auth"); v != "false" {
163+
t.Errorf("skypilot_basic_auth=%q, want false", v)
164+
}
165+
})
166+
167+
t.Run("a bare status health is not flagged as skypilot", func(t *testing.T) {
168+
if res := runOrchestrationModule(t, skypilot, 200, `{"status":"healthy"}`); len(res.Findings) > 0 {
169+
t.Errorf("an auth-gated bare health should not match skypilot, got %d findings", len(res.Findings))
170+
}
171+
})
172+
173+
t.Run("a body without version_on_disk is not flagged as skypilot", func(t *testing.T) {
174+
body := `{"status":"healthy","api_version":"14","commit":"abc","basic_auth_enabled":false}`
175+
if res := runOrchestrationModule(t, skypilot, 200, body); len(res.Findings) > 0 {
176+
t.Errorf("a body without version_on_disk should not match skypilot, got %d findings", len(res.Findings))
177+
}
178+
})
179+
180+
t.Run("a body without basic_auth_enabled is not flagged as skypilot", func(t *testing.T) {
181+
body := `{"status":"healthy","version_on_disk":"0.9.3","commit":"abc"}`
182+
if res := runOrchestrationModule(t, skypilot, 200, body); len(res.Findings) > 0 {
183+
t.Errorf("a body without basic_auth_enabled should not match skypilot, got %d findings", len(res.Findings))
184+
}
185+
})
186+
187+
t.Run("a body without commit is not flagged as skypilot", func(t *testing.T) {
188+
body := `{"status":"healthy","version_on_disk":"0.9.3","basic_auth_enabled":false}`
189+
if res := runOrchestrationModule(t, skypilot, 200, body); len(res.Findings) > 0 {
190+
t.Errorf("a body without commit should not match skypilot, got %d findings", len(res.Findings))
191+
}
192+
})
193+
194+
t.Run("a skypilot with basic auth enabled is not flagged", func(t *testing.T) {
195+
body := `{"status":"healthy","version_on_disk":"0.9.3","commit":"abc","basic_auth_enabled":true}`
196+
if res := runOrchestrationModule(t, skypilot, 200, body); len(res.Findings) > 0 {
197+
t.Errorf("an auth-enabled skypilot should not be flagged, got %d findings", len(res.Findings))
198+
}
199+
})
200+
201+
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
202+
for _, file := range []string{langflow, dify, ray, skypilot} {
203+
if res := runOrchestrationModule(t, file, 200, "ok"); len(res.Findings) > 0 {
204+
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
205+
}
206+
}
207+
})
208+
209+
t.Run("a 404 is not a leak", func(t *testing.T) {
210+
for _, file := range []string{langflow, dify, ray, skypilot} {
211+
if res := runOrchestrationModule(t, file, 404, "not found"); len(res.Findings) > 0 {
212+
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
213+
}
214+
}
215+
})
216+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Dify Console Exposure Detection Module
2+
3+
id: dify-console-exposure
4+
info:
5+
name: Dify Console Exposure
6+
author: sif
7+
severity: medium
8+
description: Detects a Dify console with open registration enabled; its unauthenticated system-features endpoint reports is_allow_register true, so anyone can create an account on the llm app platform
9+
tags: [dify, llm, ai, agent, platform, exposure, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/console/api/system-features"
17+
18+
matchers:
19+
- type: status
20+
status:
21+
- 200
22+
23+
- type: word
24+
part: body
25+
words:
26+
- "\"sso_enforced_for_signin\""
27+
28+
- type: word
29+
part: body
30+
words:
31+
- "\"enable_email_password_login\""
32+
33+
- type: word
34+
part: body
35+
words:
36+
- "\"is_allow_create_workspace\""
37+
38+
- type: regex
39+
part: body
40+
regex:
41+
- '"is_allow_register"\s*:\s*true'
42+
43+
extractors:
44+
- type: regex
45+
name: dify_allow_register
46+
part: body
47+
regex:
48+
- '"is_allow_register"\s*:\s*(true|false)'
49+
group: 1
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Langflow Exposure Detection Module
2+
3+
id: langflow-exposure
4+
info:
5+
name: Langflow Exposure
6+
author: sif
7+
severity: medium
8+
description: Fingerprints a reachable Langflow low-code agent builder via its version api. The version endpoint is unauthenticated by design, so this confirms the instance is present, not that its auth is missing; Langflow has a history of unauthenticated rce, so a reachable instance warrants review
9+
tags: [langflow, llm, ai, agent, fingerprint, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/api/v1/version"
17+
18+
matchers:
19+
- type: status
20+
status:
21+
- 200
22+
23+
- type: regex
24+
part: body
25+
regex:
26+
- '"package"\s*:\s*"Langflow'
27+
28+
- type: word
29+
part: body
30+
words:
31+
- "\"main_version\""
32+
33+
extractors:
34+
- type: regex
35+
name: langflow_version
36+
part: body
37+
regex:
38+
- '"version"\s*:\s*"([^"]+)"'
39+
group: 1
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Ray Dashboard Exposure Detection Module
2+
3+
id: ray-dashboard-exposure
4+
info:
5+
name: Ray Dashboard Exposure
6+
author: sif
7+
severity: high
8+
description: Detects an exposed Ray dashboard whose version api confirms an unauthenticated cluster that also serves the job submission api
9+
tags: [ray, ai, ml, cluster, api, exposure, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/api/version"
17+
18+
matchers:
19+
- type: status
20+
status:
21+
- 200
22+
23+
- type: word
24+
part: body
25+
words:
26+
- "\"ray_version\""
27+
28+
- type: word
29+
part: body
30+
words:
31+
- "\"ray_commit\""
32+
33+
extractors:
34+
- type: regex
35+
name: ray_version
36+
part: body
37+
regex:
38+
- '"ray_version"\s*:\s*"([^"]+)"'
39+
group: 1
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# SkyPilot API Server Exposure Detection Module
2+
3+
id: skypilot-api-exposure
4+
info:
5+
name: SkyPilot API Server Exposure
6+
author: sif
7+
severity: high
8+
description: Detects an open SkyPilot api server with basic auth disabled; it provisions and controls cloud and kubernetes clusters and holds the cloud credentials, so an unauthenticated server allows job submission and credential access. The health endpoint is served even when auth is enabled, so the module requires basic_auth_enabled to be false to confirm the server is open
9+
tags: [skypilot, ai, ml, compute, orchestration, exposure, recon]
10+
11+
type: http
12+
13+
http:
14+
method: GET
15+
paths:
16+
- "{{BaseURL}}/api/health"
17+
18+
matchers:
19+
- type: status
20+
status:
21+
- 200
22+
23+
- type: word
24+
part: body
25+
words:
26+
- "\"version_on_disk\""
27+
28+
- type: word
29+
part: body
30+
words:
31+
- "\"commit\""
32+
33+
- type: regex
34+
part: body
35+
regex:
36+
- '"basic_auth_enabled"\s*:\s*false'
37+
38+
extractors:
39+
- type: regex
40+
name: skypilot_version
41+
part: body
42+
regex:
43+
- '"version"\s*:\s*"([^"]+)"'
44+
group: 1
45+
46+
- type: regex
47+
name: skypilot_basic_auth
48+
part: body
49+
regex:
50+
- '"basic_auth_enabled"\s*:\s*(true|false)'
51+
group: 1

0 commit comments

Comments
 (0)