// Scan result summary and statistics.
// Aggregates findings by severity, rule, file, and CWE category
// for dashboard-style reporting and trend tracking.
///|
pub(all) struct ScanSummary {
total_findings : Int
error_count : Int
warning_count : Int
info_count : Int
files_scanned : Int
files_with_findings : Int
rules_triggered : Array[RuleStat]
file_stats : Array[FileStat]
cwe_categories : Array[CWEStat]
owasp_mapping : Array[OwaspStat]
scan_errors : Int
}
///|
pub(all) struct RuleStat {
rule_id : String
name : String
count : Int
severity : Severity
}
///|
pub(all) struct FileStat {
file : String
finding_count : Int
error_count : Int
warning_count : Int
}
///|
pub(all) struct CWEStat {
cwe_id : Int
name : String
count : Int
}
///|
pub(all) struct OwaspStat {
category : String
count : Int
cwe_ids : Array[Int]
}
///|
pub fn build_summary(
result : ScanResult,
rules : Array[RuleInfo],
) -> ScanSummary {
let error_count = result.findings
.iter()
.filter(fn(f) { f.severity == Error })
.count()
let warning_count = result.findings
.iter()
.filter(fn(f) { f.severity == Warning })
.count()
let info_count = result.findings
.iter()
.filter(fn(f) { f.severity == Info })
.count()
let file_set : Map[String, Bool] = Map([])
for f in result.findings {
file_set[f.file] = true
}
let rule_stats = build_rule_stats(result.findings, rules)
let file_stats = build_file_stats(result.findings)
let cwe_stats = build_cwe_stats(result.findings)
let owasp_stats = build_owasp_stats(result.findings)
{
total_findings: result.findings.length(),
error_count,
warning_count,
info_count,
files_scanned: result.files_scanned,
files_with_findings: file_set.length(),
rules_triggered: rule_stats,
file_stats,
cwe_categories: cwe_stats,
owasp_mapping: owasp_stats,
scan_errors: result.errors.length(),
}
}
///|
fn build_rule_stats(
findings : Array[Finding],
rules : Array[RuleInfo],
) -> Array[RuleStat] {
let counts : Map[String, Int] = Map([])
for f in findings {
let id = rule_id_str(f.rule_id)
let cur = match counts.get(id) {
Some(c) => c
None => 0
}
counts[id] = cur + 1
}
let stats : Array[RuleStat] = []
for rule in rules {
let id = rule_id_str(rule.id)
let count = match counts.get(id) {
Some(c) => c
None => 0
}
if count > 0 {
stats.push({
rule_id: id,
name: rule.name,
count,
severity: rule.default_severity,
})
}
}
stats.sort_by(fn(a, b) { b.count - a.count })
stats
}
///|
fn build_file_stats(findings : Array[Finding]) -> Array[FileStat] {
let file_map : Map[String, FileStat] = Map([])
for f in findings {
match file_map.get(f.file) {
Some(fs) => {
let new_err = if f.severity == Error {
fs.error_count + 1
} else {
fs.error_count
}
let new_warn = if f.severity == Warning {
fs.warning_count + 1
} else {
fs.warning_count
}
file_map[f.file] = {
file: f.file,
finding_count: fs.finding_count + 1,
error_count: new_err,
warning_count: new_warn,
}
}
None => {
let err = if f.severity == Error { 1 } else { 0 }
let warn = if f.severity == Warning { 1 } else { 0 }
file_map[f.file] = {
file: f.file,
finding_count: 1,
error_count: err,
warning_count: warn,
}
}
}
}
let result : Array[FileStat] = []
file_map.each(fn(_k, v) { result.push(v) })
result.sort_by(fn(a, b) { b.finding_count - a.finding_count })
result
}
///|
fn build_cwe_stats(findings : Array[Finding]) -> Array[CWEStat] {
let cwe_counts : Map[Int, Int] = Map([])
for f in findings {
let cur = match cwe_counts.get(f.rule_id.cwe) {
Some(c) => c
None => 0
}
cwe_counts[f.rule_id.cwe] = cur + 1
}
let stats : Array[CWEStat] = []
cwe_counts.each(fn(cwe_id, count) {
stats.push({ cwe_id, name: cwe_name(cwe_id), count })
})
stats.sort_by(fn(a, b) { b.count - a.count })
stats
}
///|
fn cwe_name(cwe_id : Int) -> String {
if cwe_id == 79 {
"Cross-site Scripting (XSS)"
} else if cwe_id == 89 {
"SQL Injection"
} else if cwe_id == 94 {
"Code Injection"
} else if cwe_id == 113 {
"HTTP Response Splitting"
} else if cwe_id == 116 {
"Improper Encoding or Escaping"
} else if cwe_id == 22 {
"Path Traversal"
} else if cwe_id == 346 {
"Origin Validation Error"
} else if cwe_id == 614 {
"Sensitive Cookie Without Secure Flag"
} else if cwe_id == 770 {
"Allocation of Resources Without Limits"
} else if cwe_id == 942 {
"Permissive CORS Policy"
} else if cwe_id == 78 {
"OS Command Injection"
} else if cwe_id == 601 {
"Open Redirect"
} else {
"CWE-" + cwe_id.to_string()
}
}
///|
fn cwe_to_owasp(cwe_id : Int) -> String {
if cwe_id == 79 ||
cwe_id == 89 ||
cwe_id == 94 ||
cwe_id == 116 ||
cwe_id == 78 ||
cwe_id == 113 {
"A03:2021 - Injection"
} else if cwe_id == 942 ||
cwe_id == 614 ||
cwe_id == 346 ||
cwe_id == 22 ||
cwe_id == 601 {
"A01:2021 - Broken Access Control"
} else if cwe_id == 770 {
"A05:2021 - Security Misconfiguration"
} else {
"General"
}
}
///|
fn build_owasp_stats(findings : Array[Finding]) -> Array[OwaspStat] {
let owasp_map : Map[String, OwaspStat] = Map([])
for f in findings {
let cat = cwe_to_owasp(f.rule_id.cwe)
match owasp_map.get(cat) {
Some(os) => {
let has_cwe = os.cwe_ids.contains(f.rule_id.cwe)
if !has_cwe {
os.cwe_ids.push(f.rule_id.cwe)
}
owasp_map[cat] = {
category: cat,
count: os.count + 1,
cwe_ids: os.cwe_ids,
}
}
None =>
owasp_map[cat] = { category: cat, count: 1, cwe_ids: [f.rule_id.cwe] }
}
}
let result : Array[OwaspStat] = []
owasp_map.each(fn(_k, v) { result.push(v) })
result.sort_by(fn(a, b) { b.count - a.count })
result
}
///|
pub fn format_summary_text(summary : ScanSummary) -> String {
let buf = StringBuilder::new()
buf.write_string(
"═══════════════════════════════════════════════\n",
)
buf.write_string(" moon-audit Scan Summary\n")
buf.write_string(
"═══════════════════════════════════════════════\n\n",
)
buf.write_string(" Files scanned: ")
buf.write_string(summary.files_scanned.to_string())
buf.write_char('\n')
buf.write_string(" Files with findings: ")
buf.write_string(summary.files_with_findings.to_string())
buf.write_char('\n')
buf.write_string(" Total findings: ")
buf.write_string(summary.total_findings.to_string())
buf.write_char('\n')
buf.write_string(" Errors: ")
buf.write_string(summary.error_count.to_string())
buf.write_char('\n')
buf.write_string(" Warnings: ")
buf.write_string(summary.warning_count.to_string())
buf.write_char('\n')
buf.write_string(" Info: ")
buf.write_string(summary.info_count.to_string())
buf.write_char('\n')
if summary.scan_errors > 0 {
buf.write_string(" Scan errors: ")
buf.write_string(summary.scan_errors.to_string())
buf.write_char('\n')
}
buf.write_string(
"\n───────────────────────────────────────────────\n",
)
buf.write_string(" Rules Triggered\n")
buf.write_string(
"───────────────────────────────────────────────\n\n",
)
for stat in summary.rules_triggered {
buf.write_string(" ")
buf.write_string(pad_right(stat.rule_id, 30))
buf.write_string(" ")
buf.write_string(pad_left(stat.count.to_string(), 4))
buf.write_string(" (")
buf.write_string(severity_str(stat.severity))
buf.write_string(")\n")
}
if summary.cwe_categories.length() > 0 {
buf.write_string(
"\n───────────────────────────────────────────────\n",
)
buf.write_string(" CWE Categories\n")
buf.write_string(
"───────────────────────────────────────────────\n\n",
)
for stat in summary.cwe_categories {
buf.write_string(" CWE-")
buf.write_string(pad_right(stat.cwe_id.to_string(), 5))
buf.write_string(" ")
buf.write_string(pad_right(stat.name, 35))
buf.write_string(" ")
buf.write_string(pad_left(stat.count.to_string(), 4))
buf.write_char('\n')
}
}
if summary.owasp_mapping.length() > 0 {
buf.write_string(
"\n───────────────────────────────────────────────\n",
)
buf.write_string(" OWASP Top 10 Mapping\n")
buf.write_string(
"───────────────────────────────────────────────\n\n",
)
for stat in summary.owasp_mapping {
buf.write_string(" ")
buf.write_string(pad_right(stat.category, 40))
buf.write_string(" ")
buf.write_string(pad_left(stat.count.to_string(), 4))
buf.write_char('\n')
}
}
if summary.file_stats.length() > 0 {
buf.write_string(
"\n───────────────────────────────────────────────\n",
)
buf.write_string(" Top Files\n")
buf.write_string(
"───────────────────────────────────────────────\n\n",
)
let max_files = if summary.file_stats.length() > 10 {
10
} else {
summary.file_stats.length()
}
for i = 0; i < max_files; i = i + 1 {
let fs = summary.file_stats[i]
buf.write_string(" ")
buf.write_string(pad_right(fs.file, 40))
buf.write_string(" ")
buf.write_string(pad_left(fs.finding_count.to_string(), 4))
buf.write_string(" (")
buf.write_string(fs.error_count.to_string())
buf.write_string("E/")
buf.write_string(fs.warning_count.to_string())
buf.write_string("W)\n")
}
}
buf.write_string(
"\n═══════════════════════════════════════════════\n",
)
buf.to_string()
}
///|
fn pad_right(s : String, width : Int) -> String {
if s.length() >= width {
return s
}
let buf = StringBuilder::new()
buf.write_string(s)
for _i = s.length(); _i < width; _i = _i + 1 {
buf.write_char(' ')
}
buf.to_string()
}
///|
fn pad_left(s : String, width : Int) -> String {
if s.length() >= width {
return s
}
let buf = StringBuilder::new()
for _i = s.length(); _i < width; _i = _i + 1 {
buf.write_char(' ')
}
buf.write_string(s)
buf.to_string()
}
///|
pub fn format_summary_json(summary : ScanSummary) -> String {
let rules_json : Array[Json] = summary.rules_triggered.map(fn(r) {
let obj : Json = {
"rule_id": Json::string(r.rule_id),
"name": Json::string(r.name),
"count": Json::number(r.count.to_double()),
"severity": Json::string(severity_str(r.severity)),
}
obj
})
let files_json : Array[Json] = summary.file_stats.map(fn(f) {
let obj : Json = {
"file": Json::string(f.file),
"findings": Json::number(f.finding_count.to_double()),
"errors": Json::number(f.error_count.to_double()),
"warnings": Json::number(f.warning_count.to_double()),
}
obj
})
let cwe_json : Array[Json] = summary.cwe_categories.map(fn(c) {
let obj : Json = {
"cwe_id": Json::number(c.cwe_id.to_double()),
"name": Json::string(c.name),
"count": Json::number(c.count.to_double()),
}
obj
})
let owasp_json : Array[Json] = summary.owasp_mapping.map(fn(o) {
let cwe_ids_json : Array[Json] = o.cwe_ids.map(fn(id) {
Json::number(id.to_double())
})
let obj : Json = {
"category": Json::string(o.category),
"count": Json::number(o.count.to_double()),
"cwe_ids": Json::array(cwe_ids_json),
}
obj
})
let obj : Json = {
"files_scanned": Json::number(summary.files_scanned.to_double()),
"files_with_findings": Json::number(summary.files_with_findings.to_double()),
"total_findings": Json::number(summary.total_findings.to_double()),
"errors": Json::number(summary.error_count.to_double()),
"warnings": Json::number(summary.warning_count.to_double()),
"info": Json::number(summary.info_count.to_double()),
"rules": Json::array(rules_json),
"files": Json::array(files_json),
"cwe_categories": Json::array(cwe_json),
"owasp_mapping": Json::array(owasp_json),
}
obj.stringify(indent=2)
}
///|
pub fn risk_score(summary : ScanSummary) -> Double {
let base = summary.error_count.to_double() * 10.0 +
summary.warning_count.to_double() * 3.0 +
summary.info_count.to_double() * 1.0
let file_ratio = if summary.files_scanned > 0 {
summary.files_with_findings.to_double() / summary.files_scanned.to_double()
} else {
0.0
}
let diversity = summary.cwe_categories.length().to_double()
let score = base * (1.0 + file_ratio * 0.5) * (1.0 + diversity * 0.1)
if score > 100.0 {
100.0
} else {
score
}
}
///|
pub fn risk_level(score : Double) -> String {
if score >= 80.0 {
"CRITICAL"
} else if score >= 50.0 {
"HIGH"
} else if score >= 20.0 {
"MEDIUM"
} else if score > 0.0 {
"LOW"
} else {
"NONE"
}
}