Files
sif/internal/modules/rails_secret_exposure_test.go
TigahandGitHub a9fde8c695 feat(modules): add rails config secret exposure modules (#199)
modules/recon/rails-database-yml-exposure.yaml flags an exposed
config/database.yml on the adapter key paired with a credential key, then
extracts the database name. requiring a credential key keeps a credential free
sqlite config from being reported as a high severity leak.

modules/recon/rails-secrets-yml-exposure.yaml flags an exposed
config/secrets.yml on the secret_key_base key and extracts the secret, the
value an attacker needs to forge rails sessions.

modules/recon/rails-master-key-exposure.yaml flags an exposed master key, the
32 hex value that decrypts the encrypted credentials store. the matcher anchors
the hex to the whole body so a longer digest such as a sha256 served at the
same path does not match, and the same probe covers config/credentials.

internal/modules/rails_secret_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: a credential free sqlite config, a longer hex
digest, a hex value away from the body start, an html page naming the markers,
a config without the markers and a 404, none of which may match.

verify: go test ./internal/modules, each matcher, guard, anchor and extractor
proven to bite (break -> red, restore -> green).
2026-06-22 17:19:50 -07:00

132 lines
4.6 KiB
Go

package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runRailsModule(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 railsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestRailsSecretExposureModules(t *testing.T) {
const database = "../../modules/recon/rails-database-yml-exposure.yaml"
const secrets = "../../modules/recon/rails-secrets-yml-exposure.yaml"
const masterKey = "../../modules/recon/rails-master-key-exposure.yaml"
const keyBase = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const masterKeyValue = "0123456789abcdef0123456789abcdef"
t.Run("database config leaks the database name and credentials", func(t *testing.T) {
body := "default: &default\n adapter: postgresql\n encoding: unicode\n pool: 5\n" +
" username: app_user\n password: s3cr3tdbpass\n host: db.internal\n\n" +
"production:\n <<: *default\n database: myapp_production\n"
res := runRailsModule(t, database, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a database config finding")
}
if v := railsExtract(res, "database"); v != "myapp_production" {
t.Errorf("database=%q, want myapp_production", v)
}
})
t.Run("a credential free sqlite database config is not a leak", func(t *testing.T) {
body := "production:\n adapter: sqlite3\n database: db/production.sqlite3\n pool: 5\n"
if res := runRailsModule(t, database, 200, body); len(res.Findings) > 0 {
t.Errorf("a sqlite config without credentials should not match, got %d findings", len(res.Findings))
}
})
t.Run("secrets config leaks the secret key base", func(t *testing.T) {
body := "development:\n secret_key_base: " + keyBase + "\n"
res := runRailsModule(t, secrets, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a secrets config finding")
}
if v := railsExtract(res, "secret_key_base"); v != keyBase {
t.Errorf("secret_key_base=%q, want %q", v, keyBase)
}
})
t.Run("master key file leaks the key", func(t *testing.T) {
res := runRailsModule(t, masterKey, 200, masterKeyValue)
if len(res.Findings) == 0 {
t.Fatal("expected a master key finding")
}
if v := railsExtract(res, "master_key"); v != masterKeyValue {
t.Errorf("master_key=%q, want %q", v, masterKeyValue)
}
})
t.Run("a longer hex digest is not the master key", func(t *testing.T) {
body := masterKeyValue + masterKeyValue
if res := runRailsModule(t, masterKey, 200, body); len(res.Findings) > 0 {
t.Errorf("a 64 char digest should not match the 32 char key, got %d findings", len(res.Findings))
}
})
t.Run("a hex value not at the body start is not the master key", func(t *testing.T) {
body := "key=" + masterKeyValue
if res := runRailsModule(t, masterKey, 200, body); len(res.Findings) > 0 {
t.Errorf("a hex value away from the start should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page naming the rails markers is not a leak", func(t *testing.T) {
body := "<html><head><title>Error</title></head><body>secret_key_base: " + keyBase + "</body></html>"
if res := runRailsModule(t, secrets, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a config without the rails markers is not a leak", func(t *testing.T) {
body := "password: hunter2\nusername: admin\nhost: db.internal\n"
for _, file := range []string{database, secrets, masterKey} {
if res := runRailsModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a config without the rails markers should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{database, secrets, masterKey} {
if res := runRailsModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}