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)
This commit is contained in:
salvacybersec
2026-04-05 15:47:49 +03:00
parent d4c140371e
commit 3dfe72779b

View File

@@ -1,24 +1,154 @@
package verify package verify
import ( import (
"bytes"
"context" "context"
"crypto/tls"
"io"
"net/http" "net/http"
"strconv"
"strings"
"time" "time"
"github.com/salvacybersec/keyhunter/pkg/engine" "github.com/salvacybersec/keyhunter/pkg/engine"
"github.com/salvacybersec/keyhunter/pkg/providers" "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 { type HTTPVerifier struct {
Client *http.Client Client *http.Client
Timeout time.Duration 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 { 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 { 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
} }