forked from mirrors/kingfisher
webhook support and kingfisher configuration yaml support
This commit is contained in:
parent
a4cf3990a5
commit
0e1fe0cede
15 changed files with 726 additions and 30 deletions
161
src/alerts/discord.rs
Normal file
161
src/alerts/discord.rs
Normal 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
155
src/alerts/googlechat.rs
Normal 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
160
src/alerts/mattermost.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue