use secfinding::Severity;
use crate::models::GenericFinding;
pub(crate) fn escape_markdown_text(input: &str) -> String {
let sanitized = neutralize_markdown_links(input);
escape_markdown_literals(&sanitized)
}
fn escape_markdown_literals(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut in_angle = false;
for ch in input.chars() {
if ch == '<' {
in_angle = true;
out.push('\\');
out.push(ch);
continue;
}
if ch == '>' {
in_angle = false;
out.push('\\');
out.push(ch);
continue;
}
match ch {
'(' | ')' if in_angle => {}
'\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '|'
| '!' | '>' | '<' => {
out.push('\\');
}
_ => {}
}
out.push(ch);
}
out
}
fn neutralize_markdown_links(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(label_start) = rest.find('[') {
out.push_str(&rest[..label_start]);
let after_label_start = &rest[label_start + 1..];
let Some(label_end_rel) = after_label_start.find(']') else {
out.push_str(&rest[label_start..]);
return out;
};
let label_end = label_start + 1 + label_end_rel;
let after_label = &rest[label_end + 1..];
if !after_label.starts_with('(') {
out.push_str(&rest[label_start..=label_end]);
rest = &rest[label_end + 1..];
continue;
}
let Some(url_end_rel) = find_matching_paren(after_label) else {
out.push_str(&rest[label_start..]);
return out;
};
let url_end = label_end + 1 + url_end_rel;
let label = &rest[label_start + 1..label_end];
let url = &rest[label_end + 2..url_end];
if url.contains(':') {
out.push_str(&format!("[{label}] <{url}>"));
} else {
out.push_str(&rest[label_start..=url_end]);
}
rest = &rest[url_end + 1..];
}
out.push_str(rest);
out
}
fn find_matching_paren(input: &str) -> Option<usize> {
let mut depth = 0usize;
for (index, ch) in input.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Some(index);
}
}
_ => {}
}
}
None
}
fn pct_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for &b in input.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char);
}
other => {
out.push('%');
out.push_str(&format!("{:02X}", other));
}
}
}
out
}
fn max_consecutive_char(input: &str, ch: char) -> usize {
let mut max = 0usize;
let mut cur = 0usize;
for c in input.chars() {
if c == ch {
cur += 1;
if cur > max {
max = cur;
}
} else {
cur = 0;
}
}
max
}
pub(crate) fn render_markdown_generic(findings: &[GenericFinding<'_>], tool_name: &str) -> String {
let now = chrono::Utc::now().format("%Y-%m-%d").to_string();
let escaped_tool = escape_markdown_text(tool_name);
let target = {
let mut uniq = findings
.iter()
.map(|f| escape_markdown_text(f.target))
.collect::<Vec<_>>();
uniq.sort();
uniq.dedup();
if uniq.is_empty() {
"unknown".to_string()
} else if uniq.len() == 1 {
uniq.into_iter().next().unwrap()
} else {
"multiple targets".to_string()
}
};
let mut md = String::with_capacity(findings.len() * 300);
md.push_str(&format!(
"# {escaped_tool} Security Report \u{2014} {target}\n\n*Generated {now} \u{00b7} {} findings*\n\n",
findings.len()
));
md.push_str("## Risk Summary\n\n| Severity | Count |\n|---|---|\n");
for severity in [
Severity::Critical,
Severity::High,
Severity::Medium,
Severity::Low,
Severity::Info,
] {
let count = findings.iter().filter(|f| f.severity == severity).count();
if count > 0 {
md.push_str(&format!("| {} | {count} |\n", severity.label()));
}
}
md.push('\n');
for (sev, heading) in [
(Severity::Critical, "Critical Findings"),
(Severity::High, "High Findings"),
(Severity::Medium, "Medium Findings"),
(Severity::Low, "Low Findings"),
(Severity::Info, "Informational"),
] {
let group: Vec<_> = findings.iter().filter(|f| f.severity == sev).collect();
if group.is_empty() {
continue;
}
md.push_str(&format!("## {heading}\n\n"));
for f in group {
md.push_str(&format!(
"### {}\n\n**Target:** `{}` \n**Scanner:** {} \n**Category:** {} \n",
escape_markdown_text(f.title),
escape_markdown_text(f.target),
escape_markdown_text(f.scanner),
escape_markdown_text(&format!("{:?}", f.kind)),
));
if !f.tags.is_empty() {
let tags = f
.tags
.iter()
.map(|tag| format!("`{}`", escape_markdown_text(tag)))
.collect::<Vec<_>>()
.join(" ");
md.push_str(&format!("**Tags:** {tags} \n"));
}
if !f.cwe_ids.is_empty() {
let cwe_escaped = f
.cwe_ids
.iter()
.map(|id| escape_markdown_text(id))
.collect::<Vec<_>>()
.join(", ");
md.push_str(&format!("**CWE:** {} \n", cwe_escaped));
}
if !f.cve_ids.is_empty() {
let cve_escaped = f
.cve_ids
.iter()
.map(|id| escape_markdown_text(id))
.collect::<Vec<_>>()
.join(", ");
md.push_str(&format!("**CVE:** {} \n", cve_escaped));
}
md.push('\n');
if !f.detail.is_empty() {
md.push_str(&format!("{}\n\n", escape_markdown_text(f.detail)));
}
if let Some(hint) = &f.exploit_hint {
let backtick_run = max_consecutive_char(hint, '`');
let fence = "`".repeat(backtick_run + 1);
md.push_str(&format!(
"**Exploit / PoC:**\n{}bash\n{}\n{}\n\n",
fence, hint, fence
));
}
md.push_str("---\n\n");
}
}
let encoded_tool = pct_encode(tool_name);
md.push_str(&format!(
"*Report generated by [{escaped_tool}](https://github.com/santh-io/{})*\n",
encoded_tool
));
md
}