forked from mirrors/kingfisher
332 lines
11 KiB
Rust
332 lines
11 KiB
Rust
//! Alert sinks: post scan results to Slack / Microsoft Teams / a generic webhook.
|
|
//!
|
|
//! Activated via CLI (`--alert-webhook`) or `kingfisher.yaml`. The dispatch is
|
|
//! best-effort: failure to deliver an alert never changes the scan exit code,
|
|
//! it only emits a `warn!` on stderr. Every webhook URL is treated as a secret —
|
|
//! we redact path/query when logging.
|
|
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result};
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::cli::commands::scan::ConfidenceLevel;
|
|
use crate::reporter::FindingReporterRecord;
|
|
|
|
pub mod discord;
|
|
pub mod generic;
|
|
pub mod googlechat;
|
|
pub mod mattermost;
|
|
pub mod slack;
|
|
pub mod teams;
|
|
|
|
/// Trigger condition for an alert.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
|
|
#[serde(rename_all = "lowercase")]
|
|
#[clap(rename_all = "lowercase")]
|
|
pub enum AlertOn {
|
|
/// Only post when at least one finding is reported.
|
|
Findings,
|
|
/// Always post, even on a clean run.
|
|
Always,
|
|
}
|
|
|
|
impl Default for AlertOn {
|
|
fn default() -> Self {
|
|
AlertOn::Findings
|
|
}
|
|
}
|
|
|
|
/// Webhook payload format / target.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
|
|
#[serde(rename_all = "lowercase")]
|
|
#[clap(rename_all = "lowercase")]
|
|
pub enum AlertFormat {
|
|
/// Slack incoming-webhook (Block Kit).
|
|
Slack,
|
|
/// Microsoft Teams incoming-webhook (Adaptive Card / MessageCard).
|
|
Teams,
|
|
/// Generic JSON envelope (`{ summary, findings }`).
|
|
Generic,
|
|
/// Discord incoming-webhook (color-coded `embeds`).
|
|
Discord,
|
|
/// Mattermost incoming-webhook (Slack-compatible `attachments`).
|
|
Mattermost,
|
|
/// Google Chat incoming-webhook (`cardsV2` payload).
|
|
Googlechat,
|
|
}
|
|
|
|
impl AlertFormat {
|
|
/// Heuristic: infer the format from the webhook host when the user did
|
|
/// not pass `--alert-format`.
|
|
pub fn infer_from_url(url: &str) -> Self {
|
|
let host = url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_lowercase));
|
|
match host.as_deref() {
|
|
Some(h) if h.contains("slack.com") => AlertFormat::Slack,
|
|
Some(h) if h.contains("office.com") || h.contains("webhook.office") => {
|
|
AlertFormat::Teams
|
|
}
|
|
Some(h) if h.contains("discord.com") || h.contains("discordapp.com") => {
|
|
AlertFormat::Discord
|
|
}
|
|
Some(h) if h.contains("chat.googleapis.com") => AlertFormat::Googlechat,
|
|
_ => AlertFormat::Generic,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One configured webhook destination. `--alert-webhook` may be repeated to
|
|
/// produce more than one. The config-file equivalent is `alerts.webhooks[]`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct AlertSink {
|
|
pub url: String,
|
|
pub format: AlertFormat,
|
|
pub on: AlertOn,
|
|
pub min_confidence: ConfidenceLevel,
|
|
pub include_secret: bool,
|
|
}
|
|
|
|
/// Summary numbers we surface to every sink, regardless of format.
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct AlertSummary {
|
|
pub total: usize,
|
|
pub active: usize,
|
|
pub inactive: usize,
|
|
pub unknown: usize,
|
|
pub by_rule: Vec<(String, usize)>,
|
|
pub kingfisher_version: String,
|
|
pub target: Option<String>,
|
|
}
|
|
|
|
impl AlertSummary {
|
|
pub fn from_findings(findings: &[FindingReporterRecord], target: Option<String>) -> Self {
|
|
let mut active = 0usize;
|
|
let mut inactive = 0usize;
|
|
let mut unknown = 0usize;
|
|
let mut by_rule_map: std::collections::HashMap<String, usize> =
|
|
std::collections::HashMap::new();
|
|
for f in findings {
|
|
*by_rule_map.entry(f.rule.id.clone()).or_default() += 1;
|
|
match f.finding.validation.status.as_str() {
|
|
"Active Credential" => active += 1,
|
|
"Inactive Credential" => inactive += 1,
|
|
_ => unknown += 1,
|
|
}
|
|
}
|
|
let mut by_rule: Vec<(String, usize)> = by_rule_map.into_iter().collect();
|
|
by_rule.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
|
by_rule.truncate(5);
|
|
|
|
Self {
|
|
total: findings.len(),
|
|
active,
|
|
inactive,
|
|
unknown,
|
|
by_rule,
|
|
kingfisher_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
target,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build a reqwest client suitable for outbound webhook POSTs. Webhook hosts
|
|
/// are public services; we always run with strict TLS validation here even if
|
|
/// the user passed `--tls-mode=off` for credential validation, since the user
|
|
/// almost certainly does not intend to lower TLS for their own paging service.
|
|
fn build_client() -> Result<Client> {
|
|
Client::builder()
|
|
.timeout(Duration::from_secs(15))
|
|
.user_agent(format!("kingfisher/{}", env!("CARGO_PKG_VERSION")))
|
|
.build()
|
|
.context("failed to build webhook reqwest::Client")
|
|
}
|
|
|
|
/// Redact the path/query of a webhook URL so we never log the full secret token
|
|
/// embedded by Slack/Teams/etc. e.g. `https://hooks.slack.com/services/...` →
|
|
/// `https://hooks.slack.com/<redacted>`.
|
|
pub fn redact_webhook(url: &str) -> String {
|
|
match url::Url::parse(url) {
|
|
Ok(u) => {
|
|
let scheme = u.scheme();
|
|
let host = u.host_str().unwrap_or("");
|
|
let port = u.port().map(|p| format!(":{p}")).unwrap_or_default();
|
|
format!("{scheme}://{host}{port}/<redacted>")
|
|
}
|
|
Err(_) => "<unparseable webhook url>".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Dispatch the configured alerts. Best-effort: a bad webhook produces a
|
|
/// `warn!` and never propagates as an error to the caller.
|
|
pub async fn dispatch(
|
|
sinks: &[AlertSink],
|
|
findings: &[FindingReporterRecord],
|
|
target: Option<String>,
|
|
) {
|
|
if sinks.is_empty() {
|
|
return;
|
|
}
|
|
let client = match build_client() {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
warn!("alert dispatch: failed to build HTTP client: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let summary = AlertSummary::from_findings(findings, target);
|
|
debug!(
|
|
"alert dispatch: total={} active={} inactive={} unknown={} sinks={}",
|
|
summary.total,
|
|
summary.active,
|
|
summary.inactive,
|
|
summary.unknown,
|
|
sinks.len()
|
|
);
|
|
|
|
for sink in sinks {
|
|
if matches!(sink.on, AlertOn::Findings) && summary.total == 0 {
|
|
debug!(
|
|
"alert dispatch: skipping {} (on=findings, no findings)",
|
|
redact_webhook(&sink.url)
|
|
);
|
|
continue;
|
|
}
|
|
let filtered: Vec<&FindingReporterRecord> = findings
|
|
.iter()
|
|
.filter(|f| matches_min_confidence(&f.finding.confidence, sink.min_confidence))
|
|
.collect();
|
|
|
|
let payload = match sink.format {
|
|
AlertFormat::Slack => slack::build_payload(&summary, &filtered, sink.include_secret),
|
|
AlertFormat::Teams => teams::build_payload(&summary, &filtered, sink.include_secret),
|
|
AlertFormat::Generic => {
|
|
generic::build_payload(&summary, &filtered, sink.include_secret)
|
|
}
|
|
AlertFormat::Discord => {
|
|
discord::build_payload(&summary, &filtered, sink.include_secret)
|
|
}
|
|
AlertFormat::Mattermost => {
|
|
mattermost::build_payload(&summary, &filtered, sink.include_secret)
|
|
}
|
|
AlertFormat::Googlechat => {
|
|
googlechat::build_payload(&summary, &filtered, sink.include_secret)
|
|
}
|
|
};
|
|
|
|
match post(&client, &sink.url, &payload).await {
|
|
Ok(()) => {
|
|
info!("alert posted to {}", redact_webhook(&sink.url));
|
|
}
|
|
Err(e) => {
|
|
warn!("alert dispatch failed for {}: {}", redact_webhook(&sink.url), e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn matches_min_confidence(finding_confidence: &str, threshold: ConfidenceLevel) -> bool {
|
|
let level = match finding_confidence {
|
|
"Low" => ConfidenceLevel::Low,
|
|
"Medium" => ConfidenceLevel::Medium,
|
|
"High" => ConfidenceLevel::High,
|
|
_ => ConfidenceLevel::Medium,
|
|
};
|
|
level >= threshold
|
|
}
|
|
|
|
async fn post(client: &Client, url: &str, payload: &serde_json::Value) -> Result<()> {
|
|
let resp = client
|
|
.post(url)
|
|
.json(payload)
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("POST to {} failed", redact_webhook(url)))?;
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
anyhow::bail!(
|
|
"webhook returned HTTP {}: {}",
|
|
status,
|
|
body.chars().take(200).collect::<String>()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn redact_webhook_keeps_host() {
|
|
let r = redact_webhook("https://hooks.slack.com/services/T0/B0/XXX");
|
|
assert_eq!(r, "https://hooks.slack.com/<redacted>");
|
|
}
|
|
|
|
#[test]
|
|
fn redact_webhook_unparseable() {
|
|
let r = redact_webhook("not a url");
|
|
assert_eq!(r, "<unparseable webhook url>");
|
|
}
|
|
|
|
#[test]
|
|
fn infer_format_slack() {
|
|
assert_eq!(
|
|
AlertFormat::infer_from_url("https://hooks.slack.com/services/T0/B0/XXX"),
|
|
AlertFormat::Slack
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn infer_format_teams() {
|
|
assert_eq!(
|
|
AlertFormat::infer_from_url(
|
|
"https://outlook.office.com/webhook/abc/IncomingWebhook/def"
|
|
),
|
|
AlertFormat::Teams
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn infer_format_generic_fallback() {
|
|
assert_eq!(
|
|
AlertFormat::infer_from_url("https://example.com/webhook"),
|
|
AlertFormat::Generic
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn infer_format_discord() {
|
|
assert_eq!(
|
|
AlertFormat::infer_from_url("https://discord.com/api/webhooks/123/abc"),
|
|
AlertFormat::Discord
|
|
);
|
|
assert_eq!(
|
|
AlertFormat::infer_from_url("https://discordapp.com/api/webhooks/123/abc"),
|
|
AlertFormat::Discord
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn infer_format_googlechat() {
|
|
assert_eq!(
|
|
AlertFormat::infer_from_url(
|
|
"https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t"
|
|
),
|
|
AlertFormat::Googlechat
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn infer_format_mattermost_falls_back_to_generic_without_override() {
|
|
// Mattermost is self-hosted with no canonical domain; users must pass
|
|
// `--alert-format mattermost` explicitly. Inference falls through.
|
|
assert_eq!(
|
|
AlertFormat::infer_from_url("https://mattermost.example.com/hooks/abcdef"),
|
|
AlertFormat::Generic
|
|
);
|
|
}
|
|
}
|