webhook support and kingfisher configuration yaml support

This commit is contained in:
Mick Grove 2026-05-03 23:10:45 -07:00
commit 0e1fe0cede
15 changed files with 726 additions and 30 deletions

161
src/alerts/discord.rs Normal file
View file

@ -0,0 +1,161 @@
//! Discord incoming-webhook payload (`embeds`).
//!
//! A single embed carries the summary as `fields`, the per-finding detail in
//! the embed `description`, and a footer with the Kingfisher version. The
//! sidebar is color-coded the same way the Teams card is: red on any active
//! credential, amber for unverified findings, green for a clean run.
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
// Discord embed `description` is capped at 4096 chars and each `fields[].value`
// at 1024. We keep the per-finding block well under both — the section is
// truncated to 1900 chars (leaving room for the trailing "…N more" line) so
// servers running older Discord clients render the embed without truncation.
const DESCRIPTION_SOFT_LIMIT: usize = 1900;
const COLOR_RED: u32 = 0xC0_39_2B; // active live secrets
const COLOR_AMBER: u32 = 0xF3_9C_12; // findings present, none verified active
const COLOR_GREEN: u32 = 0x27_AE_60; // clean
pub fn build_payload(
summary: &AlertSummary,
findings: &[&FindingReporterRecord],
include_secret: bool,
) -> Value {
let title = if summary.total == 0 {
"Kingfisher: scan complete — no findings".to_string()
} else {
format!(
"Kingfisher: {} finding{} ({} active, {} inactive, {} unknown)",
summary.total,
plural(summary.total),
summary.active,
summary.inactive,
summary.unknown
)
};
let color = if summary.active > 0 {
COLOR_RED
} else if summary.total > 0 {
COLOR_AMBER
} else {
COLOR_GREEN
};
let mut fields: Vec<Value> = vec![
json!({ "name": "Active", "value": summary.active.to_string(), "inline": true }),
json!({ "name": "Inactive", "value": summary.inactive.to_string(), "inline": true }),
json!({ "name": "Unknown", "value": summary.unknown.to_string(), "inline": true }),
];
if let Some(t) = &summary.target {
fields.push(json!({
"name": "Target",
"value": format!("`{}`", truncate(t, 1000)),
"inline": false,
}));
}
if !summary.by_rule.is_empty() {
let lines: Vec<String> =
summary.by_rule.iter().map(|(rule, count)| format!("• `{rule}` — {count}")).collect();
fields.push(json!({
"name": "Top rules",
"value": truncate(&lines.join("\n"), 1000),
"inline": false,
}));
}
let mut embed = json!({
"title": title,
"color": color,
"fields": fields,
"footer": { "text": format!("kingfisher v{}", summary.kingfisher_version) },
});
if !findings.is_empty() {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut detail = String::new();
for f in findings.iter().take(take) {
let snippet = if include_secret {
truncate(&f.finding.snippet, 32)
} else {
"<redacted>".to_string()
};
detail.push_str(&format!(
"• `{}` at `{}:{}` — `{}` (validation: {})\n",
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status,
));
}
if findings.len() > take {
detail.push_str(&format!("{} more findings omitted", findings.len() - take));
}
embed["description"] = Value::String(truncate(&detail, DESCRIPTION_SOFT_LIMIT));
}
json!({ "embeds": [embed] })
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let prefix: String = s.chars().take(n).collect();
format!("{prefix}")
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(total: usize, active: usize) -> AlertSummary {
AlertSummary {
total,
active,
inactive: 0,
unknown: 0,
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
}
}
#[test]
fn color_red_when_active() {
let p = build_payload(&summary(3, 1), &[], false);
assert_eq!(p["embeds"][0]["color"], COLOR_RED);
}
#[test]
fn color_amber_when_findings_no_active() {
let p = build_payload(&summary(2, 0), &[], false);
assert_eq!(p["embeds"][0]["color"], COLOR_AMBER);
}
#[test]
fn color_green_when_empty() {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["embeds"][0]["color"], COLOR_GREEN);
assert_eq!(p["embeds"][0]["title"], "Kingfisher: scan complete — no findings");
}
#[test]
fn footer_carries_version() {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["embeds"][0]["footer"]["text"], "kingfisher vtest");
}
#[test]
fn empty_findings_has_no_description() {
let p = build_payload(&summary(0, 0), &[], false);
assert!(p["embeds"][0].get("description").is_none());
}
}

155
src/alerts/googlechat.rs Normal file
View file

@ -0,0 +1,155 @@
//! Google Chat incoming-webhook payload (`cardsV2`).
//!
//! Google Chat does not expose a card-color knob in the public webhook API the
//! way Discord/Teams/Mattermost do, so severity is encoded textually in the
//! header title. The card uses two sections: a "Summary" with `decoratedText`
//! widgets for the active/inactive/unknown counts, and a "Findings" section
//! with a `textParagraph` widget. `textParagraph.text` accepts a small
//! markdown subset (`*bold*`, `_italic_`, backtick code spans).
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
pub fn build_payload(
summary: &AlertSummary,
findings: &[&FindingReporterRecord],
include_secret: bool,
) -> Value {
let title = if summary.total == 0 {
"Kingfisher: scan complete — no findings".to_string()
} else {
let prefix = if summary.active > 0 { "🚨 " } else { "" };
format!(
"{}Kingfisher: {} finding{} ({} active, {} inactive, {} unknown)",
prefix,
summary.total,
plural(summary.total),
summary.active,
summary.inactive,
summary.unknown
)
};
let mut summary_widgets: Vec<Value> = vec![
json!({ "decoratedText": { "topLabel": "Active", "text": summary.active.to_string() } }),
json!({ "decoratedText": { "topLabel": "Inactive", "text": summary.inactive.to_string() } }),
json!({ "decoratedText": { "topLabel": "Unknown", "text": summary.unknown.to_string() } }),
];
if let Some(t) = &summary.target {
summary_widgets.push(json!({
"decoratedText": { "topLabel": "Target", "text": t }
}));
}
if !summary.by_rule.is_empty() {
let lines: Vec<String> = summary
.by_rule
.iter()
.map(|(rule, count)| format!("• <code>{rule}</code> — {count}"))
.collect();
summary_widgets.push(json!({
"textParagraph": { "text": format!("<b>Top rules</b><br>{}", lines.join("<br>")) }
}));
}
let mut sections: Vec<Value> = vec![json!({
"header": "Summary",
"widgets": summary_widgets,
})];
if !findings.is_empty() {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut detail = String::new();
for f in findings.iter().take(take) {
let snippet = if include_secret {
truncate(&f.finding.snippet, 32)
} else {
"<redacted>".to_string()
};
detail.push_str(&format!(
"• <b>{}</b> at <code>{}:{}</code> — <code>{}</code> (validation: {})<br>",
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status,
));
}
if findings.len() > take {
detail.push_str(&format!("<i>…{} more findings omitted</i>", findings.len() - take));
}
sections.push(json!({
"header": "Findings",
"widgets": [{ "textParagraph": { "text": detail } }],
}));
}
json!({
"cardsV2": [{
"cardId": "kingfisher-alert",
"card": {
"header": {
"title": title,
"subtitle": format!("kingfisher v{}", summary.kingfisher_version),
},
"sections": sections,
}
}]
})
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let prefix: String = s.chars().take(n).collect();
format!("{prefix}")
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(total: usize, active: usize) -> AlertSummary {
AlertSummary {
total,
active,
inactive: 0,
unknown: 0,
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
}
}
#[test]
fn empty_payload_has_no_findings_section() {
let p = build_payload(&summary(0, 0), &[], false);
let sections = p["cardsV2"][0]["card"]["sections"].as_array().unwrap();
assert_eq!(sections.len(), 1, "expected only the Summary section");
assert_eq!(sections[0]["header"], "Summary");
}
#[test]
fn title_prefixes_emoji_when_active() {
let p = build_payload(&summary(3, 1), &[], false);
let title = p["cardsV2"][0]["card"]["header"]["title"].as_str().unwrap();
assert!(title.starts_with("🚨"), "active findings should prefix the title with 🚨");
}
#[test]
fn title_no_emoji_when_findings_no_active() {
let p = build_payload(&summary(2, 0), &[], false);
let title = p["cardsV2"][0]["card"]["header"]["title"].as_str().unwrap();
assert!(!title.starts_with("🚨"), "no active findings → no emoji prefix");
}
#[test]
fn subtitle_carries_version() {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["cardsV2"][0]["card"]["header"]["subtitle"], "kingfisher vtest");
}
}

160
src/alerts/mattermost.rs Normal file
View file

@ -0,0 +1,160 @@
//! Mattermost incoming-webhook payload (Slack-compatible `attachments`).
//!
//! Mattermost has no canonical hostname (it is always self-hosted), so the
//! `infer_from_url` heuristic cannot distinguish a Mattermost URL from any
//! other generic webhook. Users must pass `--alert-format mattermost`
//! explicitly.
//!
//! The legacy Slack `attachments` schema renders identically across Mattermost
//! server versions ≥ 5.x and gives us the same red/amber/green sidebar that
//! Teams/Discord use. We deliberately do **not** reuse `slack::build_payload`
//! because Slack's Block Kit support in Mattermost is partial — older clients
//! only render the top-level `text` and silently drop blocks.
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
const COLOR_RED: &str = "#C0392B";
const COLOR_AMBER: &str = "#F39C12";
const COLOR_GREEN: &str = "#27AE60";
pub fn build_payload(
summary: &AlertSummary,
findings: &[&FindingReporterRecord],
include_secret: bool,
) -> Value {
let header = if summary.total == 0 {
"Kingfisher: scan complete — no findings".to_string()
} else {
format!(
"Kingfisher: {} finding{} ({} active, {} inactive, {} unknown)",
summary.total,
plural(summary.total),
summary.active,
summary.inactive,
summary.unknown
)
};
let color = if summary.active > 0 {
COLOR_RED
} else if summary.total > 0 {
COLOR_AMBER
} else {
COLOR_GREEN
};
let mut fields: Vec<Value> = vec![
json!({ "short": true, "title": "Active", "value": summary.active.to_string() }),
json!({ "short": true, "title": "Inactive", "value": summary.inactive.to_string() }),
json!({ "short": true, "title": "Unknown", "value": summary.unknown.to_string() }),
];
if let Some(t) = &summary.target {
fields.push(json!({ "short": false, "title": "Target", "value": format!("`{t}`") }));
}
if !summary.by_rule.is_empty() {
let lines: Vec<String> =
summary.by_rule.iter().map(|(rule, count)| format!("• `{rule}` — {count}")).collect();
fields.push(json!({
"short": false,
"title": "Top rules",
"value": lines.join("\n"),
}));
}
let mut attachment = json!({
"color": color,
"title": header,
"fields": fields,
"footer": format!("kingfisher v{}", summary.kingfisher_version),
});
if !findings.is_empty() {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut details = String::new();
for f in findings.iter().take(take) {
let snippet = if include_secret {
truncate(&f.finding.snippet, 32)
} else {
"<redacted>".to_string()
};
details.push_str(&format!(
"- **{}** at `{}:{}` — `{}` (validation: {})\n",
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status,
));
}
if findings.len() > take {
details.push_str(&format!("_…{} more findings omitted_\n", findings.len() - take));
}
attachment["text"] = Value::String(details);
}
json!({
"text": header,
"attachments": [attachment],
})
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let prefix: String = s.chars().take(n).collect();
format!("{prefix}")
}
#[cfg(test)]
mod tests {
use super::*;
fn summary(total: usize, active: usize) -> AlertSummary {
AlertSummary {
total,
active,
inactive: 0,
unknown: 0,
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
}
}
#[test]
fn color_red_when_active() {
let p = build_payload(&summary(3, 1), &[], false);
assert_eq!(p["attachments"][0]["color"], COLOR_RED);
}
#[test]
fn color_amber_when_findings_no_active() {
let p = build_payload(&summary(2, 0), &[], false);
assert_eq!(p["attachments"][0]["color"], COLOR_AMBER);
}
#[test]
fn color_green_when_empty() {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["attachments"][0]["color"], COLOR_GREEN);
}
#[test]
fn fallback_text_carries_header() {
let p = build_payload(&summary(0, 0), &[], false);
let text = p["text"].as_str().unwrap();
assert!(text.contains("no findings"));
}
#[test]
fn footer_carries_version() {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["attachments"][0]["footer"], "kingfisher vtest");
}
}

View file

@ -15,7 +15,10 @@ 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;
@ -47,6 +50,12 @@ pub enum AlertFormat {
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 {
@ -59,6 +68,10 @@ impl AlertFormat {
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,
}
}
@ -192,6 +205,15 @@ pub async fn dispatch(
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 {
@ -275,4 +297,36 @@ mod tests {
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
);
}
}

View file

@ -229,7 +229,10 @@ pub struct ScanArgs {
pub alert_webhook: Vec<String>,
/// Format for `--alert-webhook` payloads. Default is inferred from the URL
/// host (slack.com → slack, *.office.com → teams, otherwise generic).
/// host (slack.com → slack, *.office.com → teams, discord.com → discord,
/// chat.googleapis.com → googlechat, otherwise generic). Mattermost is
/// self-hosted and never inferred — pass `--alert-format mattermost`
/// explicitly.
#[arg(global = true, long = "alert-format", value_name = "FORMAT")]
pub alert_format: Option<crate::alerts::AlertFormat>,

View file

@ -8,7 +8,7 @@
//! alerts:
//! webhooks:
//! - url: https://hooks.slack.com/services/...
//! format: slack # slack | teams | generic
//! format: slack # slack | teams | generic | discord | mattermost | googlechat
//! on: findings # findings | always
//! min_confidence: medium # low | medium | high
//! include_secret: false