preparing for v1.99.0

This commit is contained in:
Mick Grove 2026-05-04 18:03:29 -07:00
commit b28f15252c
6 changed files with 154 additions and 34 deletions

View file

@ -82,18 +82,18 @@ pub fn build_payload(
let mut detail = String::new();
for f in findings.iter().take(take) {
let snippet = if include_secret {
truncate(&f.finding.snippet, 32)
escape_for_code_span(&truncate(&f.finding.snippet, 32))
} else {
"<redacted>".to_string()
"redacted".to_string()
};
detail.push_str(&format!(
"• `{}` at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
f.rule.id,
f.finding.path,
escape_for_code_span(&f.rule.id),
escape_for_code_span(&f.finding.path),
f.finding.line,
snippet,
f.finding.validation.status,
f.finding.fingerprint,
escape_md(&f.finding.validation.status),
escape_for_code_span(&f.finding.fingerprint),
));
}
if findings.len() > take {
@ -137,6 +137,31 @@ fn truncate(s: &str, n: usize) -> String {
format!("{prefix}")
}
/// Escape a value before embedding it in a backtick code span. Replace
/// backticks with U+02CB so a user-controlled value cannot terminate the
/// span and inject Discord markup, and normalize newlines so a single
/// finding does not fragment the bullet list.
fn escape_for_code_span(s: &str) -> String {
s.replace('`', "\u{02CB}").replace(['\n', '\r'], " ")
}
/// Escape Discord markdown metacharacters in fields rendered outside a code
/// span (e.g. validation status). Backslash-escapes the common formatters.
fn escape_md(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' | '*' | '_' | '~' | '`' | '|' | '>' | '<' | '[' | ']' | '(' | ')' => {
out.push('\\');
out.push(ch);
}
'\n' | '\r' => out.push(' '),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -41,14 +41,14 @@ pub fn build_payload(
];
if let Some(t) = &summary.target {
summary_widgets.push(json!({
"decoratedText": { "topLabel": "Target", "text": t }
"decoratedText": { "topLabel": "Target", "text": escape_html(t) }
}));
}
if !summary.by_rule.is_empty() {
let lines: Vec<String> = summary
.by_rule
.iter()
.map(|(rule, count)| format!("• <code>{rule}</code> — {count}"))
.map(|(rule, count)| format!("• <code>{}</code> — {count}", escape_html(rule)))
.collect();
summary_widgets.push(json!({
"textParagraph": { "text": format!("<b>Top rules</b><br>{}", lines.join("<br>")) }
@ -65,18 +65,18 @@ pub fn build_payload(
let mut detail = String::new();
for f in findings.iter().take(take) {
let snippet = if include_secret {
truncate(&f.finding.snippet, 32)
escape_html(&truncate(&f.finding.snippet, 32))
} else {
"<redacted>".to_string()
"redacted".to_string()
};
detail.push_str(&format!(
"• <b>{}</b> at <code>{}:{}</code> — <code>{}</code> (validation: {}) — fp:<code>{}</code><br>",
f.rule.id,
f.finding.path,
escape_html(&f.rule.id),
escape_html(&f.finding.path),
f.finding.line,
snippet,
f.finding.validation.status,
f.finding.fingerprint,
escape_html(&f.finding.validation.status),
escape_html(&f.finding.fingerprint),
));
}
if findings.len() > take {
@ -137,6 +137,21 @@ fn truncate(s: &str, n: usize) -> String {
format!("{prefix}")
}
/// Google Chat `textParagraph.text` is HTML-ish — `<b>`, `<code>`, `<br>`, etc.
/// are rendered as markup. Any user-controlled value (rule id, path, snippet,
/// fingerprint, validation status) must be HTML-escaped before interpolation,
/// otherwise an unescaped `<` could break out of a `<code>` span and inject
/// arbitrary chat markup. Newlines are normalized to spaces so a single
/// finding does not fragment the bullet list.
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
.replace(['\n', '\r'], " ")
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -78,18 +78,18 @@ pub fn build_payload(
let mut details = String::new();
for f in findings.iter().take(take) {
let snippet = if include_secret {
truncate(&f.finding.snippet, 32)
escape_for_code_span(&truncate(&f.finding.snippet, 32))
} else {
"<redacted>".to_string()
"redacted".to_string()
};
details.push_str(&format!(
"- **{}** at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
f.rule.id,
f.finding.path,
escape_bold(&f.rule.id),
escape_for_code_span(&f.finding.path),
f.finding.line,
snippet,
f.finding.validation.status,
f.finding.fingerprint,
escape_bold(&f.finding.validation.status),
escape_for_code_span(&f.finding.fingerprint),
));
}
if findings.len() > take {
@ -127,6 +127,23 @@ fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
/// Escape a value before embedding it in a backtick code span. Replace
/// backticks with U+02CB so a user-controlled value cannot terminate the
/// span and inject markdown, and collapse newlines so a single finding
/// does not fragment the bullet list.
fn escape_for_code_span(s: &str) -> String {
s.replace('`', "\u{02CB}").replace(['\n', '\r'], " ")
}
/// Escape values rendered inside a `**bold**` span — strip embedded `**`
/// and `_` so a user-controlled value cannot end the bold or start a link.
fn escape_bold(s: &str) -> String {
s.replace("**", "\u{02CB}\u{02CB}")
.replace('_', "\\_")
.replace('|', "\\|")
.replace(['\n', '\r'], " ")
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();

View file

@ -60,12 +60,12 @@ pub fn build_payload(
let mut detail_lines: Vec<String> = Vec::with_capacity(take);
for f in findings.iter().take(take) {
let snippet = if include_secret {
truncate(&f.finding.snippet, 32)
escape_for_code_span(&truncate(&f.finding.snippet, 32))
} else {
"<redacted>".to_string()
"redacted".to_string()
};
detail_lines.push(format!(
"• `{}` at `{}:{}` — {} (validation: {}) — fp:`{}`",
"• `{}` at `{}:{}` — `{}` (validation: {}) — fp:`{}`",
escape_mrkdwn(&f.rule.id),
escape_mrkdwn(&f.finding.path),
f.finding.line,
@ -131,6 +131,16 @@ fn escape_mrkdwn(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
/// Sanitize a value before it goes inside a backtick code span. We escape
/// the same `<>&` mrkdwn metacharacters and replace embedded backticks with
/// a similar-looking U+02CB (modifier letter grave accent) so a user-controlled
/// value cannot break out of the span and inject Slack markup or `<url|text>`
/// links. Newlines are normalized to spaces so a single finding does not
/// fragment the bullet list.
fn escape_for_code_span(s: &str) -> String {
escape_mrkdwn(s).replace('`', "\u{02CB}").replace(['\n', '\r'], " ")
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -55,18 +55,18 @@ pub fn build_payload(
let mut details = String::new();
for f in findings.iter().take(take) {
let snippet = if include_secret {
truncate(&f.finding.snippet, 32)
escape_for_code_span(&truncate(&f.finding.snippet, 32))
} else {
"<redacted>".to_string()
"redacted".to_string()
};
details.push_str(&format!(
"- **{}** at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
f.rule.id,
f.finding.path,
escape_bold(&f.rule.id),
escape_for_code_span(&f.finding.path),
f.finding.line,
snippet,
f.finding.validation.status,
f.finding.fingerprint,
escape_bold(&f.finding.validation.status),
escape_for_code_span(&f.finding.fingerprint),
));
}
if findings.len() > take {
@ -111,6 +111,23 @@ fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "s" }
}
/// Escape a value before embedding it in a backtick code span. Replace
/// backticks with U+02CB so a user-controlled value cannot terminate the
/// span and inject Teams markdown, and collapse newlines so a single
/// finding does not fragment the bullet list.
fn escape_for_code_span(s: &str) -> String {
s.replace('`', "\u{02CB}").replace(['\n', '\r'], " ")
}
/// Escape values rendered inside a `**bold**` span — strip embedded `**`
/// and `_` so a user-controlled value cannot end the bold or start a link.
fn escape_bold(s: &str) -> String {
s.replace("**", "\u{02CB}\u{02CB}")
.replace('_', "\\_")
.replace('|', "\\|")
.replace(['\n', '\r'], " ")
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();

View file

@ -349,6 +349,11 @@ pub fn rewrite_argv_for_reexec(argv: impl IntoIterator<Item = OsString>) -> Vec<
let mut out: Vec<OsString> = Vec::new();
let mut already_has_no_update_check = false;
let mut hit_double_dash = false;
// Index of the `--` separator inside `out`, if seen. When set, we must
// insert any synthesized flags (like `--no-update-check`) *before* this
// index — clap stops parsing flags at `--` and treats everything after
// as positional.
let mut double_dash_idx: Option<usize> = None;
let had_argv0;
if let Some(argv0) = iter.next() {
@ -367,6 +372,7 @@ pub fn rewrite_argv_for_reexec(argv: impl IntoIterator<Item = OsString>) -> Vec<
if tok == "--" {
hit_double_dash = true;
double_dash_idx = Some(out.len());
out.push(tok);
continue;
}
@ -386,11 +392,19 @@ pub fn rewrite_argv_for_reexec(argv: impl IntoIterator<Item = OsString>) -> Vec<
out.push(tok);
}
// Only append --no-update-check when we actually preserved an argv[0]. In the
// theoretical empty-input case, returning an empty Vec keeps argv shape-consistent
// and lets the caller decide what to do.
// Only inject `--no-update-check` when we actually preserved an argv[0].
// In the theoretical empty-input case, returning an empty Vec keeps the
// argv shape consistent and lets the caller decide what to do.
//
// If a `--` separator was present, insert the flag *before* it — anything
// after `--` is positional, so appending at the end would silently fail
// to suppress the update check on the re-exec'd process.
if had_argv0 && !already_has_no_update_check {
out.push(OsString::from("--no-update-check"));
let flag = OsString::from("--no-update-check");
match double_dash_idx {
Some(idx) => out.insert(idx, flag),
None => out.push(flag),
}
}
out
@ -462,6 +476,9 @@ mod tests {
#[test]
fn rewrite_argv_preserves_tokens_after_double_dash() {
// --self-update appearing AFTER `--` is a positional and must be preserved.
// The synthesized --no-update-check has to land BEFORE `--`; otherwise it
// would be parsed as a positional and the re-exec'd process would still
// perform an update check.
let result = rewrite_argv_for_reexec(argv(&[
"kingfisher",
"scan",
@ -472,7 +489,26 @@ mod tests {
]));
assert_eq!(
result,
argv(&["kingfisher", "scan", "--", "--self-update", "--update", "--no-update-check"])
argv(&["kingfisher", "scan", "--no-update-check", "--", "--self-update", "--update"])
);
}
#[test]
fn rewrite_argv_does_not_duplicate_no_update_check_when_already_present_before_double_dash() {
// If --no-update-check is already present before `--`, the function is
// a no-op for that flag (no duplicate insertion) and tokens after `--`
// pass through verbatim.
let result = rewrite_argv_for_reexec(argv(&[
"kingfisher",
"scan",
"--no-update-check",
"--self-update",
"--",
"--self-update",
]));
assert_eq!(
result,
argv(&["kingfisher", "scan", "--no-update-check", "--", "--self-update"])
);
}