use std::{collections::BTreeMap, future::Future, pin::Pin, time::SystemTime};
use anyhow::{Context, Result, anyhow, bail};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use hmac::{Hmac, KeyInit, Mac};
use http::StatusCode;
use kingfisher_core::ValidationOutcome;
use kingfisher_rules::rule::{BetterleaksExpr, BetterleaksValidation};
use liquid::Object as LiquidObject;
use liquid_core::ValueView;
use reqwest::{Client, Method, Url};
use serde_json::Value as JsonValue;
use sha1::Sha1;
use sha2::{Digest, Sha256};
const MAX_RESPONSE_BODY: usize = 1 << 20;
#[derive(Debug)]
pub struct BetterleaksValidationOutcome {
pub valid: bool,
pub reason: Option<super::ValidationReason>,
pub status: StatusCode,
pub body: String,
pub outcome: ValidationOutcome,
}
#[derive(Clone, Debug, PartialEq)]
enum Value {
Null,
Bool(bool),
Integer(i64),
Float(f64),
String(String),
Bytes(Vec<u8>),
Array(Vec<Value>),
Object(BTreeMap<String, Value>),
}
impl Value {
fn as_string(&self) -> String {
match self {
Self::Null => String::new(),
Self::Bool(value) => value.to_string(),
Self::Integer(value) => value.to_string(),
Self::Float(value) => value.to_string(),
Self::String(value) => value.clone(),
Self::Bytes(value) => String::from_utf8_lossy(value).into_owned(),
Self::Array(_) | Self::Object(_) => {
serde_json::to_string(&self.to_json()).unwrap_or_default()
}
}
}
fn as_i64(&self) -> Option<i64> {
match self {
Self::Integer(value) => Some(*value),
Self::Float(value) => Some(*value as i64),
Self::String(value) => value.parse().ok(),
_ => None,
}
}
fn as_f64(&self) -> Option<f64> {
match self {
Self::Integer(value) => Some(*value as f64),
Self::Float(value) => Some(*value),
Self::String(value) => value.parse().ok(),
_ => None,
}
}
fn as_bytes(&self) -> Vec<u8> {
match self {
Self::Bytes(value) => value.clone(),
_ => self.as_string().into_bytes(),
}
}
fn truthy(&self) -> bool {
match self {
Self::Null => false,
Self::Bool(value) => *value,
Self::Integer(value) => *value != 0,
Self::Float(value) => *value != 0.0,
Self::String(value) => !value.is_empty(),
Self::Bytes(value) => !value.is_empty(),
Self::Array(value) => !value.is_empty(),
Self::Object(value) => !value.is_empty(),
}
}
fn get(&self, property: &Value) -> Value {
match self {
Self::Object(values) => {
values.get(&property.as_string()).cloned().unwrap_or(Value::Null)
}
Self::Array(values) => property
.as_i64()
.and_then(|index| usize::try_from(index).ok())
.and_then(|index| values.get(index).cloned())
.unwrap_or(Value::Null),
_ => Value::Null,
}
}
fn to_json(&self) -> JsonValue {
match self {
Self::Null => JsonValue::Null,
Self::Bool(value) => JsonValue::Bool(*value),
Self::Integer(value) => JsonValue::from(*value),
Self::Float(value) => JsonValue::from(*value),
Self::String(value) => JsonValue::String(value.clone()),
Self::Bytes(value) => JsonValue::String(BASE64.encode(value)),
Self::Array(values) => {
JsonValue::Array(values.iter().map(Self::to_json).collect::<Vec<_>>())
}
Self::Object(values) => JsonValue::Object(
values.iter().map(|(key, value)| (key.clone(), value.to_json())).collect(),
),
}
}
fn from_json(value: JsonValue) -> Self {
match value {
JsonValue::Null => Self::Null,
JsonValue::Bool(value) => Self::Bool(value),
JsonValue::Number(value) => value
.as_i64()
.map(Self::Integer)
.or_else(|| value.as_f64().map(Self::Float))
.unwrap_or(Self::Null),
JsonValue::String(value) => Self::String(value),
JsonValue::Array(values) => {
Self::Array(values.into_iter().map(Self::from_json).collect())
}
JsonValue::Object(values) => Self::Object(
values.into_iter().map(|(key, value)| (key, Self::from_json(value))).collect(),
),
}
}
}
type EvalFuture<'a> = Pin<Box<dyn Future<Output = Result<Value>> + Send + 'a>>;
struct Evaluator<'a> {
client: &'a Client,
allow_internal_ips: bool,
environment: BTreeMap<String, String>,
variables: BTreeMap<String, Value>,
pointer: Option<Value>,
last_status: Option<StatusCode>,
}
pub async fn validate(
validation: &BetterleaksValidation,
captures: &[(String, String, usize, usize)],
globals: &LiquidObject,
client: &Client,
allow_internal_ips: bool,
) -> BetterleaksValidationOutcome {
let mut finding_captures = BTreeMap::new();
let mut token = String::new();
for (name, value, ..) in captures {
if name.eq_ignore_ascii_case("TOKEN") {
token = value.clone();
} else {
finding_captures.insert(name.clone(), Value::String(value.clone()));
}
}
let mut finding = BTreeMap::new();
finding.insert("secret".to_string(), Value::String(token));
finding.insert("captures".to_string(), Value::Object(finding_captures));
let mut components = BTreeMap::new();
for (component_id, variable) in &validation.components {
let secret = globals
.get(variable.as_str())
.map(ValueView::to_kstr)
.map(|value| value.to_string())
.unwrap_or_default();
let mut component = BTreeMap::new();
component.insert("secret".to_string(), Value::String(secret));
component.insert("captures".to_string(), Value::Object(BTreeMap::new()));
components.insert(component_id.clone(), Value::Object(component));
}
let mut evaluator = Evaluator {
client,
allow_internal_ips,
environment: globals
.iter()
.map(|(name, value)| (name.to_string(), value.to_kstr().to_string()))
.collect(),
variables: BTreeMap::from([
("finding".to_string(), Value::Object(finding)),
("components".to_string(), Value::Object(components)),
]),
pointer: None,
last_status: None,
};
match evaluator.eval(&validation.expression).await {
Ok(value) => classify(value, evaluator.last_status),
Err(error) => BetterleaksValidationOutcome {
reason: Some(super::ValidationReason::RequestFailed),
valid: false,
status: StatusCode::BAD_GATEWAY,
body: format!("Betterleaks validation error: {error:#}"),
outcome: ValidationOutcome::Unavailable,
},
}
}
fn classify(value: Value, remote_status: Option<StatusCode>) -> BetterleaksValidationOutcome {
let result = match &value {
Value::Object(values) => values.get("result").map(Value::as_string).unwrap_or_default(),
_ => String::new(),
};
let body = serde_json::to_string(&value.to_json())
.unwrap_or_else(|_| "{\"result\":\"error\"}".to_string());
match result.as_str() {
"valid" => BetterleaksValidationOutcome {
reason: None,
valid: true,
status: remote_status.unwrap_or(StatusCode::OK),
body,
outcome: ValidationOutcome::VerifiedActive,
},
"needs_validation" if has_active_credential_marker(&value) => {
BetterleaksValidationOutcome {
reason: None,
valid: true,
status: remote_status.unwrap_or(StatusCode::FORBIDDEN),
body,
outcome: ValidationOutcome::VerifiedActive,
}
}
"invalid" | "revoked" => BetterleaksValidationOutcome {
reason: None,
valid: false,
status: remote_status.unwrap_or(StatusCode::UNAUTHORIZED),
body,
outcome: ValidationOutcome::VerifiedInactive,
},
"skipped" => BetterleaksValidationOutcome {
reason: None,
valid: false,
status: remote_status.unwrap_or(StatusCode::PRECONDITION_REQUIRED),
body: match &value {
Value::Object(values) => values
.get("reason")
.map(Value::as_string)
.filter(|reason| !reason.is_empty())
.unwrap_or(body),
_ => body,
},
outcome: ValidationOutcome::Skipped,
},
_ => BetterleaksValidationOutcome {
reason: None,
valid: false,
status: StatusCode::BAD_GATEWAY,
body: match remote_status {
Some(status) => format!("Remote HTTP status: {status}. {body}"),
None => body,
},
outcome: ValidationOutcome::Unavailable,
},
}
}
fn has_active_credential_marker(value: &Value) -> bool {
let Value::Object(values) = value else {
return false;
};
values
.iter()
.any(|(key, value)| key.starts_with("active_") && matches!(value, Value::Bool(true)))
}
impl Evaluator<'_> {
fn eval<'a>(&'a mut self, expression: &'a BetterleaksExpr) -> EvalFuture<'a> {
Box::pin(async move {
use BetterleaksExpr as Expr;
match expression {
Expr::Nil => Ok(Value::Null),
Expr::Identifier { value } => {
Ok(self.variables.get(value).cloned().unwrap_or(Value::Null))
}
Expr::Integer { value } => Ok(Value::Integer(*value)),
Expr::Float { value } => Ok(Value::Float(
value.parse().with_context(|| format!("invalid float {value:?}"))?,
)),
Expr::Bool { value } => Ok(Value::Bool(*value)),
Expr::String { value } => Ok(Value::String(value.clone())),
Expr::Unary { operator, node } => {
let value = self.eval(node).await?;
match operator.as_str() {
"!" | "not" => Ok(Value::Bool(!value.truthy())),
"-" => Ok(Value::Float(-value.as_f64().unwrap_or_default())),
other => bail!("unsupported unary operator {other}"),
}
}
Expr::Binary { operator, left, right } => {
self.eval_binary(operator, left, right).await
}
Expr::Chain { node } | Expr::Predicate { node } => self.eval(node).await,
Expr::Member { node, property, .. } => {
let container = self.eval(node).await?;
let property = self.eval(property).await?;
Ok(container.get(&property))
}
Expr::Slice { node, from, to } => {
let value = self.eval(node).await?;
let start = self.eval(from).await?.as_i64().unwrap_or_default().max(0) as usize;
let end = self.eval(to).await?.as_i64().unwrap_or(i64::MAX).max(0) as usize;
match value {
Value::Array(values) => Ok(Value::Array(
values[start.min(values.len())..end.min(values.len())].to_vec(),
)),
Value::String(value) => Ok(Value::String(
value.chars().skip(start).take(end.saturating_sub(start)).collect(),
)),
_ => Ok(Value::Null),
}
}
Expr::Call { callee, arguments } => self.eval_call(callee, arguments).await,
Expr::Builtin { name, arguments } => self.eval_builtin(name, arguments).await,
Expr::Conditional { cond, exp1, exp2 } => {
if self.eval(cond).await?.truthy() {
self.eval(exp1).await
} else {
self.eval(exp2).await
}
}
Expr::VariableDeclarator { name, value, expr } => {
let value = self.eval(value).await?;
let previous = self.variables.insert(name.clone(), value);
let result = self.eval(expr).await;
match previous {
Some(value) => {
self.variables.insert(name.clone(), value);
}
None => {
self.variables.remove(name);
}
}
result
}
Expr::Sequence { nodes } => {
let mut result = Value::Null;
for node in nodes {
result = self.eval(node).await?;
}
Ok(result)
}
Expr::Array { nodes } => {
let mut values = Vec::with_capacity(nodes.len());
for node in nodes {
values.push(self.eval(node).await?);
}
Ok(Value::Array(values))
}
Expr::Map { pairs } => {
let mut values = BTreeMap::new();
for pair in pairs {
let Expr::Pair { key, value } = pair else {
bail!("map contains a non-pair AST node");
};
let key = self.eval(key).await?.as_string();
let value = self.eval(value).await?;
values.insert(key, value);
}
Ok(Value::Object(values))
}
Expr::Pair { .. } => bail!("pair evaluated outside a map"),
Expr::Pointer { .. } => Ok(self.pointer.clone().unwrap_or(Value::Null)),
}
})
}
async fn eval_binary(
&mut self,
operator: &str,
left: &BetterleaksExpr,
right: &BetterleaksExpr,
) -> Result<Value> {
let left = self.eval(left).await?;
match operator {
"&&" if !left.truthy() => return Ok(Value::Bool(false)),
"||" if left.truthy() => return Ok(Value::Bool(true)),
"??" if !matches!(left, Value::Null) => return Ok(left),
_ => {}
}
let right = self.eval(right).await?;
match operator {
"&&" => Ok(Value::Bool(right.truthy())),
"||" => Ok(Value::Bool(right.truthy())),
"??" => Ok(right),
"+" => match (&left, &right) {
(Value::Integer(a), Value::Integer(b)) => Ok(Value::Integer(a + b)),
_ => Ok(Value::String(left.as_string() + &right.as_string())),
},
"==" => Ok(Value::Bool(values_equal(&left, &right))),
"!=" => Ok(Value::Bool(!values_equal(&left, &right))),
">" => Ok(Value::Bool(
left.as_f64().unwrap_or_default() > right.as_f64().unwrap_or_default(),
)),
"in" => Ok(Value::Bool(value_in(&left, &right))),
"contains" => Ok(Value::Bool(value_contains(&left, &right))),
"startsWith" => Ok(Value::Bool(left.as_string().starts_with(&right.as_string()))),
other => bail!("unsupported binary operator {other}"),
}
}
async fn eval_call(
&mut self,
callee: &BetterleaksExpr,
arguments: &[BetterleaksExpr],
) -> Result<Value> {
let name = static_name(callee).unwrap_or_default();
if let BetterleaksExpr::Member { node, property, method: true, .. } = callee
&& static_property(property)
.is_some_and(|method| matches!(method.as_str(), "contains" | "split"))
{
let method = static_property(property).unwrap_or_default();
let receiver = self.eval(node).await?;
let args = self.eval_arguments(arguments).await?;
return match method.as_str() {
"contains" => Ok(Value::Bool(
args.first().is_some_and(|needle| value_contains(&receiver, needle)),
)),
"split" => {
let separator = args.first().map(Value::as_string).unwrap_or_default();
Ok(Value::Array(
receiver
.as_string()
.split(&separator)
.map(|part| Value::String(part.to_string()))
.collect(),
))
}
other => bail!("unsupported Betterleaks method {other}"),
};
}
let args = self.eval_arguments(arguments).await?;
match name.as_str() {
"http.get" => self.http_request(Method::GET, &args).await,
"http.post" => self.http_request(Method::POST, &args).await,
"validate.unknown" => Ok(unknown_result(args.first().cloned().unwrap_or(Value::Null))),
"bytes" => Ok(Value::Bytes(args.first().map(Value::as_bytes).unwrap_or_default())),
"size" | "len" => Ok(Value::Integer(value_size(args.first()))),
"string" => Ok(Value::String(args.first().map(Value::as_string).unwrap_or_default())),
"int" => Ok(Value::Integer(
args.first().and_then(Value::as_i64).context("int: expected a number")?,
)),
"type" => Ok(Value::String(
match args.first() {
None | Some(Value::Null) => "nil",
Some(Value::Bool(_)) => "bool",
Some(Value::Integer(_)) => "int",
Some(Value::Float(_)) => "float",
Some(Value::String(_)) => "string",
Some(Value::Array(_) | Value::Bytes(_)) => "array",
Some(Value::Object(_)) => "map",
}
.to_string(),
)),
"toJSON" => Ok(Value::String(serde_json::to_string(
&args.first().unwrap_or(&Value::Null).to_json(),
)?)),
"trim" => Ok(Value::String(
args.first().map(Value::as_string).unwrap_or_default().trim().to_string(),
)),
"split" | "strings.splitTrim" => {
let input = args.first().map(Value::as_string).unwrap_or_default();
let separator = args.get(1).map(Value::as_string).unwrap_or_default();
if name == "strings.splitTrim" && separator.is_empty() {
bail!("strings.splitTrim: separator must not be empty");
}
let parts = if separator.is_empty() {
input.chars().map(|ch| ch.to_string()).collect::<Vec<_>>()
} else {
input.split(&separator).map(str::to_string).collect()
};
Ok(Value::Array(
parts
.into_iter()
.filter_map(|part| {
if name == "strings.splitTrim" {
let part = part.trim();
(!part.is_empty()).then(|| Value::String(part.to_string()))
} else {
Some(Value::String(part))
}
})
.collect(),
))
}
"substring" => {
let value = args.first().map(Value::as_string).unwrap_or_default();
let start = args.get(1).and_then(Value::as_i64).unwrap_or_default().max(0) as usize;
Ok(Value::String(value.chars().skip(start).collect()))
}
"base64.encode" => Ok(Value::String(
BASE64.encode(args.first().map(Value::as_bytes).unwrap_or_default()),
)),
"base64.decode" => Ok(Value::Bytes(
BASE64
.decode(args.first().map(Value::as_string).unwrap_or_default())
.context("base64.decode")?,
)),
"hex.encode" => Ok(Value::String(hex::encode(
args.first().map(Value::as_bytes).unwrap_or_default(),
))),
"crypto.sha1" => {
let bytes = args.first().map(Value::as_bytes).unwrap_or_default();
Ok(Value::Bytes(Sha1::digest(bytes).to_vec()))
}
"crypto.hmacSha1" => Ok(Value::Bytes(hmac_sha1(&args)?)),
"crypto.hmacSha256" => Ok(Value::Bytes(hmac_sha256(&args)?)),
"strings.urlQueryEscape" => Ok(Value::String(query_escape(
&args.first().map(Value::as_string).unwrap_or_default(),
))),
"json.string" => Ok(Value::String(serde_json::to_string(
&args.first().map(Value::as_string).unwrap_or_default(),
)?)),
"time.nowUnix" => Ok(Value::String(unix_timestamp().to_string())),
"time.nowRFC3339" => Ok(Value::String(
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
)),
"env.getOrDefault" => {
let name = args.first().map(Value::as_string).unwrap_or_default();
let fallback = args.get(1).map(Value::as_string).unwrap_or_default();
Ok(Value::String(
self.environment
.get(&name)
.cloned()
.or_else(|| std::env::var(&name).ok())
.unwrap_or(fallback),
))
}
"filter.matchesAny" | "matchesAny" => matches_any(&args),
"aws.validate" => self.aws_validate(&args).await,
"gcp.validate" => self.gcp_validate(&args).await,
"azure.validateStorage" => self.azure_validate_storage(&args).await,
"azure.validateServicePrincipal" => self.azure_validate_service_principal(&args).await,
"azure.validateAppConfig" => self.azure_validate_app_config(&args).await,
"azure.validateServiceBusSAS" => self.azure_validate_service_bus(&args).await,
other => bail!("unsupported Betterleaks function {other}"),
}
}
async fn eval_arguments(&mut self, arguments: &[BetterleaksExpr]) -> Result<Vec<Value>> {
let mut args = Vec::with_capacity(arguments.len());
for argument in arguments {
args.push(self.eval(argument).await?);
}
Ok(args)
}
async fn eval_builtin(&mut self, name: &str, arguments: &[BetterleaksExpr]) -> Result<Value> {
if matches!(name, "any" | "all" | "filter") {
let [input, predicate] = arguments else {
bail!("{name}: expected an array and predicate");
};
let values = self.eval(input).await?;
let Value::Array(values) = values else {
bail!("{name}: expected an array");
};
let mut filtered = Vec::new();
for value in values {
let previous = self.pointer.replace(value.clone());
let result = self.eval(predicate).await;
self.pointer = previous;
let matches = result?.truthy();
if name == "any" && matches {
return Ok(Value::Bool(true));
}
if name == "all" && !matches {
return Ok(Value::Bool(false));
}
if name == "filter" && matches {
filtered.push(value);
}
}
return Ok(if name == "filter" {
Value::Array(filtered)
} else {
Value::Bool(name == "all")
});
}
let args = self.eval_arguments(arguments).await?;
match name {
"replace" => {
let input = args.first().map(Value::as_string).unwrap_or_default();
let from = args.get(1).map(Value::as_string).unwrap_or_default();
let to = args.get(2).map(Value::as_string).unwrap_or_default();
Ok(Value::String(input.replace(&from, &to)))
}
"lastIndexOf" => {
let input = args.first().map(Value::as_string).unwrap_or_default();
let needle = args.get(1).map(Value::as_string).unwrap_or_default();
Ok(Value::Integer(
input.rfind(&needle).and_then(|index| i64::try_from(index).ok()).unwrap_or(-1),
))
}
other => bail!("unsupported Betterleaks builtin {other}"),
}
}
async fn http_request(&mut self, method: Method, args: &[Value]) -> Result<Value> {
let raw_url = args.first().map(Value::as_string).unwrap_or_default();
let url = Url::parse(&raw_url)
.map_err(|_| anyhow!("Betterleaks validation produced an invalid URL"))?;
crate::validation::check_url_resolvable(&url, self.allow_internal_ips)
.await
.map_err(|_| anyhow!("Betterleaks validation URL was blocked"))?;
let mut request = self.client.request(method, url.clone());
if let Some(Value::Object(headers)) = args.get(1) {
for (name, value) in headers {
request = request.header(name, value.as_string());
}
}
if let Some(body) = args.get(2) {
request = request.body(body.as_string());
}
let response =
request.send().await.map_err(|_| anyhow!("Betterleaks validation request failed"))?;
if response.url() != &url {
bail!("Betterleaks validation request was redirected");
}
let status = response.status();
let headers = response.headers().clone();
let mut response = response;
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await? {
if !super::limits::NetworkLimits::current().unlimited_response
&& body.len().saturating_add(chunk.len()) > MAX_RESPONSE_BODY
{
bail!("Betterleaks validation response exceeded size limit");
}
body.extend_from_slice(&chunk);
}
let body = String::from_utf8_lossy(&body).into_owned();
let json = serde_json::from_str(&body)
.map(Value::from_json)
.unwrap_or_else(|_| Value::Object(BTreeMap::new()));
let headers = headers
.iter()
.map(|(name, value)| {
(
name.as_str().to_ascii_lowercase(),
Value::String(value.to_str().unwrap_or_default().to_string()),
)
})
.collect();
self.last_status = Some(status);
Ok(Value::Object(BTreeMap::from([
("status".to_string(), Value::Integer(i64::from(status.as_u16()))),
("body".to_string(), Value::String(body)),
("json".to_string(), json),
("headers".to_string(), Value::Object(headers)),
])))
}
async fn aws_validate(&mut self, args: &[Value]) -> Result<Value> {
let access_key = args.first().map(Value::as_string).unwrap_or_default();
let secret_key = args.get(1).map(Value::as_string).unwrap_or_default();
let result =
crate::validation::aws::validate_aws_credential_pair(&access_key, &secret_key, None)
.await;
let mut response = BTreeMap::new();
self.last_status = Some(result.status);
response.insert("status".to_string(), Value::Integer(i64::from(result.status.as_u16())));
if result.outcome == ValidationOutcome::Skipped {
response.insert("skipped".to_string(), Value::Bool(true));
}
if let Some(identity) = result.identity {
response.insert("arn".to_string(), Value::String(identity));
} else if !result.message.is_empty() {
response.insert("error_message".to_string(), Value::String(result.message));
}
if let Some(account_id) = result.account_id {
response.insert("account".to_string(), Value::String(account_id));
}
Ok(Value::Object(response))
}
async fn gcp_validate(&mut self, args: &[Value]) -> Result<Value> {
let credential_json = args.first().map(Value::as_string).unwrap_or_default();
let parsed: JsonValue =
serde_json::from_str(&credential_json).context("GCP credential is not valid JSON")?;
let credential_type = parsed["type"].as_str().unwrap_or_default();
let mut response = BTreeMap::from([(
"credential_type".to_string(),
Value::String(credential_type.to_string()),
)]);
match credential_type {
"service_account" => {
match crate::validation::gcp::GcpValidator::global()?
.get_access_token_from_sa_json(&credential_json)
.await
{
Ok(context) => {
self.last_status = Some(StatusCode::OK);
response.insert("status".to_string(), Value::Integer(200));
response
.insert("project_id".to_string(), Value::String(context.project_id));
response.insert(
"client_email".to_string(),
Value::String(context.client_email),
);
}
Err(error) => {
self.last_status = Some(StatusCode::BAD_REQUEST);
response.insert("status".to_string(), Value::Integer(400));
response
.insert("error_message".to_string(), Value::String(error.to_string()));
}
}
}
"authorized_user" => {
let token_uri = parsed["token_uri"]
.as_str()
.filter(|value| !value.is_empty())
.unwrap_or("https://oauth2.googleapis.com/token");
if !matches!(
token_uri,
"https://oauth2.googleapis.com/token"
| "https://accounts.google.com/o/oauth2/token"
) {
bail!("GCP token_uri is not an allowed Google OAuth endpoint");
}
let form = [
("grant_type", "refresh_token"),
("client_id", parsed["client_id"].as_str().unwrap_or_default()),
("client_secret", parsed["client_secret"].as_str().unwrap_or_default()),
("refresh_token", parsed["refresh_token"].as_str().unwrap_or_default()),
];
let remote = self
.client
.post(token_uri)
.header("Accept", "application/json")
.form(&form)
.send()
.await
.context("GCP authorized-user validation request failed")?;
let status = remote.status();
let body: JsonValue = remote.json().await.unwrap_or(JsonValue::Null);
self.last_status = Some(status);
response.insert("status".to_string(), Value::Integer(i64::from(status.as_u16())));
response.insert(
"client_id".to_string(),
Value::String(parsed["client_id"].as_str().unwrap_or_default().to_string()),
);
if let Some(error) = body["error"].as_str() {
response.insert("error_code".to_string(), Value::String(error.to_string()));
}
if let Some(message) = body["error_description"].as_str() {
response
.insert("error_message".to_string(), Value::String(message.to_string()));
}
}
_ => {
self.last_status = Some(StatusCode::BAD_REQUEST);
response.insert("status".to_string(), Value::Integer(400));
response.insert(
"error_code".to_string(),
Value::String("invalid_credential_json".to_string()),
);
}
}
Ok(Value::Object(response))
}
async fn azure_validate_storage(&mut self, args: &[Value]) -> Result<Value> {
let account = args.first().map(Value::as_string).unwrap_or_default();
let key = args.get(1).map(Value::as_string).unwrap_or_default();
if account.is_empty() || key.is_empty() {
return Ok(input_error("missing_storage_account_or_key"));
}
let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
let canonical_headers = format!("x-ms-date:{date}\nx-ms-version:2023-11-03\n");
let string_to_sign =
format!("GET\n\n\n\n\n\n\n\n\n\n\n\n{canonical_headers}/{account}/\ncomp:list");
let signature = hmac_base64_key(&key, &string_to_sign)?;
let url = format!("https://{account}.blob.core.windows.net/?comp=list");
let headers = Value::Object(BTreeMap::from([
("x-ms-date".to_string(), Value::String(date)),
("x-ms-version".to_string(), Value::String("2023-11-03".to_string())),
(
"Authorization".to_string(),
Value::String(format!("SharedKey {account}:{signature}")),
),
]));
let result = self.http_request(Method::GET, &[Value::String(url), headers]).await?;
Ok(with_fields(result, [("account", Value::String(account))]))
}
async fn azure_validate_service_principal(&mut self, args: &[Value]) -> Result<Value> {
let tenant_id = args.first().map(Value::as_string).unwrap_or_default();
let client_id = args.get(1).map(Value::as_string).unwrap_or_default();
let client_secret = args.get(2).map(Value::as_string).unwrap_or_default();
if tenant_id.is_empty() || client_id.is_empty() || client_secret.is_empty() {
return Ok(input_error("missing_service_principal_fields"));
}
let tenant_pattern = regex::Regex::new(r"^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$")?;
if !tenant_pattern.is_match(&tenant_id) {
return Ok(input_error("invalid_tenant_id"));
}
let body = url::form_urlencoded::Serializer::new(String::new())
.append_pair("client_id", &client_id)
.append_pair("client_secret", &client_secret)
.append_pair("grant_type", "client_credentials")
.append_pair("scope", "https://management.azure.com/.default")
.finish();
let url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
let headers = Value::Object(BTreeMap::from([
(
"Content-Type".to_string(),
Value::String("application/x-www-form-urlencoded".to_string()),
),
("Accept".to_string(), Value::String("application/json".to_string())),
]));
let result = self
.http_request(Method::POST, &[Value::String(url), headers, Value::String(body)])
.await?;
Ok(with_fields(
result,
[("tenant_id", Value::String(tenant_id)), ("client_id", Value::String(client_id))],
))
}
async fn azure_validate_app_config(&mut self, args: &[Value]) -> Result<Value> {
let endpoint = args
.first()
.map(Value::as_string)
.unwrap_or_default()
.trim_end_matches('/')
.to_string();
let id = args.get(1).map(Value::as_string).unwrap_or_default();
let secret = args.get(2).map(Value::as_string).unwrap_or_default();
if endpoint.is_empty() || id.is_empty() || secret.is_empty() {
return Ok(input_error("missing_app_config_fields"));
}
let parsed = Url::parse(&endpoint).context("invalid App Configuration endpoint")?;
let host =
parsed.host_str().ok_or_else(|| anyhow!("App Configuration endpoint has no host"))?;
let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string();
let content_hash = BASE64.encode(Sha256::digest([]));
let path = "/kv?api-version=1.0";
let string_to_sign = format!("GET\n{path}\n{date};{host};{content_hash}");
let signature = hmac_base64_key(&secret, &string_to_sign)?;
let headers = Value::Object(BTreeMap::from([
("Date".to_string(), Value::String(date)),
("Host".to_string(), Value::String(host.to_string())),
("x-ms-content-sha256".to_string(), Value::String(content_hash)),
(
"Authorization".to_string(),
Value::String(format!(
"HMAC-SHA256 Credential={id}&SignedHeaders=date;host;x-ms-content-sha256&Signature={signature}"
)),
),
]));
let result = self
.http_request(Method::GET, &[Value::String(format!("{endpoint}{path}")), headers])
.await?;
Ok(with_fields(result, [("endpoint", Value::String(endpoint)), ("id", Value::String(id))]))
}
async fn azure_validate_service_bus(&mut self, args: &[Value]) -> Result<Value> {
let connection = args.first().map(Value::as_string).unwrap_or_default();
let fields = connection
.split(';')
.filter_map(|part| part.split_once('='))
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string()))
.collect::<BTreeMap<_, _>>();
let endpoint = fields.get("endpoint").cloned().unwrap_or_default();
let key_name = fields.get("sharedaccesskeyname").cloned().unwrap_or_default();
let key = fields.get("sharedaccesskey").cloned().unwrap_or_default();
if endpoint.is_empty() || key_name.is_empty() || key.is_empty() {
return Ok(input_error("missing_servicebus_connection_fields"));
}
let parsed = Url::parse(&endpoint).context("invalid Service Bus endpoint")?;
let host = parsed.host_str().ok_or_else(|| anyhow!("Service Bus endpoint has no host"))?;
let path =
fields.get("entitypath").map(|value| value.trim_matches('/')).unwrap_or_default();
let (resource, request_url) = if path.is_empty() {
(
format!("https://{host}"),
format!("https://{host}/$Resources/Queues?api-version=2021-05"),
)
} else {
let resource = format!("https://{host}/{}", query_escape(path));
let request_url = format!("{resource}?api-version=2017-04");
(resource, request_url)
};
let expiry = unix_timestamp() + 300;
let encoded_resource = query_escape(&resource).to_ascii_lowercase();
let to_sign = format!("{encoded_resource}\n{expiry}");
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())?;
mac.update(to_sign.as_bytes());
let signature = query_escape(&BASE64.encode(mac.finalize().into_bytes()));
let authorization = format!(
"SharedAccessSignature sr={encoded_resource}&sig={signature}&se={expiry}&skn={}",
query_escape(&key_name)
);
let headers = Value::Object(BTreeMap::from([
("Authorization".to_string(), Value::String(authorization)),
(
"Accept".to_string(),
Value::String("application/atom+xml, application/json".to_string()),
),
]));
let result = self.http_request(Method::GET, &[Value::String(request_url), headers]).await?;
let mut fields = vec![("host", Value::String(host.to_string()))];
if !path.is_empty() {
fields.push(("entity_path", Value::String(path.to_string())));
}
Ok(with_fields(result, fields))
}
}
fn input_error(code: &str) -> Value {
Value::Object(BTreeMap::from([
("status".to_string(), Value::Integer(400)),
("error_code".to_string(), Value::String(code.to_string())),
]))
}
fn with_fields<I>(value: Value, fields: I) -> Value
where
I: IntoIterator<Item = (&'static str, Value)>,
{
let Value::Object(mut values) = value else {
return value;
};
values.extend(fields.into_iter().map(|(key, value)| (key.to_string(), value)));
Value::Object(values)
}
fn hmac_base64_key(base64_key: &str, message: &str) -> Result<String> {
let key = BASE64.decode(base64_key).context("invalid base64 HMAC key")?;
let mut mac = Hmac::<Sha256>::new_from_slice(&key)?;
mac.update(message.as_bytes());
Ok(BASE64.encode(mac.finalize().into_bytes()))
}
fn static_name(expression: &BetterleaksExpr) -> Option<String> {
match expression {
BetterleaksExpr::Identifier { value } => Some(value.clone()),
BetterleaksExpr::Member { node, property, .. } => {
Some(format!("{}.{}", static_name(node)?, static_property(property)?))
}
_ => None,
}
}
fn static_property(expression: &BetterleaksExpr) -> Option<String> {
match expression {
BetterleaksExpr::String { value } | BetterleaksExpr::Identifier { value } => {
Some(value.clone())
}
_ => None,
}
}
fn values_equal(left: &Value, right: &Value) -> bool {
match (left.as_f64(), right.as_f64()) {
(Some(left), Some(right)) => left == right,
_ => left == right,
}
}
fn value_in(needle: &Value, haystack: &Value) -> bool {
match haystack {
Value::Array(values) => values.iter().any(|value| values_equal(needle, value)),
Value::String(value) => value.contains(&needle.as_string()),
_ => false,
}
}
fn value_contains(haystack: &Value, needle: &Value) -> bool {
match haystack {
Value::Array(values) => values.iter().any(|value| values_equal(value, needle)),
Value::String(value) => value.contains(&needle.as_string()),
_ => false,
}
}
fn value_size(value: Option<&Value>) -> i64 {
match value {
Some(Value::String(value)) => value.len() as i64,
Some(Value::Bytes(value)) => value.len() as i64,
Some(Value::Array(value)) => value.len() as i64,
Some(Value::Object(value)) => value.len() as i64,
_ => 0,
}
}
fn unknown_result(response: Value) -> Value {
let status = response.get(&Value::String("status".to_string())).as_i64().unwrap_or_default();
let skipped = response.get(&Value::String("skipped".to_string())).truthy();
let reason = response.get(&Value::String("error_message".to_string())).as_string();
Value::Object(BTreeMap::from([
(
"result".to_string(),
Value::String(if skipped { "skipped" } else { "unknown" }.to_string()),
),
(
"reason".to_string(),
Value::String(if skipped {
reason
} else if status == 429 {
"rate limited".to_string()
} else {
format!("HTTP {status}")
}),
),
]))
}
fn hmac_sha1(args: &[Value]) -> Result<Vec<u8>> {
let mut mac =
Hmac::<Sha1>::new_from_slice(&args.first().map(Value::as_bytes).unwrap_or_default())
.context("HMAC-SHA1 key")?;
mac.update(&args.get(1).map(Value::as_bytes).unwrap_or_default());
Ok(mac.finalize().into_bytes().to_vec())
}
fn hmac_sha256(args: &[Value]) -> Result<Vec<u8>> {
let mut mac =
Hmac::<Sha256>::new_from_slice(&args.first().map(Value::as_bytes).unwrap_or_default())
.context("HMAC-SHA256 key")?;
mac.update(&args.get(1).map(Value::as_bytes).unwrap_or_default());
Ok(mac.finalize().into_bytes().to_vec())
}
fn query_escape(value: &str) -> String {
let encoded =
url::form_urlencoded::Serializer::new(String::new()).append_pair("", value).finish();
encoded.strip_prefix('=').unwrap_or(&encoded).to_string()
}
fn unix_timestamp() -> i64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or_default()
}
fn matches_any(args: &[Value]) -> Result<Value> {
let input = args.first().map(Value::as_string).unwrap_or_default();
let patterns = match args.get(1) {
Some(Value::Array(patterns)) => patterns,
_ => return Ok(Value::Bool(false)),
};
for pattern in patterns {
if regex::Regex::new(&pattern.as_string())?.is_match(&input) {
return Ok(Value::Bool(true));
}
}
Ok(Value::Bool(false))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, response::Redirect, routing::get};
use kingfisher_rules::{Validation, get_betterleaks_rules};
use liquid_core::Value as LiquidValue;
use std::collections::BTreeSet;
#[test]
fn generated_snapshot_uses_only_supported_expression_operations() {
let rules = get_betterleaks_rules(None).unwrap();
let mut validations = 0;
let mut calls = BTreeSet::new();
let mut builtins = BTreeSet::new();
let mut binary = BTreeSet::new();
let mut unary = BTreeSet::new();
for rule in rules {
let Some(Validation::Betterleaks(validation)) = rule.validation else {
continue;
};
validations += 1;
collect_operations(
&validation.expression,
&mut calls,
&mut builtins,
&mut binary,
&mut unary,
);
}
assert!(validations > 0);
assert!(
calls.is_subset(&set(&[
"aws.validate",
"azure.validateAppConfig",
"azure.validateServiceBusSAS",
"azure.validateServicePrincipal",
"azure.validateStorage",
"base64.decode",
"base64.encode",
"bytes",
"crypto.hmacSha1",
"crypto.hmacSha256",
"crypto.sha1",
"env.getOrDefault",
"filter.matchesAny",
"gcp.validate",
"hex.encode",
"http.get",
"http.post",
"int",
"json.string",
"len",
"matchesAny",
"member.contains",
"member.split",
"size",
"split",
"string",
"strings.splitTrim",
"strings.urlQueryEscape",
"substring",
"time.nowRFC3339",
"time.nowUnix",
"toJSON",
"trim",
"type",
"validate.unknown",
])),
"unsupported calls: {calls:?}"
);
assert!(builtins.is_subset(&set(&["any", "all", "filter", "lastIndexOf", "replace"])));
assert!(binary.is_subset(&set(&[
"!=",
"&&",
"+",
">",
"==",
"??",
"contains",
"in",
"startsWith",
"||"
])));
assert!(unary.is_subset(&set(&["!", "-", "not"])));
}
#[tokio::test]
async fn cloudflare_v2_validation_routes_tokens_and_requires_active_provider_evidence() {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
let active = serde_json::json!({"success": true, "result": {"id": "mock-token", "status": "active"}});
let cases = [
("cfut_", "", active.clone(), ValidationOutcome::VerifiedActive, 1),
("cfat_", "mock-account", active.clone(), ValidationOutcome::VerifiedActive, 1),
("cfat_", "", active.clone(), ValidationOutcome::Unavailable, 0),
(
"cfut_",
"",
serde_json::json!({"success": false, "result": {"id": "mock-token", "status": "active"}}),
ValidationOutcome::Unavailable,
1,
),
(
"cfut_",
"",
serde_json::json!({"success": true, "result": {"status": "active"}}),
ValidationOutcome::Unavailable,
1,
),
(
"cfut_",
"",
serde_json::json!({"success": true, "result": {"id": "mock-token", "status": "expired"}}),
ValidationOutcome::VerifiedInactive,
1,
),
(
"cfut_",
"",
serde_json::json!({"success": true, "result": {"id": "mock-token", "status": "disabled"}}),
ValidationOutcome::VerifiedInactive,
1,
),
];
let rule = get_betterleaks_rules(None)
.unwrap()
.into_iter()
.find(|rule| rule.id == "betterleaks.cloudflare-api-key.2")
.unwrap();
let Some(Validation::Betterleaks(validation)) = rule.validation else {
panic!("missing Cloudflare validation")
};
for (prefix, account, response, expected, requests) in cases {
let calls = Arc::new(AtomicUsize::new(0));
let observed = calls.clone();
let route = if prefix == "cfat_" {
"/accounts/mock-account/tokens/verify"
} else {
"/user/tokens/verify"
};
let app = Router::new().route(
route,
get(move || {
let response = response.clone();
let observed = observed.clone();
async move {
observed.fetch_add(1, Ordering::SeqCst);
response.to_string()
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let mut validation = validation.clone();
replace_string_literal(
&mut validation.expression,
"https://api.cloudflare.com/client/v4",
&format!("http://{address}"),
);
let mut globals = LiquidObject::new();
if !account.is_empty() {
globals.insert("CLOUDFLARE_ACCOUNT_ID_1".into(), LiquidValue::scalar(account));
}
let token = format!("{prefix}{}deadbeef", "Ab3DeF9gHk".repeat(4));
let outcome = validate(
&validation,
&[("TOKEN".to_string(), token.clone(), 0, token.len())],
&globals,
&Client::new(),
true,
)
.await;
server.abort();
assert_eq!(outcome.outcome, expected, "{prefix}/{account}: {}", outcome.body);
assert_eq!(calls.load(Ordering::SeqCst), requests);
assert_eq!(outcome.valid, expected == ValidationOutcome::VerifiedActive);
}
}
#[tokio::test]
async fn v2_response_helpers_preserve_types_and_predicate_scope() {
fn call(name: &str, arguments: Vec<BetterleaksExpr>) -> BetterleaksExpr {
BetterleaksExpr::Call {
callee: Box::new(BetterleaksExpr::Identifier { value: name.to_string() }),
arguments,
}
}
fn string(value: &str) -> BetterleaksExpr {
BetterleaksExpr::String { value: value.to_string() }
}
let mut evaluator = Evaluator {
client: &Client::new(),
allow_internal_ips: false,
environment: BTreeMap::new(),
variables: BTreeMap::new(),
pointer: Some(Value::String("outer".to_string())),
last_status: None,
};
let array = BetterleaksExpr::Array {
nodes: vec![string("read"), BetterleaksExpr::Integer { value: 42 }, string("write")],
};
let predicate = BetterleaksExpr::Binary {
operator: "==".to_string(),
left: Box::new(call("type", vec![BetterleaksExpr::Pointer { name: "#".to_string() }])),
right: Box::new(string("string")),
};
let filtered = BetterleaksExpr::Builtin {
name: "filter".to_string(),
arguments: vec![array.clone(), predicate.clone()],
};
assert_eq!(
evaluator.eval(&filtered).await.unwrap(),
Value::Array(vec![
Value::String("read".to_string()),
Value::String("write".to_string())
])
);
assert_eq!(evaluator.pointer, Some(Value::String("outer".to_string())));
assert_eq!(
evaluator
.eval(&BetterleaksExpr::Builtin {
name: "all".to_string(),
arguments: vec![array, predicate.clone()]
})
.await
.unwrap(),
Value::Bool(false)
);
assert_eq!(
evaluator
.eval(&BetterleaksExpr::Builtin {
name: "all".to_string(),
arguments: vec![filtered.clone(), predicate]
})
.await
.unwrap(),
Value::Bool(true)
);
for (expression, expected) in [
(call("len", vec![filtered]), Value::Integer(2)),
(
call(
"string",
vec![call("int", vec![BetterleaksExpr::Float { value: "123.0".to_string() }])],
),
Value::String("123".to_string()),
),
(call("trim", vec![string(" mock user ")]), Value::String("mock user".to_string())),
(call("toJSON", vec![string("a\"b")]), Value::String("\"a\\\"b\"".to_string())),
] {
assert_eq!(evaluator.eval(&expression).await.unwrap(), expected);
}
let namespace_call = BetterleaksExpr::Call {
callee: Box::new(BetterleaksExpr::Member {
node: Box::new(BetterleaksExpr::Identifier { value: "strings".to_string() }),
property: Box::new(string("splitTrim")),
optional: false,
method: true,
}),
arguments: vec![string(" read, ,write ,"), string(",")],
};
assert_eq!(
evaluator.eval(&namespace_call).await.unwrap(),
Value::Array(vec![
Value::String("read".to_string()),
Value::String("write".to_string())
])
);
assert!(evaluator.eval(&call("int", vec![string("not-a-number")])).await.is_err());
}
#[tokio::test]
async fn namespace_calls_are_not_mistaken_for_string_methods() {
let expression: BetterleaksExpr = serde_json::from_value(serde_json::json!({
"kind": "call",
"callee": {
"kind": "member",
"node": { "kind": "identifier", "value": "json" },
"property": { "kind": "string", "value": "string" },
"optional": false,
"method": true
},
"arguments": [{ "kind": "string", "value": "a\"b" }]
}))
.unwrap();
let mut evaluator = Evaluator {
client: &Client::new(),
allow_internal_ips: false,
environment: BTreeMap::new(),
variables: BTreeMap::new(),
pointer: None,
last_status: None,
};
assert_eq!(
evaluator.eval(&expression).await.unwrap(),
Value::String("\"a\\\"b\"".to_string())
);
}
#[tokio::test]
async fn generated_http_validation_executes_against_a_mock_provider() {
let app = Router::new().route("/v0/meta/whoami", get(|| async { "{\"id\":\"usrMock\"}" }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let rule = get_betterleaks_rules(None)
.unwrap()
.into_iter()
.find(|rule| rule.id == "betterleaks.airtable-oauth-token")
.unwrap();
let Some(Validation::Betterleaks(mut validation)) = rule.validation else {
panic!("expected Betterleaks validation");
};
replace_string_literal(
&mut validation.expression,
"https://api.airtable.com/v0/meta/whoami",
&format!("http://{address}/v0/meta/whoami"),
);
let token = "mock-airtable-token".to_string();
let outcome = validate(
&validation,
&[("TOKEN".to_string(), token.clone(), 0, token.len())],
&LiquidObject::new(),
&Client::new(),
true,
)
.await;
server.abort();
assert!(outcome.valid, "{}", outcome.body);
assert_eq!(outcome.status, StatusCode::OK);
assert_eq!(outcome.outcome, ValidationOutcome::VerifiedActive);
}
#[tokio::test]
async fn redirected_http_validation_cannot_turn_a_landing_page_into_valid_credentials() {
let app = Router::new()
.route("/validate", get(|| async { Redirect::temporary("/landing") }))
.route("/landing", get(|| async { "generic landing page" }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let rule = get_betterleaks_rules(None)
.unwrap()
.into_iter()
.find(|rule| rule.id == "betterleaks.airtable-oauth-token")
.unwrap();
let Some(Validation::Betterleaks(mut validation)) = rule.validation else {
panic!("expected Betterleaks validation");
};
replace_string_literal(
&mut validation.expression,
"https://api.airtable.com/v0/meta/whoami",
&format!("http://{address}/validate"),
);
let token = "mock-airtable-token".to_string();
let outcome = validate(
&validation,
&[("TOKEN".to_string(), token.clone(), 0, token.len())],
&LiquidObject::new(),
&Client::new(),
true,
)
.await;
server.abort();
assert!(!outcome.valid);
assert_eq!(outcome.outcome, ValidationOutcome::Unavailable);
assert!(outcome.body.contains("request was redirected"), "{}", outcome.body);
}
#[tokio::test]
async fn aws_validation_preserves_the_shared_canary_skip_list() {
let rule = get_betterleaks_rules(None)
.unwrap()
.into_iter()
.find(|rule| rule.id == "betterleaks.aws-access-token")
.unwrap();
let Some(Validation::Betterleaks(validation)) = rule.validation else {
panic!("expected Betterleaks validation");
};
let secret_key = "not-a-real-secret-key";
let mut globals = LiquidObject::new();
globals.insert("AWS_SECRET_ACCESS_KEY".into(), LiquidValue::scalar(secret_key));
let access_key = "AKIAXYZDQCEN4B6JSJQI";
let outcome = validate(
&validation,
&[("TOKEN".to_string(), access_key.to_string(), 0, access_key.len())],
&globals,
&Client::new(),
true,
)
.await;
assert_eq!(outcome.outcome, ValidationOutcome::Skipped);
assert_eq!(outcome.status, StatusCode::PRECONDITION_REQUIRED);
assert!(outcome.body.contains("(skip list entry)"), "{}", outcome.body);
}
#[test]
fn ebay_validation_uses_the_pinned_upstream_validation_endpoint() {
let rule = get_betterleaks_rules(None)
.unwrap()
.into_iter()
.find(|rule| rule.id == "betterleaks.ebay-client-secret")
.unwrap();
let Some(Validation::Betterleaks(validation)) = rule.validation else {
panic!("expected Betterleaks validation");
};
let expression = serde_json::to_string(&validation.expression).unwrap();
assert!(expression.contains("/identity/v1/oauth2/token/introspect"));
assert!(expression.contains("token=betterleaks-validation-token"));
}
#[test]
fn unknown_results_remain_inconclusive() {
let value = Value::Object(BTreeMap::from([(
"result".to_string(),
Value::String("unknown".to_string()),
)]));
let outcome = classify(value, Some(StatusCode::NOT_FOUND));
assert!(!outcome.valid);
assert_eq!(outcome.status, StatusCode::BAD_GATEWAY);
assert_eq!(outcome.outcome, ValidationOutcome::Unavailable);
}
#[test]
fn active_needs_validation_results_are_reported_as_active() {
let value = Value::Object(BTreeMap::from([
("result".to_string(), Value::String("needs_validation".to_string())),
("active_google_key".to_string(), Value::Bool(true)),
]));
let outcome = classify(value, Some(StatusCode::FORBIDDEN));
assert!(outcome.valid);
assert_eq!(outcome.outcome, ValidationOutcome::VerifiedActive);
assert_eq!(outcome.status, StatusCode::FORBIDDEN);
}
#[test]
fn needs_validation_without_active_marker_remains_inconclusive() {
let value = Value::Object(BTreeMap::from([(
"result".to_string(),
Value::String("needs_validation".to_string()),
)]));
let outcome = classify(value, Some(StatusCode::FORBIDDEN));
assert!(!outcome.valid);
assert_eq!(outcome.outcome, ValidationOutcome::Unavailable);
}
fn set(values: &[&str]) -> BTreeSet<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn replace_string_literal(expression: &mut BetterleaksExpr, from: &str, to: &str) {
use BetterleaksExpr as Expr;
match expression {
Expr::String { value } if value == from => *value = to.to_string(),
Expr::Unary { node, .. } | Expr::Chain { node } | Expr::Predicate { node } => {
replace_string_literal(node, from, to)
}
Expr::Binary { left, right, .. } => {
replace_string_literal(left, from, to);
replace_string_literal(right, from, to);
}
Expr::Member { node, property, .. } => {
replace_string_literal(node, from, to);
replace_string_literal(property, from, to);
}
Expr::Slice { node, from: start, to: end } => {
replace_string_literal(node, from, to);
replace_string_literal(start, from, to);
replace_string_literal(end, from, to);
}
Expr::Call { callee, arguments } => {
replace_string_literal(callee, from, to);
for argument in arguments {
replace_string_literal(argument, from, to);
}
}
Expr::Builtin { arguments, .. }
| Expr::Sequence { nodes: arguments }
| Expr::Array { nodes: arguments }
| Expr::Map { pairs: arguments } => {
for argument in arguments {
replace_string_literal(argument, from, to);
}
}
Expr::Conditional { cond, exp1, exp2 } => {
replace_string_literal(cond, from, to);
replace_string_literal(exp1, from, to);
replace_string_literal(exp2, from, to);
}
Expr::VariableDeclarator { value, expr, .. } => {
replace_string_literal(value, from, to);
replace_string_literal(expr, from, to);
}
Expr::Pair { key, value } => {
replace_string_literal(key, from, to);
replace_string_literal(value, from, to);
}
Expr::Nil
| Expr::Identifier { .. }
| Expr::Integer { .. }
| Expr::Float { .. }
| Expr::Bool { .. }
| Expr::String { .. }
| Expr::Pointer { .. } => {}
}
}
fn collect_operations(
expression: &BetterleaksExpr,
calls: &mut BTreeSet<String>,
builtins: &mut BTreeSet<String>,
binary: &mut BTreeSet<String>,
unary: &mut BTreeSet<String>,
) {
use BetterleaksExpr as Expr;
match expression {
Expr::Unary { operator, node } => {
unary.insert(operator.clone());
collect_operations(node, calls, builtins, binary, unary);
}
Expr::Binary { operator, left, right } => {
binary.insert(operator.clone());
collect_operations(left, calls, builtins, binary, unary);
collect_operations(right, calls, builtins, binary, unary);
}
Expr::Chain { node } | Expr::Predicate { node } => {
collect_operations(node, calls, builtins, binary, unary);
}
Expr::Member { node, property, .. } => {
collect_operations(node, calls, builtins, binary, unary);
collect_operations(property, calls, builtins, binary, unary);
}
Expr::Slice { node, from, to } => {
collect_operations(node, calls, builtins, binary, unary);
collect_operations(from, calls, builtins, binary, unary);
collect_operations(to, calls, builtins, binary, unary);
}
Expr::Call { callee, arguments } => {
let name = if let Expr::Member { property, .. } = callee.as_ref()
&& static_property(property)
.is_some_and(|method| matches!(method.as_str(), "contains" | "split"))
{
match static_property(property).as_deref() {
Some("contains") => "member.contains".to_string(),
Some("split") => "member.split".to_string(),
_ => unreachable!(),
}
} else {
static_name(callee).unwrap()
};
calls.insert(name);
collect_operations(callee, calls, builtins, binary, unary);
for argument in arguments {
collect_operations(argument, calls, builtins, binary, unary);
}
}
Expr::Builtin { name, arguments } => {
builtins.insert(name.clone());
for argument in arguments {
collect_operations(argument, calls, builtins, binary, unary);
}
}
Expr::Conditional { cond, exp1, exp2 } => {
collect_operations(cond, calls, builtins, binary, unary);
collect_operations(exp1, calls, builtins, binary, unary);
collect_operations(exp2, calls, builtins, binary, unary);
}
Expr::VariableDeclarator { value, expr, .. } => {
collect_operations(value, calls, builtins, binary, unary);
collect_operations(expr, calls, builtins, binary, unary);
}
Expr::Sequence { nodes } | Expr::Array { nodes } => {
for node in nodes {
collect_operations(node, calls, builtins, binary, unary);
}
}
Expr::Map { pairs } => {
for pair in pairs {
collect_operations(pair, calls, builtins, binary, unary);
}
}
Expr::Pair { key, value } => {
collect_operations(key, calls, builtins, binary, unary);
collect_operations(value, calls, builtins, binary, unary);
}
Expr::Nil
| Expr::Identifier { .. }
| Expr::Integer { .. }
| Expr::Float { .. }
| Expr::Bool { .. }
| Expr::String { .. }
| Expr::Pointer { .. } => {}
}
}
}