ci/verbs/src/scorecard.rs
//! `ci-verbs scorecard [--checkout <dir>] [--out <dir>] [--bin <scorecard>]` (the checkout
//! else `$DWARVES_CI_CHECKOUT`, which the dwarves forge sets for a CI run): the OpenSSF
//! Scorecard of a checkout in LOCAL mode (`scorecard --local <dir> --format json`), as a
//! step the dwarves forge runs nightly (a Runa `Xtask(cmd: "scorecard", alias: "ci-verbs")`).
//!
//! It writes `scorecard.json` (Scorecard's own answer) and `summary.md` (the score, each
//! check's score and reason, and the checks local mode does not run) into `--out`, else
//! `$NORNIR_CI_OUT`, else the current directory. The binary is `--bin`, else
//! `$SCORECARD_BIN`, else `scorecard` on `PATH`. A low score is not a failed step: the
//! step fails only when Scorecard could not run or did not answer JSON.
use std::path::{Path, PathBuf};
use serde_json::Value;
/// Every check Scorecard v5 knows. Local mode runs only those that read the files;
/// the rest need a forge API (GitHub or GitLab) and are named on the page as not run.
pub const KNOWN_CHECKS: [&str; 20] = [
"Binary-Artifacts",
"Branch-Protection",
"CI-Tests",
"CII-Best-Practices",
"Code-Review",
"Contributors",
"Dangerous-Workflow",
"Dependency-Update-Tool",
"Fuzzing",
"License",
"Maintained",
"Packaging",
"Pinned-Dependencies",
"SAST",
"SBOM",
"Security-Policy",
"Signed-Releases",
"Token-Permissions",
"Vulnerabilities",
"Webhooks",
];
/// The known checks a Scorecard answer has no row for (local mode cannot run them).
pub fn not_run(answer: &Value) -> Vec<&'static str> {
let ran: Vec<&str> = answer["checks"].as_array().map(|c| c.iter().filter_map(|x| x["name"].as_str()).collect()).unwrap_or_default();
KNOWN_CHECKS.iter().copied().filter(|k| !ran.contains(k)).collect()
}
fn cell(s: &str) -> String {
s.replace('|', "\\|").replace('\n', " ")
}
/// **The summary of one Scorecard answer**, in Markdown: the aggregate, a row per check
/// that ran (`-1` = inconclusive: Scorecard found nothing to judge), and the checks local
/// mode does not run.
pub fn summary(answer: &Value, repo: &str) -> String {
let score = answer["score"].as_f64().map(|s| format!("{s:.1}")).unwrap_or_else(|| "?".into());
let version = answer["scorecard"]["version"].as_str().unwrap_or("?");
let commit = answer["repo"]["commit"].as_str().unwrap_or("");
let mut s = format!(
"OpenSSF Scorecard {version}, local mode, on `{repo}` at `{}`: **{score} / 10** (the aggregate of the checks local mode runs).\n\n| check | score | reason |\n|---|---|---|\n",
commit.get(..8).unwrap_or(commit)
);
if let Some(checks) = answer["checks"].as_array() {
for c in checks {
let sc = match c["score"].as_i64() {
Some(-1) => "inconclusive".to_string(),
Some(n) => format!("{n} / 10"),
None => "?".into(),
};
s.push_str(&format!("| {} | {sc} | {} |\n", cell(c["name"].as_str().unwrap_or("?")), cell(c["reason"].as_str().unwrap_or(""))));
}
}
let missing = not_run(answer);
if !missing.is_empty() {
s.push_str(&format!(
"\nNot run in local mode (they need a GitHub or GitLab API): {}.\n",
missing.join(", ")
));
}
s
}
fn value(args: &[String], flag: &str) -> Option<String> {
crate::value(args, flag)
}
/// The verb.
pub fn verb(args: &[String]) -> i32 {
let named = value(args, "--checkout").or_else(|| std::env::var("DWARVES_CI_CHECKOUT").ok().filter(|v| !v.is_empty()));
let Some(checkout) = named.map(PathBuf::from) else {
eprintln!("usage: ci-verbs scorecard --checkout <dir> [--out <dir>] [--bin <scorecard>]");
return 2;
};
let checkout = checkout.canonicalize().unwrap_or(checkout);
let out = value(args, "--out")
.or_else(|| std::env::var("NORNIR_CI_OUT").ok().filter(|v| !v.is_empty()))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let bin = value(args, "--bin").or_else(|| std::env::var("SCORECARD_BIN").ok().filter(|v| !v.is_empty())).unwrap_or_else(|| "scorecard".into());
let repo = checkout.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
match run(&bin, &checkout, &out, &repo) {
Ok(line) => {
println!("{line}");
0
}
Err(e) => {
eprintln!("ci-verbs scorecard: {e}");
1
}
}
}
fn run(bin: &str, checkout: &Path, out: &Path, repo: &str) -> Result<String, String> {
std::fs::create_dir_all(out).map_err(|e| format!("{}: {e}", out.display()))?;
println!("ci-verbs scorecard: {bin} --local {} --format json", checkout.display());
let o = std::process::Command::new(bin)
.arg("--local")
.arg(checkout)
.args(["--format", "json"])
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| format!("could not run `{bin}`: {e} (set SCORECARD_BIN or pass --bin)"))?;
let stderr = String::from_utf8_lossy(&o.stderr);
if !stderr.trim().is_empty() {
let _ = std::fs::write(out.join("scorecard.stderr.txt"), stderr.as_bytes());
}
let answer: Value = serde_json::from_slice(&o.stdout)
.map_err(|e| format!("`{bin}` exited {} and answered no JSON ({e}): {}", o.status, stderr.lines().last().unwrap_or("")))?;
let mut answer = answer;
// local mode answers `unknown` for the commit: the checkout says which one it is
if answer["repo"]["commit"].as_str().is_none_or(|c| c.is_empty() || c == "unknown") {
if let Some(head) = std::process::Command::new("git")
.arg("-C")
.arg(checkout)
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
{
answer["repo"]["commit"] = Value::String(head);
}
}
std::fs::write(out.join("scorecard.json"), serde_json::to_vec_pretty(&answer).unwrap_or_default())
.map_err(|e| format!("scorecard.json: {e}"))?;
let md = summary(&answer, repo);
std::fs::write(out.join("summary.md"), &md).map_err(|e| format!("summary.md: {e}"))?;
Ok(format!(
"ci-verbs scorecard: {repo} {} / 10 → {}",
answer["score"].as_f64().map(|s| format!("{s:.1}")).unwrap_or_else(|| "?".into()),
out.join("summary.md").display()
))
}