forked from mirrors/kingfisher
442 lines
15 KiB
Rust
442 lines
15 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
|
|
}
|
|
}
|
|
|
|
/// How much per-finding detail to include in alert payloads.
|
|
///
|
|
/// `Auto` switches to `Summary` once the per-sink filtered finding count
|
|
/// exceeds [`AUTO_DETAIL_THRESHOLD`] — at that volume, chat detail blocks add
|
|
/// noise without being actionable, and the operator should be pivoting to the
|
|
/// full report (see `--alert-report-url`).
|
|
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
|
|
#[serde(rename_all = "lowercase")]
|
|
#[clap(rename_all = "lowercase")]
|
|
pub enum AlertDetail {
|
|
/// Headline + top-rules + report link only. No per-finding lines.
|
|
Summary,
|
|
/// Headline + top-rules + per-finding lines (capped at 10).
|
|
Detail,
|
|
/// `Detail` if filtered findings ≤ [`AUTO_DETAIL_THRESHOLD`], else `Summary`.
|
|
#[default]
|
|
Auto,
|
|
}
|
|
|
|
/// Auto-mode threshold: if a sink's filtered finding count exceeds this, the
|
|
/// payload drops the per-finding block and points at the full report instead.
|
|
pub const AUTO_DETAIL_THRESHOLD: usize = 25;
|
|
|
|
/// 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,
|
|
/// Pivot link rendered in the payload — typically the URL of the full
|
|
/// report artifact (CI run, S3 object, SARIF in Code Scanning, etc).
|
|
/// `None` omits the link from the payload.
|
|
pub report_url: Option<String>,
|
|
/// How much per-finding detail to include. `Auto` is resolved against the
|
|
/// per-sink filtered finding count at dispatch time before the payload
|
|
/// builder runs, so each `build_payload` only sees `Summary` or `Detail`.
|
|
pub detail: AlertDetail,
|
|
}
|
|
|
|
/// Summary numbers we surface to every sink, regardless of format.
|
|
///
|
|
/// Per-sink fields (`report_url`, `detail`, `filtered_total`) are populated by
|
|
/// `dispatch` immediately before the payload builder runs. They are
|
|
/// intentionally not part of `from_findings` because they are sink-specific.
|
|
#[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>,
|
|
/// Pivot link, copied from the per-sink configuration. `None` → no link
|
|
/// is rendered.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub report_url: Option<String>,
|
|
/// Resolved detail level (`Summary` or `Detail`, never `Auto`).
|
|
pub detail: AlertDetail,
|
|
/// Count of findings the per-sink min-confidence filter let through. May
|
|
/// be smaller than `total` when the sink raises `min_confidence` above the
|
|
/// scan default.
|
|
pub filtered_total: usize,
|
|
}
|
|
|
|
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,
|
|
report_url: None,
|
|
// Placeholder; `dispatch` overwrites this per-sink with a resolved
|
|
// value (`Summary` or `Detail`) before calling `build_payload`.
|
|
detail: AlertDetail::Detail,
|
|
filtered_total: findings.len(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 base_summary = AlertSummary::from_findings(findings, target);
|
|
debug!(
|
|
"alert dispatch: total={} active={} inactive={} unknown={} sinks={}",
|
|
base_summary.total,
|
|
base_summary.active,
|
|
base_summary.inactive,
|
|
base_summary.unknown,
|
|
sinks.len()
|
|
);
|
|
|
|
for sink in sinks {
|
|
if matches!(sink.on, AlertOn::Findings) && base_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();
|
|
|
|
// Per-sink summary: clone the base, overlay sink-specific fields, and
|
|
// resolve `Auto` based on this sink's filtered count.
|
|
let resolved_detail = match sink.detail {
|
|
AlertDetail::Auto => {
|
|
if filtered.len() > AUTO_DETAIL_THRESHOLD {
|
|
AlertDetail::Summary
|
|
} else {
|
|
AlertDetail::Detail
|
|
}
|
|
}
|
|
other => other,
|
|
};
|
|
let mut summary = base_summary.clone();
|
|
summary.report_url = sink.report_url.clone();
|
|
summary.detail = resolved_detail;
|
|
summary.filtered_total = filtered.len();
|
|
|
|
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(())
|
|
}
|
|
|
|
/// Shared test helper: build a fully-formed `FindingReporterRecord` so payload
|
|
/// builders can be unit-tested against per-finding rendering (fingerprint,
|
|
/// snippet redaction, summary-mode suppression). Test-only; not for runtime
|
|
/// callers.
|
|
#[cfg(test)]
|
|
pub(crate) fn make_test_record(
|
|
rule_id: &str,
|
|
fingerprint: &str,
|
|
) -> crate::reporter::FindingReporterRecord {
|
|
use crate::reporter::{FindingRecordData, FindingReporterRecord, RuleMetadata, ValidationInfo};
|
|
FindingReporterRecord {
|
|
rule: RuleMetadata { name: rule_id.to_string(), id: rule_id.to_string() },
|
|
finding: FindingRecordData {
|
|
snippet: "AKIAEXAMPLE_REDACTED_TOKEN_12345".to_string(),
|
|
fingerprint: fingerprint.to_string(),
|
|
confidence: "Medium".to_string(),
|
|
entropy: "4.5".to_string(),
|
|
validation: ValidationInfo {
|
|
status: "Active Credential".to_string(),
|
|
response: String::new(),
|
|
},
|
|
language: "rust".to_string(),
|
|
line: 42,
|
|
column_start: 10,
|
|
column_end: 50,
|
|
path: "src/foo.rs".to_string(),
|
|
encoding: None,
|
|
git_metadata: None,
|
|
validate_command: None,
|
|
revoke_command: None,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[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
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_detail_threshold_is_inclusive_at_25() {
|
|
// Boundary regression: filtered.len() == THRESHOLD must stay in
|
|
// Detail mode; > THRESHOLD must escalate to Summary.
|
|
assert_eq!(AUTO_DETAIL_THRESHOLD, 25);
|
|
// The resolution itself lives inside `dispatch`; this test pins the
|
|
// constant so any future tuning is intentional.
|
|
}
|
|
}
|