Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 179 additions & 22 deletions bin/core/src/alert/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::collections::HashMap;

use anyhow::{Context, anyhow};
use database::mungos::{find::find_collect, mongodb::bson::doc};
use futures_util::future::join_all;
Expand Down Expand Up @@ -106,14 +108,17 @@ pub async fn send_alert_to_alerter(
}

match &alerter.config.endpoint {
AlerterEndpoint::Custom(CustomAlerterEndpoint { url }) => {
send_custom_alert(url, alert).await.with_context(|| {
AlerterEndpoint::Custom(CustomAlerterEndpoint {
url,
headers,
}) => send_custom_alert(url, headers, alert).await.with_context(
|| {
format!(
"Failed to send alert to Custom Alerter {}",
alerter.name
)
})
}
},
),
AlerterEndpoint::Slack(SlackAlerterEndpoint { url }) => {
slack::send_alert(url, alert).await.with_context(|| {
format!(
Expand Down Expand Up @@ -153,47 +158,100 @@ pub async fn send_alert_to_alerter(

async fn send_custom_alert(
url: &str,
headers: &HashMap<String, String>,
alert: &Alert,
) -> anyhow::Result<()> {
let VariablesAndSecrets { variables, secrets } =
get_variables_and_secrets().await?;
let mut url_interpolated = url.to_string();
send_custom_alert_inner(url, headers, alert, &variables, &secrets)
.await
}

let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
async fn send_custom_alert_inner(
url: &str,
headers: &HashMap<String, String>,
alert: &Alert,
variables: &HashMap<String, String>,
secrets: &HashMap<String, String>,
) -> anyhow::Result<()> {
let mut interpolator = Interpolator::new(Some(variables), secrets);
let (url_interpolated, headers) = interpolate_custom_request_parts(
url,
headers,
&mut interpolator,
)?;

interpolator.interpolate_string(&mut url_interpolated)?;
let mut request =
reqwest::Client::new().post(url_interpolated).json(alert);

let res = reqwest::Client::new()
.post(url_interpolated)
.json(alert)
for (header, value) in headers {
request = request.header(&header, &value);
}

let res = request
.send()
.await
.map_err(|e| {
let replacers = interpolator
.secret_replacers
.into_iter()
.collect::<Vec<_>>();
let sanitized_error =
svi::replace_in_string(&format!("{e:?}"), &replacers);
anyhow::Error::msg(format!(
"Error with request: {sanitized_error}"
))
})
.map_err(|e| sanitize_request_error(&interpolator, &e))
.context("failed at post request to alerter")?;
let status = res.status();
if !status.is_success() {
let text = res
.text()
.await
.context("failed to get response text on alerter response")?;
let text = sanitize_interpolated_text(&interpolator, &text);
return Err(anyhow!(
"post to alerter failed | {status} | {text}"
));
}
Ok(())
}

fn interpolate_custom_request_parts(
url: &str,
headers: &HashMap<String, String>,
interpolator: &mut Interpolator,
) -> anyhow::Result<(String, Vec<(String, String)>)> {
let mut url_interpolated = url.to_string();
interpolator.interpolate_string(&mut url_interpolated)?;

let headers = headers
.iter()
.map(|(header, value)| {
let mut header = header.to_string();
let mut value = value.to_string();
interpolator.interpolate_string(&mut header)?;
interpolator.interpolate_string(&mut value)?;
anyhow::Ok((header, value))
})
.collect::<anyhow::Result<Vec<_>>>()?;
let mut headers = headers;
headers.sort_by(|left, right| left.0.cmp(&right.0));

Ok((url_interpolated, headers))
}

fn sanitize_request_error(
interpolator: &Interpolator,
error: &impl std::fmt::Debug,
) -> anyhow::Error {
let sanitized_error =
sanitize_interpolated_text(interpolator, &format!("{error:?}"));
anyhow::Error::msg(format!("Error with request: {sanitized_error}"))
}

fn sanitize_interpolated_text(
interpolator: &Interpolator,
text: &str,
) -> String {
let replacers = interpolator
.secret_replacers
.iter()
.cloned()
.collect::<Vec<_>>();
svi::replace_in_string(text, &replacers)
}

fn fmt_region(region: &Option<String>) -> String {
match region {
Some(region) => format!(" ({region})"),
Expand Down Expand Up @@ -221,6 +279,105 @@ fn fmt_stack_state(state: &StackState) -> String {
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn custom_alert_interpolates_headers_and_url() {
let mut headers = HashMap::new();
headers.insert(
String::from("authorization"),
String::from("Bearer [[TOKEN]]"),
);
headers
.insert(String::from("x-komodo-env"), String::from("[[ENV]]"));

let mut variables = HashMap::new();
variables.insert(String::from("ENV"), String::from("dev"));

let mut secrets = HashMap::new();
secrets
.insert(String::from("TOKEN"), String::from("super-secret"));

let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
let (url, headers) = interpolate_custom_request_parts(
"https://example.com/[[ENV]]",
&headers,
&mut interpolator,
)
.unwrap();

assert_eq!(url, "https://example.com/dev");
assert_eq!(
headers,
vec![
(
String::from("authorization"),
String::from("Bearer super-secret"),
),
(String::from("x-komodo-env"), String::from("dev")),
]
);
}

#[test]
fn custom_alert_sanitizes_secret_on_request_error() {
let mut headers = HashMap::new();
headers.insert(
String::from("authorization"),
String::from("Bearer [[TOKEN]]"),
);

let variables = HashMap::new();
let secrets = HashMap::from([(
String::from("TOKEN"),
String::from("super-secret"),
)]);
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);

interpolate_custom_request_parts(
"https://example.com",
&headers,
&mut interpolator,
)
.unwrap();

let err = sanitize_request_error(
&interpolator,
&"authorization: Bearer super-secret",
)
.to_string();

assert!(err.contains("Error with request:"));
assert!(!err.contains("super-secret"));
}

#[test]
fn custom_alert_sanitizes_secret_in_response_body() {
let variables = HashMap::new();
let secrets = HashMap::from([(
String::from("TOKEN"),
String::from("super-secret"),
)]);
let interpolator =
Interpolator::new(Some(&variables), &secrets);
let mut interpolator = interpolator;
let mut token = String::from("[[TOKEN]]");

interpolator.interpolate_string(&mut token).unwrap();

let sanitized = sanitize_interpolated_text(
&interpolator,
"authorization: Bearer super-secret",
);

assert!(!sanitized.contains("super-secret"));
}
}

fn fmt_level(level: SeverityLevel) -> &'static str {
match level {
SeverityLevel::Critical => "CRITICAL 🚨",
Expand Down
8 changes: 8 additions & 0 deletions client/core/rs/src/entities/alerter.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::collections::HashMap;

use bson::{Document, doc};
use derive_builder::Builder;
use derive_default_builder::DefaultBuilder;
Expand Down Expand Up @@ -184,12 +186,18 @@ pub struct CustomAlerterEndpoint {
#[serde(default = "default_custom_url")]
#[builder(default = "default_custom_url()")]
pub url: String,

/// Additional headers to include on the request.
#[serde(default)]
#[builder(default)]
pub headers: HashMap<String, String>,
}

impl Default for CustomAlerterEndpoint {
fn default() -> Self {
Self {
url: default_custom_url(),
headers: Default::default(),
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion client/core/ts/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,7 @@ export type UserConfig =
description: string;
}};


export type LinkedLoginsMap = Record<UserConfig["type"], UserConfig>;

export interface UserTotpConfig {
Expand Down Expand Up @@ -7006,6 +7007,8 @@ export interface CreateVariable {
export interface CustomAlerterEndpoint {
/** The http/s endpoint to send the POST to */
url: string;
/** Additional headers to include on the request. */
headers: Record<string, string>;
}

/**
Expand Down Expand Up @@ -11117,4 +11120,3 @@ export type WsLoginMessage =
key: string;
secret: string;
}};

1 change: 1 addition & 0 deletions docsite/docs/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ All resources which depend on git repos / docker registries are able to use thes
## Alerter

- Route alerts to various endpoints.
- `Custom` alerters can post the raw JSON alert payload to any HTTP endpoint and include custom request headers.
- Can configure rules on each Alerter, such as resource whitelist, blacklist, or alert type filter.
2 changes: 2 additions & 0 deletions ui/public/client/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6738,6 +6738,8 @@ export interface CreateVariable {
export interface CustomAlerterEndpoint {
/** The http/s endpoint to send the POST to */
url: string;
/** Additional headers to include on the request. */
headers: Record<string, string>;
}
/**
* Deletes the action at the given id, and returns the deleted action.
Expand Down
40 changes: 40 additions & 0 deletions ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ export function envToText(envVars: Types.EnvironmentVar[] | undefined) {
);
}

export function recordToText(record: Record<string, string> | undefined) {
return Object.entries(record ?? {})
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${key}: ${value}`)
.join("\n");
}

export function textToEnv(env: string): Types.EnvironmentVar[] {
return env
.split("\n")
Expand All @@ -41,6 +48,39 @@ export function textToEnv(env: string): Types.EnvironmentVar[] {
.map(([variable, value]) => ({ variable, value }));
}

export function textToRecord(text: string): Record<string, string> {
const record: Record<string, string> = {};

for (const rawLine of text.split("\n")) {
if (!keepLine(rawLine)) continue;

const line = rawLine
.split(" #", 1)[0]
.trim()
.replace(/^-/, "")
.trim();
const separator = line.search(/[:=]/);

if (separator === -1) {
throw new Error("Each header line must contain ':' or '='.");
}

const key = line.slice(0, separator).trim().replace(/^["']|["']$/g, "");
const value = line
.slice(separator + 1)
.trim()
.replace(/^["']|["']$/g, "");

if (!key) {
throw new Error("Header names cannot be empty.");
}

record[key] = value;
}

return record;
}

function keepLine(line: string) {
if (line.length === 0) return false;
let firstIndex = -1;
Expand Down
Loading