///|
fn json_escape(s : String) -> String {
let mut out = ""
for c in s {
match c {
'"' => out = out + "\\\""
'\\' => out = out + "\\\\"
'\n' => out = out + "\\n"
'\r' => out = out + "\\r"
'\t' => out = out + "\\t"
_ => out = out + c.to_string()
}
}
out
}
///|
fn html_escape(s : String) -> String {
let mut out = ""
for c in s {
match c {
'&' => out = out + "&"
'<' => out = out + "<"
'>' => out = out + ">"
'"' => out = out + """
_ => out = out + c.to_string()
}
}
out
}
///|
fn finding_json(f : Finding) -> String {
let formula = match f.formula {
Some(text) => "\"formula\":\"\{json_escape(text)}\""
None => "\"formula\":null"
}
let related = "[" + f.related.map(fn(x) { "\"" + json_escape(x) + "\"" }).join(",") + "]"
"{\"rule_id\":\"\{f.rule_id}\",\"severity\":\"\{f.severity.label()}\",\"sheet\":\"\{json_escape(f.sheet)}\",\"cell\":\"\{json_escape(f.cell)}\",\{formula},\"message\":\"\{json_escape(f.message)}\",\"related\":\{related}}"
}
///|
pub fn to_json(report : AuditReport) -> String {
let findings = "[" + report.findings.map(finding_json).join(",") + "]"
let sheets = "[" + report.sheets.map(fn(x) { "\"" + json_escape(x) + "\"" }).join(",") + "]"
"{\"tool\":\"sheetsentry\",\"version\":\"0.1.0\",\"file\":\"\{json_escape(report.file)}\",\"sheets\":\{sheets},\"formula_cells\":\{report.formula_cells},\"findings\":\{findings}}"
}
///|
fn sarif_level(severity : Severity) -> String {
match severity {
High => "error"
Medium => "warning"
Low => "note"
}
}
///|
fn sarif_result(f : Finding) -> String {
let related = match f.related {
[] => ""
list => {
let refs = "[" + list.map(fn(x) { "{\"logicalLocations\":[{\"fullyQualifiedName\":\"" + json_escape(x) + "\"}]}" }).join(",") + "]"
",\"relatedLocations\":\{refs}"
}
}
"{\"ruleId\":\"\{f.rule_id}\",\"level\":\"\{sarif_level(f.severity)}\",\"message\":{\"text\":\"\{json_escape(f.message)}\"},\"locations\":[{\"logicalLocations\":[{\"name\":\"\{json_escape(f.sheet)}!\{json_escape(f.cell)}\",\"fullyQualifiedName\":\"\{json_escape(f.sheet)}!\{json_escape(f.cell)}\",\"kind\":\"member\"}]}]\{related}}"
}
///|
pub fn to_sarif(report : AuditReport) -> String {
let results = "[" + report.findings.map(sarif_result).join(",") + "]"
let rules = "[{\"id\":\"SS001\",\"name\":\"ErrorValue\",\"shortDescription\":{\"text\":\"Cell contains an Excel error value\"}},{\"id\":\"SS002\",\"name\":\"FormulaInconsistency\",\"shortDescription\":{\"text\":\"Formula differs from its row or column pattern\"}},{\"id\":\"SS003\",\"name\":\"HardcodedConstant\",\"shortDescription\":{\"text\":\"Formula embeds a numeric constant\"}},{\"id\":\"SS004\",\"name\":\"VolatileFunction\",\"shortDescription\":{\"text\":\"Formula uses a volatile function\"}},{\"id\":\"SS005\",\"name\":\"HiddenFormula\",\"shortDescription\":{\"text\":\"Formula lives on a hidden sheet or row\"}},{\"id\":\"SS006\",\"name\":\"CircularReference\",\"shortDescription\":{\"text\":\"Custom dependency cycle\"}},{\"id\":\"SS007\",\"name\":\"DynamicReference\",\"shortDescription\":{\"text\":\"Formula uses INDIRECT/OFFSET\"}}]"
"{\"$schema\":\"https://json.schemastore.org/sarif-2.1.0.json\",\"version\":\"2.1.0\",\"runs\":[{\"tool\":{\"driver\":{\"name\":\"SheetSentry\",\"version\":\"0.1.0\",\"informationUri\":\"https://github.com/mhlagent/sheetsentry\",\"rules\":\{rules}}},\"results\":\{results}}]}"
}
///|
fn severity_class(severity : Severity) -> String {
match severity {
High => "high"
Medium => "medium"
Low => "low"
}
}
///|
fn same_severity(a : Severity, b : Severity) -> Bool {
match (a, b) {
(High, High) => true
(Medium, Medium) => true
(Low, Low) => true
_ => false
}
}
///|
fn count_severity(report : AuditReport, severity : Severity) -> Int {
let mut count = 0
for finding in report.findings {
if same_severity(finding.severity, severity) {
count = count + 1
}
}
count
}
///|
pub fn to_html(report : AuditReport) -> String {
let mut rows = ""
for finding in report.findings {
let formula = match finding.formula {
Some(text) => html_escape(text)
None => ""
}
let related = finding.related.map(fn(x) { html_escape(x) }).join(", ")
rows = rows + "| \{finding.rule_id} | \{finding.severity.label()} | \{html_escape(finding.sheet)}!\{html_escape(finding.cell)} | \{html_escape(finding.message)} | \{formula} | \{related} |
"
}
let high = count_severity(report, High)
let medium = count_severity(report, Medium)
let low = count_severity(report, Low)
let sheet_count = report.sheets.length()
"SheetSentry reportSheetSentry report
\{html_escape(report.file)} · \{sheet_count} sheets · \{report.formula_cells} formula cells
\{report.findings.length()}findings
\{high}high
\{medium}medium
\{low}low
| Rule | Severity | Cell | Message | Formula | Related |
\{rows}
"
}
///|
pub fn to_terminal(report : AuditReport) -> String {
let mut out = "SheetSentry: \{report.file}\n"
out = out + " sheets: \{report.sheets.length()} formula cells: \{report.formula_cells} findings: \{report.findings.length()}\n"
out = out + " high: \{count_severity(report, High)} medium: \{count_severity(report, Medium)} low: \{count_severity(report, Low)}\n"
for finding in report.findings {
out = out + " [\{finding.severity.label()}] \{finding.rule_id} \{finding.sheet}!\{finding.cell} \{finding.message}\n"
}
if report.findings.length() == 0 {
out = out + " No findings.\n"
}
out
}