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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user