use super::limits::ResourceTimeout;
use std::{
collections::{BTreeMap, BTreeSet},
fmt,
sync::Arc,
time::Duration,
};
use anyhow::{Result, ensure};
use futures::{StreamExt, stream};
use kingfisher_rules::Validation;
use liquid::{Object, model::Value};
use reqwest::Client;
use tokio::sync::Semaphore;
#[cfg(test)]
use super::engine::text;
use super::{ValidationEngine, ValidationReason, ValidationResult};
use crate::{Finding, ValidationOutcome, scanner::finding_is_within};
#[derive(Clone)]
#[non_exhaustive]
pub struct ValidatedFinding {
pub finding: Finding,
pub outcome: ValidationOutcome,
pub reason: Option<ValidationReason>,
pub http_status: Option<u16>,
}
impl ValidatedFinding {
pub fn into_redacted(mut self) -> Self {
self.finding.secret = "[REDACTED]".into();
for value in self.finding.captures.values_mut() {
*value = "[REDACTED]".into();
}
self
}
}
impl fmt::Debug for ValidatedFinding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ValidatedFinding")
.field("rule_id", &self.finding.rule_id)
.field("outcome", &self.outcome)
.field("reason", &self.reason)
.field("http_status", &self.http_status)
.finish_non_exhaustive()
}
}
impl ValidationResult {
fn attach(self, finding: Finding) -> ValidatedFinding {
ValidatedFinding {
finding,
outcome: self.outcome,
reason: self.reason,
http_status: self.http_status,
}
}
}
#[derive(Clone)]
pub struct Validator {
client: Client,
parser: Arc<liquid::Parser>,
permits: Arc<Semaphore>,
concurrency: usize,
timeout: Duration,
retries: u32,
max_response_bytes: usize,
allow_internal_ips: bool,
variables: BTreeMap<String, String>,
}
pub struct ValidatorBuilder {
client: Option<Client>,
concurrency: usize,
timeout: Duration,
retries: u32,
max_response_bytes: usize,
allow_internal_ips: bool,
variables: BTreeMap<String, String>,
}
impl Default for ValidatorBuilder {
fn default() -> Self {
Self {
client: None,
concurrency: 8,
timeout: Duration::from_secs(10),
retries: 0,
max_response_bytes: 1 << 20,
allow_internal_ips: false,
variables: BTreeMap::new(),
}
}
}
impl ValidatorBuilder {
pub fn client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
pub fn concurrency(mut self, concurrency: usize) -> Self {
self.concurrency = concurrency;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn retries(mut self, retries: u32) -> Self {
self.retries = retries;
self
}
pub fn max_response_bytes(mut self, limit: usize) -> Self {
self.max_response_bytes = limit;
self
}
pub fn allow_internal_ips(mut self, allow: bool) -> Self {
self.allow_internal_ips = allow;
self
}
pub fn variable(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.variables.insert(name.into().to_uppercase(), value.into());
self
}
pub fn build(self) -> Result<Validator> {
ensure!(
self.concurrency > 0 && self.concurrency <= Semaphore::MAX_PERMITS,
"validation concurrency must be between 1 and Semaphore::MAX_PERMITS"
);
let client = match self.client {
Some(client) => client,
None => Client::builder()
.redirect(reqwest::redirect::Policy::none())
.resource_timeout(self.timeout)
.build()?,
};
let parser =
kingfisher_rules::register_liquid_filters(liquid::ParserBuilder::with_stdlib())
.build()?;
Ok(Validator {
client,
parser: Arc::new(parser),
permits: Arc::new(Semaphore::new(self.concurrency)),
concurrency: self.concurrency,
timeout: self.timeout,
retries: self.retries,
max_response_bytes: self.max_response_bytes,
allow_internal_ips: self.allow_internal_ips,
variables: self.variables,
})
}
}
impl Validator {
pub fn builder() -> ValidatorBuilder {
ValidatorBuilder::default()
}
pub async fn validate_finding(&self, finding: &Finding) -> ValidatedFinding {
self.validate_finding_with_context(finding, &[]).await
}
pub async fn validate_finding_with_context(
&self,
finding: &Finding,
context: &[Finding],
) -> ValidatedFinding {
self.check_finding(finding, context).await.attach(finding.clone())
}
async fn check_finding(&self, finding: &Finding, context: &[Finding]) -> ValidationResult {
let work = async {
let Some(validation) = &finding.rule().syntax().validation else {
return ValidationResult::outcome(ValidationOutcome::NotAttempted);
};
if !finding.rule().syntax().is_authoritative() {
return ValidationResult::outcome(ValidationOutcome::NotAttempted)
.reason(ValidationReason::NonAuthoritative);
}
if matches!(validation, Validation::Assumed) {
return ValidationResult::outcome(ValidationOutcome::Assumed);
}
if finding.secret == "[REDACTED]"
|| finding.captures.values().any(|v| v == "[REDACTED]")
{
return ValidationResult::skipped(ValidationReason::RedactedInput);
}
let globals = match self.bind(finding, context) {
Ok(globals) => globals,
Err(reason) => return ValidationResult::skipped(reason),
};
let Ok(_permit) = self.permits.acquire().await else {
return ValidationResult::unavailable(ValidationReason::RequestFailed);
};
ValidationEngine::new(&self.client, &self.parser)
.timeout(self.timeout)
.retries(self.retries)
.max_response_bytes(self.max_response_bytes)
.allow_internal_ips(self.allow_internal_ips)
.validate(finding.rule(), &globals)
.await
};
super::limits::timeout(self.timeout, work)
.await
.unwrap_or_else(|_| ValidationResult::unavailable(ValidationReason::DeadlineExceeded))
}
pub async fn validate_findings(&self, findings: Vec<Finding>) -> Vec<ValidatedFinding> {
let checks: Vec<_> = stream::iter(findings.iter())
.map(|finding| self.check_finding(finding, &findings))
.buffered(self.concurrency)
.collect()
.await;
findings.into_iter().zip(checks).map(|(finding, check)| check.attach(finding)).collect()
}
fn bind(
&self,
finding: &Finding,
context: &[Finding],
) -> std::result::Result<Object, ValidationReason> {
let mut values = self.variables.clone();
if let Some(base) = values.get("GITHUB_API_BASE_URL").cloned() {
values.entry("GITHUB_BASE_URL".into()).or_insert(base);
}
if let Some(base) = values.get("GITHUB_BASE_URL").cloned() {
values.entry("GITHUB_API_BASE_URL".into()).or_insert(base);
}
for (name, value) in [
("GITHUB_API_BASE_URL", "https://api.github.com"),
("GITHUB_BASE_URL", "https://api.github.com"),
("GITHUB_WEB_BASE_URL", "https://github.com"),
("GITLAB_API_BASE_URL", "https://gitlab.com/api/v4"),
("GITEA_API_BASE_URL", "https://gitea.com/api/v1"),
] {
values.entry(name.into()).or_insert_with(|| value.into());
}
let mut captures = BTreeMap::new();
for (name, value) in &finding.captures {
let name = name.to_uppercase();
if name != "TOKEN"
&& captures.insert(name, value.clone()).is_some_and(|previous| previous != *value)
{
return Err(ValidationReason::AmbiguousDependency);
}
}
values.extend(captures);
values.insert("TOKEN".into(), finding.secret.clone());
for dependency in finding.rule().syntax().depends_on_rule.iter().flatten() {
if dependency.variable.eq_ignore_ascii_case("TOKEN") {
continue;
}
let candidates: BTreeSet<&str> = context
.iter()
.filter(|candidate| {
candidate.rule_id == dependency.rule_id
&& candidate.blob_id == finding.blob_id
&& candidate.is_base64_encoded == finding.is_base64_encoded
&& dependency
.within
.as_deref()
.is_none_or(|within| finding_is_within(finding, candidate, within))
})
.map(|candidate| candidate.secret.as_str())
.collect();
if candidates.len() > 1 {
return Err(ValidationReason::AmbiguousDependency);
}
if let Some(value) = candidates.first() {
if *value == "[REDACTED]" {
return Err(ValidationReason::RedactedInput);
}
values.insert(dependency.variable.to_uppercase(), (*value).into());
} else if !dependency.optional {
return Err(ValidationReason::MissingDependency);
}
}
Ok(values.into_iter().map(|(k, v)| (k.into(), Value::scalar(v))).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Rule, RuleSyntax, RulesDatabase, Scanner};
#[test]
fn github_api_override_applies_to_both_template_conventions() {
let scanner = Scanner::new(Arc::new(
RulesDatabase::from_rules(vec![Rule::new(RuleSyntax::new(
"acme.demo",
"Demo",
"(synthetic-token)",
))])
.unwrap(),
));
let finding = scanner.scan_bytes(b"synthetic-token").unwrap().remove(0);
for name in ["GITHUB_BASE_URL", "GITHUB_API_BASE_URL"] {
let validator = Validator::builder()
.variable(name, "https://github.example/api/v3")
.build()
.unwrap();
let globals = validator.bind(&finding, &[]).unwrap();
assert_eq!(text(&globals, "GITHUB_BASE_URL"), "https://github.example/api/v3");
assert_eq!(text(&globals, "GITHUB_API_BASE_URL"), "https://github.example/api/v3");
}
}
}