///|
/// Additional renderers for audit reports and profile assessments.
pub(all) enum ReportFormat {
Markdown
PlainText
Checklist
Json
Sarif
} derive(Eq, Debug)
///|
pub(all) struct RenderOptions {
title : String
limit : Int
include_evidence : Bool
include_remediation : Bool
sort_by_severity : Bool
} derive(Eq, Debug)
///|
pub fn default_render_options() -> RenderOptions {
{
title: "Security Header Audit",
limit: 50,
include_evidence: true,
include_remediation: true,
sort_by_severity: true,
}
}
///|
pub fn no_detail_render_options() -> RenderOptions {
{
title: "Security Header Audit",
limit: 20,
include_evidence: false,
include_remediation: false,
sort_by_severity: true,
}
}
///|
pub fn AuditReport::render(
self : AuditReport,
format : ReportFormat,
options? : RenderOptions = default_render_options(),
) -> String {
match format {
Markdown => self.to_detailed_markdown(options)
PlainText => self.to_plain_text(options)
Checklist => self.to_checklist_markdown(options)
Json => self.to_json_string()
Sarif => self.to_sarif_string(options)
}
}
///|
pub fn AuditReport::to_detailed_markdown(
self : AuditReport,
options : RenderOptions,
) -> String {
let findings = self.rendered_findings(options)
let lines : Array[String] = []
lines.push("# " + options.title)
lines.push("")
lines.push("- Score: " + self.score.to_string())
lines.push("- Grade: " + self.grade)
lines.push("- Findings: " + findings.length().to_string())
lines.push("")
lines.push("| Severity | Header | Message |")
lines.push("| --- | --- | --- |")
for finding in findings {
lines.push(finding.to_markdown_row())
if options.include_evidence {
lines.push("")
lines.push(" - Evidence: " + non_empty_or_dash(finding.evidence))
}
if options.include_remediation {
lines.push(" - Remediation: " + non_empty_or_dash(finding.remediation))
}
}
lines.join("\n")
}
///|
pub fn AuditReport::to_plain_text(
self : AuditReport,
options : RenderOptions,
) -> String {
let findings = self.rendered_findings(options)
let lines : Array[String] = []
lines.push(options.title)
lines.push("score=" + self.score.to_string() + ", grade=" + self.grade)
lines.push("findings=" + findings.length().to_string())
for finding in findings {
lines.push(finding.to_plain_text_line())
if options.include_evidence {
lines.push(" evidence: " + non_empty_or_dash(finding.evidence))
}
if options.include_remediation {
lines.push(" remediation: " + non_empty_or_dash(finding.remediation))
}
}
lines.join("\n")
}
///|
pub fn AuditReport::to_checklist_markdown(
self : AuditReport,
options : RenderOptions,
) -> String {
let findings = self.rendered_findings(options)
let lines : Array[String] = []
lines.push("# " + options.title + " Checklist")
lines.push("")
lines.push("- [x] Score " + self.score.to_string())
lines.push("- [x] Grade " + self.grade)
lines.push("- [x] Findings " + findings.length().to_string())
lines.push("")
for finding in findings {
lines.push(
"- [ ] " +
finding.severity.label() +
" " +
finding.header +
": " +
finding.message,
)
if options.include_remediation {
lines.push(" - Fix: " + non_empty_or_dash(finding.remediation))
}
}
lines.join("\n")
}
///|
pub fn AuditReport::to_sarif_string(
self : AuditReport,
options : RenderOptions,
) -> String {
let findings = self.rendered_findings(options)
let results : Array[Json] = []
for finding in findings {
results.push(finding_to_sarif_json(finding))
}
let sarif = Json::object(
Map([
("version", Json::string("2.1.0")),
(
"runs",
Json::array([
Json::object(
Map([
(
"tool",
Json::object(
Map([
(
"driver",
Json::object(
Map([
("name", Json::string("moonsec-headers")),
(
"informationUri",
Json::string(
"https://github.com/HYF-ai2006/moonsec-headers",
),
),
]),
),
),
]),
),
),
("results", Json::array(results)),
]),
),
]),
),
]),
)
sarif.stringify(indent=2)
}
///|
pub fn Finding::to_markdown_row(self : Finding) -> String {
"| " +
self.severity.label() +
" | " +
self.header +
" | " +
self.message +
" |"
}
///|
pub fn Finding::to_plain_text_line(self : Finding) -> String {
self.severity.label() + " " + self.header + ": " + self.message
}
///|
fn finding_to_sarif_json(finding : Finding) -> Json {
Json::object(
Map([
("ruleId", Json::string(finding.id)),
("level", Json::string(finding.severity.label())),
("message", Json::object(Map([("text", Json::string(finding.message))]))),
(
"properties",
Json::object(
Map([
("header", Json::string(finding.header)),
("evidence", Json::string(finding.evidence)),
("remediation", Json::string(finding.remediation)),
]),
),
),
]),
)
}
///|
pub fn AuditReport::rendered_findings(
self : AuditReport,
options : RenderOptions,
) -> Array[Finding] {
let findings = self.findings.copy()
if options.sort_by_severity {
findings.sort_by((a, b) => b.severity.weight() - a.severity.weight())
}
if findings.length() > options.limit {
ignore(findings.drain(options.limit, findings.length()))
}
findings
}
///|
pub fn AuditReport::top_findings(
self : AuditReport,
limit : Int,
) -> Array[Finding] {
let options = default_render_options()
let findings = self.rendered_findings({
title: options.title,
limit,
include_evidence: options.include_evidence,
include_remediation: options.include_remediation,
sort_by_severity: true,
})
findings
}
///|
pub fn AuditReport::findings_by_header(
self : AuditReport,
name : StringView,
) -> Array[Finding] {
let expected = normalize_header_name(name)
self.findings.filter(f => f.header == expected)
}
///|
pub fn AuditReport::findings_by_severity(
self : AuditReport,
severity : Severity,
) -> Array[Finding] {
self.findings.filter(f => f.severity == severity)
}
///|
pub fn AuditReport::severity_breakdown(self : AuditReport) -> String {
let critical = self.count_by_severity(Critical)
let high = self.count_by_severity(High)
let medium = self.count_by_severity(Medium)
let low = self.count_by_severity(Low)
let info = self.count_by_severity(Info)
"critical=" +
critical.to_string() +
", high=" +
high.to_string() +
", medium=" +
medium.to_string() +
", low=" +
low.to_string() +
", info=" +
info.to_string()
}
///|
pub fn AuditReport::to_issue_summary(self : AuditReport) -> String {
let lines : Array[String] = []
lines.push("# Issue Summary")
lines.push("")
lines.push("- Breakdown: " + self.severity_breakdown())
lines.push("")
for finding in self.findings {
lines.push("- " + finding.id + " -> " + finding.to_plain_text_line())
}
lines.join("\n")
}
///|
fn non_empty_or_dash(value : String) -> String {
if value.is_empty() {
"-"
} else {
value
}
}
///|
pub fn ProfileAssessment::to_plain_text(self : ProfileAssessment) -> String {
let lines : Array[String] = []
lines.push("Profile " + self.profile_key)
lines.push("score=" + self.score.to_string())
lines.push(self.summary())
for finding in self.findings {
lines.push(
finding.name +
" " +
finding.status.label() +
" " +
finding.severity.label(),
)
}
lines.join("\n")
}
///|
pub fn ProfileAssessment::to_checklist(self : ProfileAssessment) -> String {
let lines : Array[String] = []
lines.push("# Profile Checklist")
lines.push("")
for finding in self.findings {
lines.push("- [ ] " + finding.name + " " + finding.status.label())
}
lines.join("\n")
}
///|
pub fn ProfileAssessment::to_json_string(self : ProfileAssessment) -> String {
let items : Array[Json] = []
for finding in self.findings {
items.push(
Json::object(
Map([
("name", Json::string(finding.name)),
("status", Json::string(finding.status.label())),
("expected", Json::string(finding.expected)),
("actual", Json::string(option_text(finding.actual))),
("severity", Json::string(finding.severity.label())),
("rationale", Json::string(finding.rationale)),
]),
),
)
}
Json::object(
Map([
("profile_key", Json::string(self.profile_key)),
(
"score",
Json::number(self.score.to_double(), repr=self.score.to_string()),
),
(
"matched",
Json::number(self.matched.to_double(), repr=self.matched.to_string()),
),
(
"missing",
Json::number(self.missing.to_double(), repr=self.missing.to_string()),
),
("weak", Json::number(self.weak.to_double(), repr=self.weak.to_string())),
(
"conflict",
Json::number(self.conflict.to_double(), repr=self.conflict.to_string()),
),
(
"optional",
Json::number(self.optional.to_double(), repr=self.optional.to_string()),
),
("findings", Json::array(items)),
]),
).stringify(indent=2)
}
///|
pub fn ProfileAssessment::recommendations(
self : ProfileAssessment,
) -> Array[String] {
let lines : Array[String] = []
for finding in self.findings {
match finding.status {
Missing => lines.push("Add " + finding.name + ": " + finding.expected)
Weak => lines.push("Tighten " + finding.name + ": " + finding.expected)
Conflict => lines.push("Remove or change " + finding.name)
Satisfied => ()
Optional => ()
}
}
lines
}
///|
pub fn ProfileAssessment::to_recommendations_markdown(
self : ProfileAssessment,
) -> String {
let lines : Array[String] = []
lines.push("# Recommendations")
lines.push("")
for item in self.recommendations() {
lines.push("- " + item)
}
lines.join("\n")
}