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
use std::collections::HashMap;
use std::time::Duration;
use keyhog_core::{AuthSpec, VerificationResult};
use reqwest::Client;
use crate::interpolate::{interpolate, resolve_field, sanitize_raw_value};
use crate::verify::{build_aws_probe, RequestBuildResult};
pub(crate) async fn build_request_for_auth(
request: reqwest::RequestBuilder,
auth: &AuthSpec,
credential: &str,
companions: &HashMap<String, String>,
timeout: Duration,
client: &Client,
) -> RequestBuildResult {
match auth {
AuthSpec::None => RequestBuildResult::Ready(request),
AuthSpec::Bearer { field } => {
// SECURITY: kimi verifier audit LOW finding. Bearer token
// values feed into `Authorization:` headers. If a credential
// contains a CR/LF or NUL it must be stripped first, matching
// the sanitization Header auth already applies via interpolate().
// A raw newline in a bearer token would silently terminate the
// header line and inject the next byte into the request stream.
let token = sanitize_raw_value(&resolve_field(field, credential, companions));
RequestBuildResult::Ready(request.bearer_auth(token))
}
AuthSpec::Basic { username, password } => {
// SECURITY: same finding - Basic auth values land in the
// Authorization header after reqwest base64-encodes them, but
// a NUL byte in the raw username/password still propagates as
// a `\0` byte through the encoding round-trip and can confuse
// C-FFI HTTP parsers downstream. Strip controls first.
let u = sanitize_raw_value(&resolve_field(username, credential, companions));
let p = sanitize_raw_value(&resolve_field(password, credential, companions));
RequestBuildResult::Ready(request.basic_auth(u, Some(p)))
}
AuthSpec::Header { name, template } => {
let value = interpolate(template, credential, companions);
RequestBuildResult::Ready(request.header(name, value))
}
AuthSpec::Query { param, field } => {
// SECURITY: same finding - query params land in the URL.
// reqwest percent-encodes safe chars but control bytes can
// still survive in raw form depending on serializer path.
let value = sanitize_raw_value(&resolve_field(field, credential, companions));
RequestBuildResult::Ready(request.query(&[(param, value)]))
}
AuthSpec::AwsV4 {
access_key,
secret_key,
session_token,
region,
..
} => {
build_aws_probe(
access_key,
secret_key,
session_token,
region,
credential,
companions,
timeout,
client,
)
.await
}
AuthSpec::Script { engine, code } => {
// SECURITY: kimi-wave1 audit finding 4.HIGH. The Script auth
// path runs operator-supplied script source (from a detector
// TOML) inside `codewalk::sandbox` with `companions` (which
// can include credential-adjacent fields) in scope. The
// sandbox's isolation guarantees are not re-audited inside
// keyhog. Refuse by default; require an explicit opt-in env
// var on the host running keyhog. This is NOT a feature flag
// - it's an admin policy switch, not surfaced via CLI.
if std::env::var("KEYHOG_ALLOW_SCRIPT_VERIFY").as_deref() != Ok("1") {
return RequestBuildResult::Final {
result: VerificationResult::Error(
"blocked: AuthSpec::Script verification disabled (set \
KEYHOG_ALLOW_SCRIPT_VERIFY=1 to enable; sandbox isolation \
is not re-audited inside keyhog)"
.to_string(),
),
metadata: HashMap::new(),
transient: false,
};
}
// Even with the env var set, restrict engine to a known
// allowlist. New engines need a code change + audit, not
// a config knob.
const ALLOWED_ENGINES: &[&str] = &["python3", "python", "node"];
if !ALLOWED_ENGINES.contains(&engine.as_str()) {
return RequestBuildResult::Final {
result: VerificationResult::Error(format!(
"blocked: AuthSpec::Script engine '{engine}' is not on \
the allowlist ({:?}); refuse to run unknown interpreters \
with credential context in scope",
ALLOWED_ENGINES
)),
metadata: HashMap::new(),
transient: false,
};
}
let variables = companions.clone();
match codewalk::sandbox::execute_script(
engine,
code,
"verification_target",
"custom_verify",
&variables,
timeout,
)
.await
{
Ok(output) => {
if output.contains("STATUS: LIVE") {
RequestBuildResult::Final {
result: VerificationResult::Live,
metadata: HashMap::new(),
transient: false,
}
} else {
RequestBuildResult::Final {
result: VerificationResult::Dead,
metadata: HashMap::new(),
transient: false,
}
}
}
Err(e) => RequestBuildResult::Final {
result: VerificationResult::Error(e.to_string()),
metadata: HashMap::new(),
transient: true,
},
}
}
}
}