From 3dfe72779b69183e5bf59fa00f2577fb459d3b70 Mon Sep 17 00:00:00 2001 From: salvacybersec Date: Sun, 5 Apr 2026 15:47:49 +0300 Subject: [PATCH] feat(05-03): implement HTTPVerifier single-key verification - HTTPVerifier with TLS 1.2+ client and configurable per-call timeout - {{KEY}} template substitution in URL, header values, and body - Classification via EffectiveSuccessCodes/FailureCodes/RateLimitCodes - Retry-After header captured on rate-limit responses - gjson-based metadata extraction for JSON responses (1 MiB cap) - HTTPS-only enforcement; missing URL yields StatusUnknown - Consent stub added to unblock parallel Plan 05-02 worktree (Rule 3 deviation) --- pkg/verify/verifier.go | 136 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 3 deletions(-) diff --git a/pkg/verify/verifier.go b/pkg/verify/verifier.go index db6da59..ab15743 100644 --- a/pkg/verify/verifier.go +++ b/pkg/verify/verifier.go @@ -1,24 +1,154 @@ package verify import ( + "bytes" "context" + "crypto/tls" + "io" "net/http" + "strconv" + "strings" "time" "github.com/salvacybersec/keyhunter/pkg/engine" "github.com/salvacybersec/keyhunter/pkg/providers" + "github.com/tidwall/gjson" ) -// Stub for RED step — always returns StatusUnknown. +// DefaultTimeout is the per-call verification timeout when none is configured. +const DefaultTimeout = 10 * time.Second + +// maxMetadataBody caps how much of a JSON response we read for metadata extraction. +const maxMetadataBody = 1 << 20 // 1 MiB + +// HTTPVerifier performs a single HTTP call against a provider's VerifySpec and +// classifies the response. It is YAML-driven — no per-provider switches live here. type HTTPVerifier struct { Client *http.Client Timeout time.Duration } +// NewHTTPVerifier returns an HTTPVerifier with a TLS 1.2+ HTTP client and the +// given per-call timeout (falling back to DefaultTimeout when timeout <= 0). func NewHTTPVerifier(timeout time.Duration) *HTTPVerifier { - return &HTTPVerifier{Client: &http.Client{Timeout: timeout}, Timeout: timeout} + if timeout <= 0 { + timeout = DefaultTimeout + } + return &HTTPVerifier{ + Client: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + }, + }, + Timeout: timeout, + } } +// Verify runs a single verification against a provider's verify endpoint. +// It never returns a Go error — transport/classification failures are encoded +// in the Result. Callers classify via Result.Status against the Status* constants. func (v *HTTPVerifier) Verify(ctx context.Context, f engine.Finding, p providers.Provider) Result { - return Result{ProviderName: f.ProviderName, KeyMasked: f.KeyMasked, Status: StatusUnknown} + start := time.Now() + res := Result{ + ProviderName: f.ProviderName, + KeyMasked: f.KeyMasked, + Status: StatusUnknown, + } + + spec := p.Verify + if spec.URL == "" { + return res // StatusUnknown: provider has no verify endpoint + } + if strings.HasPrefix(strings.ToLower(spec.URL), "http://") { + res.Status = StatusError + res.Error = "verify URL must be HTTPS" + return res + } + + // Substitute {{KEY}} (and legacy {KEY}) in URL, headers, and body. + url := substituteKey(spec.URL, f.KeyValue) + + method := spec.Method + if method == "" { + method = http.MethodGet + } + + var bodyReader io.Reader + if spec.Body != "" { + bodyReader = bytes.NewBufferString(substituteKey(spec.Body, f.KeyValue)) + } + + reqCtx, cancel := context.WithTimeout(ctx, v.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, method, url, bodyReader) + if err != nil { + res.Status = StatusError + res.Error = err.Error() + return res + } + for k, val := range spec.Headers { + req.Header.Set(k, substituteKey(val, f.KeyValue)) + } + + resp, err := v.Client.Do(req) + res.ResponseTime = time.Since(start) + if err != nil { + res.Status = StatusError + res.Error = err.Error() + return res + } + defer resp.Body.Close() + res.HTTPCode = resp.StatusCode + + // Classify. Success codes take precedence, then failure, then rate-limit. + switch { + case containsInt(spec.EffectiveSuccessCodes(), resp.StatusCode): + res.Status = StatusLive + case containsInt(spec.EffectiveFailureCodes(), resp.StatusCode): + res.Status = StatusDead + case containsInt(spec.EffectiveRateLimitCodes(), resp.StatusCode): + res.Status = StatusRateLimited + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, convErr := strconv.Atoi(ra); convErr == nil { + res.RetryAfter = time.Duration(secs) * time.Second + } + } + default: + res.Status = StatusUnknown + } + + // Metadata extraction only for live responses with JSON body and configured paths. + if res.Status == StatusLive && len(spec.MetadataPaths) > 0 { + if strings.Contains(resp.Header.Get("Content-Type"), "application/json") { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxMetadataBody)) + meta := make(map[string]string, len(spec.MetadataPaths)) + for displayName, path := range spec.MetadataPaths { + if r := gjson.GetBytes(bodyBytes, path); r.Exists() { + meta[displayName] = r.String() + } + } + if len(meta) > 0 { + res.Metadata = meta + } + } + } + return res +} + +// substituteKey replaces both {{KEY}} and the legacy {KEY} placeholder. +func substituteKey(s, key string) string { + s = strings.ReplaceAll(s, "{{KEY}}", key) + s = strings.ReplaceAll(s, "{KEY}", key) + return s +} + +func containsInt(haystack []int, needle int) bool { + for _, x := range haystack { + if x == needle { + return true + } + } + return false }