diff --git a/internal/modules/dell_idrac_bmc_info_exposure_test.go b/internal/modules/dell_idrac_bmc_info_exposure_test.go new file mode 100644 index 0000000..f4c5908 --- /dev/null +++ b/internal/modules/dell_idrac_bmc_info_exposure_test.go @@ -0,0 +1,81 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runIdracModule(t *testing.T, file string, status int, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule(file) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{ + Timeout: 5 * time.Second, + Threads: 2, + }) + if err != nil { + t.Fatalf("execute %s: %v", file, err) + } + return res +} + +func idracExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestDellIdracBMCInfoExposureModule(t *testing.T) { + const idrac = "../../modules/recon/dell-idrac-bmc-info-exposure.yaml" + + t.Run("a real idrac9 bmc info response is flagged with firmware and model", func(t *testing.T) { + body := `{"Attributes":{"BuildVersion":"21.07.00.00","FwVer":"5.10.10.00","SystemRev":"A00",` + + `"SystemID":"03BB","SystemModelName":"PowerEdge R740","ChassisModel":"PowerEdge R740"}}` + res := runIdracModule(t, idrac, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected an idrac bmc info finding") + } + if v := idracExtract(res, "idrac_firmware"); v != "5.10.10.00" { + t.Errorf("idrac_firmware=%q, want 5.10.10.00", v) + } + if v := idracExtract(res, "idrac_server_model"); v != "PowerEdge R740" { + t.Errorf("idrac_server_model=%q, want PowerEdge R740", v) + } + }) + + t.Run("a body with only SystemModelName and no BuildVersion is not flagged", func(t *testing.T) { + body := `{"SystemModelName":"PowerEdge R740","Notes":"decommissioned"}` + if res := runIdracModule(t, idrac, 200, body); len(res.Findings) > 0 { + t.Errorf("a partial body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a generic build info page mentioning BuildVersion alone is not flagged", func(t *testing.T) { + body := `{"app":"internal-tool","BuildVersion":"1.2.3"}` + if res := runIdracModule(t, idrac, 200, body); len(res.Findings) > 0 { + t.Errorf("an unrelated BuildVersion-only body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + if res := runIdracModule(t, idrac, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/internal/modules/hpe_ilo_xmldata_exposure_test.go b/internal/modules/hpe_ilo_xmldata_exposure_test.go new file mode 100644 index 0000000..3effc6f --- /dev/null +++ b/internal/modules/hpe_ilo_xmldata_exposure_test.go @@ -0,0 +1,87 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runIloModule(t *testing.T, file string, status int, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule(file) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{ + Timeout: 5 * time.Second, + Threads: 2, + }) + if err != nil { + t.Fatalf("execute %s: %v", file, err) + } + return res +} + +func iloExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestHPEIloXMLDataExposureModule(t *testing.T) { + const ilo = "../../modules/recon/hpe-ilo-xmldata-exposure.yaml" + + t.Run("a real ilo xmldata response is flagged with serial, product and firmware", func(t *testing.T) { + body := `CZC1234ABC ProLiant DL380 Gen10` + + `31393736-3935-435A-4331-323334414243100000000-0000-0000-0000-000000000000` + + `Inactive` + + `1Integrated Lights-Out 5 (iLO 5)2.44ASIC: 17` + + `ILO1234567890 ILO000000000000103.4` + res := runIloModule(t, ilo, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected an ilo xmldata finding") + } + if v := iloExtract(res, "ilo_server_serial"); v != "CZC1234ABC" { + t.Errorf("ilo_server_serial=%q, want CZC1234ABC", v) + } + if v := iloExtract(res, "ilo_server_product"); v != "ProLiant DL380 Gen10" { + t.Errorf("ilo_server_product=%q, want ProLiant DL380 Gen10", v) + } + if v := iloExtract(res, "ilo_firmware"); v != "2.44" { + t.Errorf("ilo_firmware=%q, want 2.44", v) + } + }) + + t.Run("an unrelated xml api with a generic RIMP-shaped body is not flagged", func(t *testing.T) { + body := `FAKEGeneric Management Card` + if res := runIloModule(t, ilo, 200, body); len(res.Findings) > 0 { + t.Errorf("a non-ilo RIMP-shaped body should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a page that merely mentions Integrated Lights-Out in prose is not flagged", func(t *testing.T) { + body := `Our support team can help configure Integrated Lights-Out on your HPE server.` + if res := runIloModule(t, ilo, 200, body); len(res.Findings) > 0 { + t.Errorf("a prose mention should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + if res := runIloModule(t, ilo, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/internal/modules/redfish_service_root_test.go b/internal/modules/redfish_service_root_test.go new file mode 100644 index 0000000..196e0df --- /dev/null +++ b/internal/modules/redfish_service_root_test.go @@ -0,0 +1,82 @@ +package modules_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/vmfunc/sif/internal/modules" +) + +func runRedfishModule(t *testing.T, file string, status int, body string) *modules.Result { + t.Helper() + def, err := modules.ParseYAMLModule(file) + if err != nil { + t.Fatalf("parse %s: %v", file, err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{ + Timeout: 5 * time.Second, + Threads: 2, + }) + if err != nil { + t.Fatalf("execute %s: %v", file, err) + } + return res +} + +func redfishExtract(res *modules.Result, key string) string { + for _, f := range res.Findings { + if v := f.Extracted[key]; v != "" { + return v + } + } + return "" +} + +func TestRedfishServiceRootModule(t *testing.T) { + const rf = "../../modules/info/redfish-service-root.yaml" + + t.Run("a real redfish service root is flagged with its version", func(t *testing.T) { + body := `{"@odata.context":"/redfish/v1/$metadata#ServiceRoot.ServiceRoot","@odata.id":"/redfish/v1/",` + + `"@odata.type":"#ServiceRoot.v1_9_0.ServiceRoot","Id":"RootService","Name":"Root Service",` + + `"RedfishVersion":"1.9.0","UUID":"92384634-2938-2342-8820-489239905423",` + + `"Systems":{"@odata.id":"/redfish/v1/Systems"},"Chassis":{"@odata.id":"/redfish/v1/Chassis"},` + + `"Managers":{"@odata.id":"/redfish/v1/Managers"},"SessionService":{"@odata.id":"/redfish/v1/SessionService"}}` + res := runRedfishModule(t, rf, 200, body) + if len(res.Findings) == 0 { + t.Fatal("expected a redfish service root finding") + } + if v := redfishExtract(res, "redfish_version"); v != "1.9.0" { + t.Errorf("redfish_version=%q, want 1.9.0", v) + } + }) + + t.Run("a body with RedfishVersion and Managers but a non-ServiceRoot odata.type is not flagged", func(t *testing.T) { + body := `{"@odata.type":"#ManagerCollection.ManagerCollection","RedfishVersion":"1.9.0",` + + `"Managers":[{"@odata.id":"/redfish/v1/Managers/1"}]}` + if res := runRedfishModule(t, rf, 200, body); len(res.Findings) > 0 { + t.Errorf("a manager collection page should not match the service root, got %d findings", len(res.Findings)) + } + }) + + t.Run("a docs page mentioning RedfishVersion in prose is not flagged", func(t *testing.T) { + body := `The Redfish service root exposes a RedfishVersion and a Managers collection for BMC discovery.` + if res := runRedfishModule(t, rf, 200, body); len(res.Findings) > 0 { + t.Errorf("a prose mention should not match, got %d findings", len(res.Findings)) + } + }) + + t.Run("a 404 is not a leak", func(t *testing.T) { + if res := runRedfishModule(t, rf, 404, "not found"); len(res.Findings) > 0 { + t.Errorf("a 404 should not match, got %d findings", len(res.Findings)) + } + }) +} diff --git a/modules/info/redfish-service-root.yaml b/modules/info/redfish-service-root.yaml new file mode 100644 index 0000000..14edd51 --- /dev/null +++ b/modules/info/redfish-service-root.yaml @@ -0,0 +1,40 @@ +# Redfish Service Root Fingerprint Detection Module + +id: redfish-service-root +info: + name: Redfish Service Root + author: sif + severity: info + description: Detects a reachable DMTF Redfish service root, confirming an out-of-band BMC management API is exposed on the network even though the root document is spec-mandated to answer without authentication + tags: [redfish, bmc, dmtf, out-of-band, panel, detection] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/redfish/v1/" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + words: + - "RedfishVersion" + - "Managers" + + - type: regex + part: body + regex: + - '"@odata\.type"\s*:\s*"#ServiceRoot\.' + + extractors: + - type: regex + name: redfish_version + part: body + regex: + - '"RedfishVersion"\s*:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/dell-idrac-bmc-info-exposure.yaml b/modules/recon/dell-idrac-bmc-info-exposure.yaml new file mode 100644 index 0000000..8ad76a7 --- /dev/null +++ b/modules/recon/dell-idrac-bmc-info-exposure.yaml @@ -0,0 +1,42 @@ +# Dell iDRAC Unauthenticated BMC Info Exposure Detection Module + +id: dell-idrac-bmc-info-exposure +info: + name: Dell iDRAC Unauthenticated BMC Info Exposure + author: sif + severity: medium + description: Detects a Dell iDRAC9 sysmgmt bmc info endpoint answering without authentication, leaking the host server model and iDRAC firmware version + tags: [idrac, dell, bmc, out-of-band, exposure, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/sysmgmt/2015/bmc/info" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + words: + - "BuildVersion" + - "SystemModelName" + + extractors: + - type: regex + name: idrac_firmware + part: body + regex: + - '"FwVer"\s*:\s*"([^"]+)"' + group: 1 + + - type: regex + name: idrac_server_model + part: body + regex: + - '"SystemModelName"\s*:\s*"([^"]+)"' + group: 1 diff --git a/modules/recon/hpe-ilo-xmldata-exposure.yaml b/modules/recon/hpe-ilo-xmldata-exposure.yaml new file mode 100644 index 0000000..b65cc4e --- /dev/null +++ b/modules/recon/hpe-ilo-xmldata-exposure.yaml @@ -0,0 +1,55 @@ +# HPE iLO Unauthenticated XML Data Exposure Detection Module + +id: hpe-ilo-xmldata-exposure +info: + name: HPE iLO Unauthenticated XML Data Exposure + author: sif + severity: high + description: Detects an HPE iLO management processor answering /xmldata?item=all without authentication, leaking the host server serial number, product name and iLO firmware revision + tags: [ilo, hpe, bmc, out-of-band, exposure, recon] + +type: http + +http: + method: GET + paths: + - "{{BaseURL}}/xmldata?item=all" + + matchers: + - type: status + status: + - 200 + + - type: word + part: body + words: + - "" + - "" + - "" + + - type: word + part: body + words: + - "Integrated Lights-Out" + + extractors: + - type: regex + name: ilo_server_serial + part: body + regex: + - '\s*([^<]*?)\s*' + group: 1 + + - type: regex + name: ilo_server_product + part: body + regex: + - '([^<]+)' + group: 1 + + - type: regex + name: ilo_firmware + part: body + regex: + - '([^<]+)' + group: 1