keyhog-verifier 0.5.43

keyhog-verifier: parallel async credential verification framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::OnceLock;
use std::time::{Duration, Instant};

use dashmap::DashMap;
use keyhog_core::{HeaderSpec, HttpMethod, VerificationResult};
use reqwest::Client;

use crate::interpolate::{interpolate_http_value, missing_companion_refs};
use crate::ssrf::{is_private_ip_addr, is_private_url};

// ── refusal family ────────────────────────────────────────────────────────────
// Security/policy refusals: deliberately terse and uniformly `blocked:`-prefixed.
// They are NOT actionable in the operator sense (the credential's host is unsafe
// to contact), so they do not carry a `Fix:`: the refusal IS the correct outcome.
pub const PRIVATE_URL_ERROR: &str = "blocked: private URL";
pub const HTTPS_ONLY_ERROR: &str = "blocked: HTTPS only";
/// The host resolved to zero usable addresses; fail closed rather than proceed.
pub const DNS_NO_ADDRESSES_ERROR: &str = "blocked: DNS returned no addresses";

/// The verification target URL failed to parse. Leads with the legacy
/// `invalid URL:` phrase (Law 3) and preserves the underlying parse error, then
/// points at the most likely cause: the malformed URL is almost always the
/// detector's `[detector.verify] url` (or a credential-interpolated host), not
/// the scanned credential. This is an ACTIONABLE error (carries a `Fix:`), unlike
/// the `blocked:` refusals above.
pub fn invalid_url_error(parse_error: impl std::fmt::Display) -> String {
    format!(
        "invalid URL: {parse_error}. Fix: the verification target URL is malformed, check the \
         detector's `[detector.verify] url` (and any credential-interpolated host) in its TOML"
    )
}

// Operator-facing verification reasons for transport failures. Every message
// leads with the legacy short phrase (`timeout`, `connection failed`,
// `too many redirects`, `request failed`) so downstream substring checks keep
// matching, then states the concrete fix the operator can act on. These are the
// most user-visible verifier errors (they surface as a finding's verification
// status), so they carry context + remedy rather than a bare token.
/// The verification request exceeded its deadline before the endpoint responded.
pub const TIMEOUT_ERROR: &str = "timeout: the endpoint did not respond within the \
     verification deadline. Fix: raise the verification timeout with --timeout, or \
     check network egress / proxy reachability to the credential's host";
/// The TCP/TLS connection to the endpoint could not be opened.
pub const CONNECTION_FAILED_ERROR: &str = "connection failed: could not open a \
     connection to the endpoint. Fix: check DNS resolution, firewall/egress rules, \
     and proxy settings for the credential's host";
/// The endpoint tried to redirect, but redirects are disabled (Policy::none) to
/// keep the pre-connect SSRF screen sound, a redirect target is re-resolved and
/// would bypass the pin, so it is refused rather than followed.
pub const REDIRECT_LIMIT_ERROR: &str = "too many redirects: the endpoint issued a \
     redirect, but redirects are disabled for SSRF safety. Fix: set the detector's \
     verification URL to the canonical API host so it answers directly without \
     redirecting";
/// The request failed before any response arrived (TLS handshake, body write, or
/// another transport error that is not a timeout, connect, or redirect failure).
pub const REQUEST_FAILED_ERROR: &str = "request failed: the HTTP request errored \
     before any response was received. Fix: check the endpoint URL, TLS \
     configuration, and proxy settings for the credential's host";
const PINNED_CLIENT_CACHE_TTL: Duration = Duration::from_secs(60);
const PINNED_CLIENT_CACHE_MAX_ENTRIES: usize = 4096;

pub(crate) struct ResolvedTarget {
    pub client: Client,
    pub url: reqwest::Url,
}

pub(crate) enum RequestBuildResult {
    Ready(reqwest::RequestBuilder),
    Final {
        result: VerificationResult,
        metadata: HashMap<String, String>,
        transient: bool,
    },
}

pub(crate) struct RequestError {
    pub result: VerificationResult,
    pub transient: bool,
}

#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct PinnedClientKey {
    host: String,
    addrs: Vec<SocketAddr>,
    timeout: Duration,
    insecure_tls: bool,
}

struct CachedPinnedClient {
    inserted_at: Instant,
    client: Client,
}

static PINNED_CLIENT_CACHE: OnceLock<DashMap<PinnedClientKey, CachedPinnedClient>> =
    OnceLock::new();

pub(crate) fn reject_private_resolved_addrs(
    addrs: &[std::net::SocketAddr],
    allow_private_ips: bool,
) -> std::result::Result<(), VerificationResult> {
    if !allow_private_ips && addrs.iter().any(|addr| is_private_ip_addr(&addr.ip())) {
        return Err(VerificationResult::Error(PRIVATE_URL_ERROR.into()));
    }
    Ok(())
}

fn screen_target_url_and_addrs(
    url: &reqwest::Url,
    addrs: &[std::net::SocketAddr],
    allow_private_ips: bool,
) -> std::result::Result<(), VerificationResult> {
    if !allow_private_ips && is_private_url(url.as_str()) {
        return Err(VerificationResult::Error(PRIVATE_URL_ERROR.into()));
    }
    reject_private_resolved_addrs(addrs, allow_private_ips)
}

pub(crate) fn ssrf_check_url_with_resolved_addrs_for_test(
    raw_url: &str,
    addrs: &[std::net::SocketAddr],
    allow_private_ips: bool,
) -> std::result::Result<(), VerificationResult> {
    let url = parse_target_url(raw_url)?;
    screen_target_url_and_addrs(&url, addrs, allow_private_ips)
}

pub(crate) async fn resolved_client_for_url(
    base_client: &Client,
    raw_url: &str,
    timeout: Duration,
    allow_private_ips: bool,
    allow_http: bool,
    proxy_in_use: bool,
    insecure_tls: bool,
) -> std::result::Result<ResolvedTarget, VerificationResult> {
    let url = parse_target_url(raw_url)?;
    enforce_target_url_policy(&url, allow_private_ips, allow_http)?;

    // When a proxy is in use, keep the proxy-bearing base client, but still
    // resolve and screen the target locally before handing it to the proxy.
    // Without this preflight, single-label/custom internal domains and public
    // hostnames resolving to private IPs bypass the post-resolution SSRF veto
    // because the proxy owns DNS.
    if proxy_in_use {
        if !allow_private_ips {
            let host = target_host(&url);
            let _screened_addrs =
                resolve_direct_target_addrs(&url, &host, allow_private_ips).await?;
        }
        return Ok(proxied_target(base_client, url));
    }

    // Direct connection (no proxy): resolve the host once and PIN that
    // resolution into the per-request client via `resolve_to_addrs`. The
    // DNS-rebinding fix (kimi-wave1 audit finding 4.2). Previously we
    // only validated the first lookup; reqwest then re-resolved at
    // connect time, allowing an attacker DNS server to return 1.1.1.1
    // the first time and 127.0.0.1 the second. Pinning means the TCP
    // connect uses the IP we already accepted - the second lookup never
    // happens.
    let host = target_host(&url);
    let pinned_addrs = resolve_direct_target_addrs(&url, &host, allow_private_ips).await?;
    let client = direct_target_client(base_client, &host, &pinned_addrs, timeout, insecure_tls)?;

    Ok(ResolvedTarget { client, url })
}

fn parse_target_url(raw_url: &str) -> std::result::Result<reqwest::Url, VerificationResult> {
    reqwest::Url::parse(raw_url).map_err(|e| VerificationResult::Error(invalid_url_error(e)))
}

fn enforce_target_url_policy(
    url: &reqwest::Url,
    allow_private_ips: bool,
    allow_http: bool,
) -> std::result::Result<(), VerificationResult> {
    // SSRF check MUST come before HTTPS-only check to prevent information leakage
    // about internal network topology via error message differentiation.
    screen_target_url_and_addrs(url, &[], allow_private_ips)?;

    // Enforce HTTPS unconditionally in production. Plaintext loopback secret
    // transmission was a known leak vector - see audit release-2026-04-26.
    // Tests that need HTTP set `danger_allow_http=true` AND
    // `danger_allow_private_ips=true` so production paths can never opt
    // into either accidentally.
    if !allow_http && url.scheme() != "https" {
        return Err(VerificationResult::Error(HTTPS_ONLY_ERROR.into()));
    }

    Ok(())
}

fn proxied_target(base_client: &Client, url: reqwest::Url) -> ResolvedTarget {
    ResolvedTarget {
        client: base_client.clone(),
        url,
    }
}

fn target_host(url: &reqwest::Url) -> String {
    url.host_str().unwrap_or_default().to_string() // LAW10: missing/non-string field => empty/placeholder; recall-safe
}

async fn resolve_direct_target_addrs(
    url: &reqwest::Url,
    host: &str,
    allow_private_ips: bool,
) -> std::result::Result<Vec<SocketAddr>, VerificationResult> {
    if host.is_empty() {
        return Ok(Vec::new());
    }

    let port = url
        .port_or_known_default()
        .unwrap_or(crate::DEFAULT_HTTPS_PORT); // LAW10: no explicit port => scheme default; recall-irrelevant
    let target = format!("{host}:{port}");
    match crate::ssrf::resolve_dns_cached(target.as_str()).await {
        Ok(addrs) if addrs.is_empty() => {
            Err(VerificationResult::Error(DNS_NO_ADDRESSES_ERROR.into()))
        }
        Ok(addrs) => {
            screen_target_url_and_addrs(url, &addrs, allow_private_ips)?;
            Ok(addrs)
        }
        Err(error) => {
            // Law 10: failure => fail-closed error (blocked/refused), never proceeds; security guard
            Err(VerificationResult::Error(format!(
                "blocked: DNS resolution failed: {error}"
            )))
        }
    }
}

fn direct_target_client(
    base_client: &Client,
    host: &str,
    pinned_addrs: &[SocketAddr],
    timeout: Duration,
    insecure_tls: bool,
) -> std::result::Result<Client, VerificationResult> {
    // Build or reuse a cached client that pins host->addresses. `.resolve_to_addrs`
    // bypasses the system resolver for this hostname, so reqwest's internal
    // connector cannot re-resolve to a private IP between the check above
    // and the TCP connect. Keep `base_client` for code paths that don't
    // resolve a URL (e.g. AwsV4 self-constructing auth).
    if pinned_addrs.is_empty() {
        return Ok(base_client.clone());
    }

    // The DNS-pinning rebuild MUST replicate the security-critical
    // config baked into `base_client`. Reqwest's default ClientBuilder
    // would otherwise:
    //   - follow redirects (Policy::limited(10)) - the base client sets
    //     Policy::none() to stop a public host from issuing a 302 to a
    //     private IP that bypasses the pre-connect SSRF check (the pin
    //     only covers the ORIGINAL host; the redirect target is
    //     re-resolved via the system resolver).
    //   - validate certs strictly - the base client honors
    //     `--insecure` (`config.insecure_tls`); dropping that here
    //     means the flag silently doesn't apply on the path that
    //     actually serves the request when no proxy is in use.
    // Both gaps were live until 2026-05-26.
    pinned_client_for(host, pinned_addrs, timeout, insecure_tls)
}

/// Canonical, order-independent form of a pinned-address set. DNS routinely
/// returns the same A/AAAA records in a DIFFERENT ORDER per query (round-robin),
/// which made two logically-identical pins hash to different `PinnedClientKey`s
/// and rebuild a fresh reqwest `Client` (TLS config + connection pool) on every
/// request to a round-robin host, the cache never hit. Sorting keys the cache
/// on the IP SET, not its arrival order, so those requests share one client.
pub(crate) fn canonical_pinned_addrs(addrs: &[SocketAddr]) -> Vec<SocketAddr> {
    let mut sorted = addrs.to_vec();
    sorted.sort_unstable();
    sorted
}

/// Test accessor: whether two client pins that share host/timeout/insecure_tls
/// but differ in resolved-address ORDER or SET collapse to the SAME
/// `PinnedClientKey`: the round-robin-DNS cache HIT that `canonical_pinned_addrs`
/// exists to produce (equal keys) vs. the no-false-sharing MISS for a genuinely
/// different IP set (distinct keys). Kept beside the key type so `PinnedClientKey`
/// and its fields stay module-private while the re-homed cache-key tests
/// (`tests/unit/pinned_client_key.rs`) exercise the real `Eq`/canonicalization
/// through this one accessor (the `verify::request` no-inline-tests folder gate
/// forbids testing it in place).
pub(crate) fn pinned_keys_equal_for_test(
    host: &str,
    addrs_a: &[SocketAddr],
    addrs_b: &[SocketAddr],
    timeout: Duration,
    insecure_tls: bool,
) -> bool {
    let key = |addrs: &[SocketAddr]| PinnedClientKey {
        host: host.to_string(),
        addrs: canonical_pinned_addrs(addrs),
        timeout,
        insecure_tls,
    };
    key(addrs_a) == key(addrs_b)
}

fn pinned_client_for(
    host: &str,
    pinned_addrs: &[SocketAddr],
    timeout: Duration,
    insecure_tls: bool,
) -> std::result::Result<Client, VerificationResult> {
    // Key on the CANONICAL (sorted) address set so a DNS round-robin reorder is
    // a cache hit, not a client rebuild. `build_pinned_client` below still
    // receives the original `pinned_addrs` (the pin is a set; connection-attempt
    // order is unchanged for the client actually built).
    let key = PinnedClientKey {
        host: host.to_string(),
        addrs: canonical_pinned_addrs(pinned_addrs),
        timeout,
        insecure_tls,
    };
    let cache = PINNED_CLIENT_CACHE.get_or_init(DashMap::new);
    if let Some(entry) = cache.get(&key) {
        if entry.inserted_at.elapsed() < PINNED_CLIENT_CACHE_TTL {
            return Ok(entry.client.clone());
        }
        drop(entry);
        cache.remove(&key);
    }
    if cache.len() >= PINNED_CLIENT_CACHE_MAX_ENTRIES {
        // Drop the oldest ~1/8 instead of wiping every still-valid pinned client.
        crate::cache::evict_oldest_dashmap_entries(
            cache,
            crate::cache::oldest_eviction_batch(PINNED_CLIENT_CACHE_MAX_ENTRIES),
            |client| client.inserted_at,
        );
    }
    let client = build_pinned_client(host, pinned_addrs, timeout, insecure_tls)?;
    cache.insert(
        key,
        CachedPinnedClient {
            inserted_at: Instant::now(),
            client: client.clone(),
        },
    );
    Ok(client)
}

pub(crate) fn clear_pinned_client_cache_for_test() {
    if let Some(cache) = PINNED_CLIENT_CACHE.get() {
        cache.clear();
    }
}

pub(crate) fn pinned_client_cache_len_for_test() -> usize {
    PINNED_CLIENT_CACHE.get().map_or(0, DashMap::len)
}

pub(crate) fn pinned_client_cache_len_for_host_for_test(host: &str) -> usize {
    PINNED_CLIENT_CACHE.get().map_or(0, |cache| {
        cache
            .iter()
            .filter(|entry| entry.key().host == host)
            .count()
    })
}

pub(crate) fn pinned_client_for_test(
    host: &str,
    pinned_addrs: &[SocketAddr],
    timeout: Duration,
    insecure_tls: bool,
) -> std::result::Result<(), VerificationResult> {
    pinned_client_for(host, pinned_addrs, timeout, insecure_tls).map(|_| ())
}

fn build_pinned_client(
    host: &str,
    pinned_addrs: &[SocketAddr],
    timeout: Duration,
    insecure_tls: bool,
) -> std::result::Result<Client, VerificationResult> {
    // ONE owner for the pinned rebuild: `crate::build_pinned_verifier_client`
    // carries the identical security-critical posture (hardened
    // decompression/redirect + `no_proxy` + host→addr pin) baked into
    // `base_client`. A build failure is a blocked verifier state, never a
    // license to use an unpinned client.
    crate::build_pinned_verifier_client(host, pinned_addrs, timeout, insecure_tls).map_err(|e| {
        VerificationResult::Error(format!(
            "blocked: DNS pin client build failed ({e}); refusing to \
             fall back to an unpinned client (would reopen the \
             DNS-rebinding window). Fix: report this verifier build"
        ))
    })
}

pub(crate) async fn build_request_for_step(
    client: &Client,
    method: &HttpMethod,
    auth: &keyhog_core::AuthSpec,
    url: reqwest::Url,
    credential: &str,
    companions: &HashMap<String, String>,
    timeout: Duration,
    allow_private_ips: bool,
    allow_http: bool,
    proxy_in_use: bool,
    insecure_tls: bool,
    allow_script_verify: bool,
) -> RequestBuildResult {
    let request = request_for_method(client, method, url).timeout(timeout);
    crate::verify::auth::build_request_for_auth(
        request,
        auth,
        credential,
        companions,
        timeout,
        client,
        allow_private_ips,
        allow_http,
        proxy_in_use,
        insecure_tls,
        allow_script_verify,
    )
    .await
}

pub(crate) fn apply_header_body_templates(
    mut request: reqwest::RequestBuilder,
    headers: &[HeaderSpec],
    body_template: Option<&str>,
    credential: &str,
    companions: &HashMap<String, String>,
) -> reqwest::RequestBuilder {
    for header in headers {
        let value = interpolate_http_value(&header.value, credential, companions);
        request = request.header(&header.name, &value);
    }

    if let Some(body_template) = body_template {
        let body = interpolate_http_value(body_template, credential, companions);
        request = request.body(body);
    }

    request
}

pub(crate) fn missing_companion_error(context: &str, missing: &[String]) -> VerificationResult {
    VerificationResult::Error(format!(
        "failed to resolve verification companion(s) in {context}: {}. Fix: configure detector companions that populate every companion.<name> reference before verification",
        missing.join(", ")
    ))
}

pub(crate) fn validate_template_companions(
    context: &str,
    template: &str,
    companions: &HashMap<String, String>,
) -> Result<(), VerificationResult> {
    let missing = missing_companion_refs(template, companions);
    if missing.is_empty() {
        Ok(())
    } else {
        Err(missing_companion_error(context, &missing))
    }
}

pub(crate) fn validate_header_body_templates(
    headers: &[HeaderSpec],
    body_template: Option<&str>,
    companions: &HashMap<String, String>,
) -> Result<(), VerificationResult> {
    for header in headers {
        validate_template_companions("verification header", &header.value, companions)?;
    }
    if let Some(body_template) = body_template {
        validate_template_companions("verification body", body_template, companions)?;
    }
    Ok(())
}

fn request_for_method(
    client: &Client,
    method: &HttpMethod,
    url: reqwest::Url,
) -> reqwest::RequestBuilder {
    match method {
        HttpMethod::Get => client.get(url),
        HttpMethod::Post => client.post(url),
        HttpMethod::Put => client.put(url),
        HttpMethod::Delete => client.delete(url),
        HttpMethod::Patch => client.patch(url),
        HttpMethod::Head => client.head(url),
    }
}

pub(crate) async fn execute_request(
    request: reqwest::RequestBuilder,
) -> std::result::Result<reqwest::Response, RequestError> {
    request.send().await.map_err(|e| RequestError {
        result: if e.is_timeout() {
            VerificationResult::Error(TIMEOUT_ERROR.into())
        } else if e.is_redirect() {
            VerificationResult::Error(REDIRECT_LIMIT_ERROR.into())
        } else if e.is_connect() {
            VerificationResult::Error(CONNECTION_FAILED_ERROR.into())
        } else {
            VerificationResult::Error(REQUEST_FAILED_ERROR.into())
        },
        transient: e.is_timeout() || e.is_connect(),
    })
}