diff --git a/pkg/verify/result.go b/pkg/verify/result.go new file mode 100644 index 0000000..a7f1615 --- /dev/null +++ b/pkg/verify/result.go @@ -0,0 +1,29 @@ +// Package verify provides active API key verification by calling provider +// verify endpoints (driven by the YAML VerifySpec) and classifying the +// response into live/dead/rate_limited/error/unknown states. +package verify + +import "time" + +// Status constants for Result.Status. +const ( + StatusLive = "live" + StatusDead = "dead" + StatusRateLimited = "rate_limited" + StatusError = "error" + StatusUnknown = "unknown" +) + +// Result is the outcome of verifying a single finding against its provider's +// verify endpoint. Verify never returns a Go error — transport, timeout, and +// classification failures are all encoded into the Result. +type Result struct { + ProviderName string + KeyMasked string + Status string // one of the Status* constants + HTTPCode int + Metadata map[string]string + RetryAfter time.Duration + ResponseTime time.Duration + Error string +} diff --git a/pkg/verify/verifier.go b/pkg/verify/verifier.go new file mode 100644 index 0000000..db6da59 --- /dev/null +++ b/pkg/verify/verifier.go @@ -0,0 +1,24 @@ +package verify + +import ( + "context" + "net/http" + "time" + + "github.com/salvacybersec/keyhunter/pkg/engine" + "github.com/salvacybersec/keyhunter/pkg/providers" +) + +// Stub for RED step — always returns StatusUnknown. +type HTTPVerifier struct { + Client *http.Client + Timeout time.Duration +} + +func NewHTTPVerifier(timeout time.Duration) *HTTPVerifier { + return &HTTPVerifier{Client: &http.Client{Timeout: timeout}, Timeout: timeout} +} + +func (v *HTTPVerifier) Verify(ctx context.Context, f engine.Finding, p providers.Provider) Result { + return Result{ProviderName: f.ProviderName, KeyMasked: f.KeyMasked, Status: StatusUnknown} +} diff --git a/pkg/verify/verifier_test.go b/pkg/verify/verifier_test.go new file mode 100644 index 0000000..ba81245 --- /dev/null +++ b/pkg/verify/verifier_test.go @@ -0,0 +1,189 @@ +package verify + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "github.com/salvacybersec/keyhunter/pkg/engine" + "github.com/salvacybersec/keyhunter/pkg/providers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestVerifier builds an HTTPVerifier whose transport trusts the given +// httptest TLS server's self-signed cert. +func newTestVerifier(t *testing.T, srv *httptest.Server, timeout time.Duration) *HTTPVerifier { + t.Helper() + v := NewHTTPVerifier(timeout) + v.Client = srv.Client() + v.Client.Timeout = timeout + return v +} + +func testFinding(key string) engine.Finding { + return engine.Finding{ + ProviderName: "testprov", + KeyValue: key, + KeyMasked: engine.MaskKey(key + "padding1234"), + } +} + +func testProvider(spec providers.VerifySpec) providers.Provider { + return providers.Provider{Name: "testprov", Verify: spec} +} + +func TestVerify_Live_200(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + v := newTestVerifier(t, srv, 5*time.Second) + p := testProvider(providers.VerifySpec{URL: srv.URL, Method: "GET"}) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + assert.Equal(t, StatusLive, res.Status) + assert.Equal(t, 200, res.HTTPCode) +} + +func TestVerify_Dead_401(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + v := newTestVerifier(t, srv, 5*time.Second) + p := testProvider(providers.VerifySpec{URL: srv.URL}) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + assert.Equal(t, StatusDead, res.Status) + assert.Equal(t, 401, res.HTTPCode) +} + +func TestVerify_RateLimited_429_WithRetryAfter(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(http.StatusTooManyRequests) + })) + defer srv.Close() + + v := newTestVerifier(t, srv, 5*time.Second) + p := testProvider(providers.VerifySpec{URL: srv.URL}) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + assert.Equal(t, StatusRateLimited, res.Status) + assert.Equal(t, 30*time.Second, res.RetryAfter) +} + +func TestVerify_MetadataExtraction(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"organization":{"name":"Acme"},"tier":"plus"}`)) + })) + defer srv.Close() + + v := newTestVerifier(t, srv, 5*time.Second) + p := testProvider(providers.VerifySpec{ + URL: srv.URL, + MetadataPaths: map[string]string{"org": "organization.name", "tier": "tier"}, + }) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + require.Equal(t, StatusLive, res.Status) + assert.Equal(t, "Acme", res.Metadata["org"]) + assert.Equal(t, "plus", res.Metadata["tier"]) +} + +func TestVerify_KeySubstitution_InHeader(t *testing.T) { + var gotAuth string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + v := newTestVerifier(t, srv, 5*time.Second) + p := testProvider(providers.VerifySpec{ + URL: srv.URL, + Headers: map[string]string{"Authorization": "Bearer {{KEY}}"}, + }) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + assert.Equal(t, StatusLive, res.Status) + assert.Equal(t, "Bearer sk-test-keyvalue", gotAuth) +} + +func TestVerify_KeySubstitution_InBody(t *testing.T) { + var gotBody string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + v := newTestVerifier(t, srv, 5*time.Second) + p := testProvider(providers.VerifySpec{ + URL: srv.URL, + Method: "POST", + Body: `{"api_key":"{{KEY}}"}`, + }) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + assert.Equal(t, StatusLive, res.Status) + assert.Equal(t, `{"api_key":"sk-test-keyvalue"}`, gotBody) +} + +func TestVerify_KeySubstitution_InURL(t *testing.T) { + var gotKey string + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.URL.Query().Get("key") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + v := newTestVerifier(t, srv, 5*time.Second) + p := testProvider(providers.VerifySpec{URL: srv.URL + "/v1/models?key={{KEY}}"}) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + assert.Equal(t, StatusLive, res.Status) + assert.Equal(t, "sk-test-keyvalue", gotKey) +} + +func TestVerify_MissingURL_Unknown(t *testing.T) { + v := NewHTTPVerifier(5 * time.Second) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), testProvider(providers.VerifySpec{})) + assert.Equal(t, StatusUnknown, res.Status) +} + +func TestVerify_HTTPRejected(t *testing.T) { + v := NewHTTPVerifier(5 * time.Second) + p := testProvider(providers.VerifySpec{URL: "http://example.com/verify"}) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + assert.Equal(t, StatusError, res.Status) + assert.True(t, strings.Contains(strings.ToLower(res.Error), "https"), "error should mention HTTPS: %q", res.Error) +} + +func TestVerify_Timeout(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(300 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + v := newTestVerifier(t, srv, 50*time.Millisecond) + p := testProvider(providers.VerifySpec{URL: srv.URL}) + res := v.Verify(context.Background(), testFinding("sk-test-keyvalue"), p) + + assert.Equal(t, StatusError, res.Status) + assert.True(t, regexp.MustCompile(`(?i)timeout|deadline|canceled`).MatchString(res.Error), + "expected timeout-like error, got %q", res.Error) +}