use std::io::Write;
use tirith_core::engine::{self, AnalysisContext};
use tirith_core::extract::ScanContext;
use tirith_core::output;
use tirith_core::scoring::{self, ScoreBreakdown};
use tirith_core::tokenize::ShellType;
pub fn run(url: &str, json: bool, explain: bool) -> i32 {
let ctx = AnalysisContext {
input: url.to_string(),
shell: ShellType::Posix,
scan_context: ScanContext::Exec,
raw_bytes: None,
interactive: true,
cwd: std::env::current_dir()
.ok()
.map(|p| p.display().to_string()),
file_path: None,
repo_root: None,
is_config_override: false,
clipboard_html: None,
card_ref: None,
clipboard_source: tirith_core::clipboard::ClipboardSourceState::Unread,
};
let (verdict, policy) = engine::analyze_returning_policy(&ctx);
let breakdown = scoring::score_verdict(&verdict);
debug_assert!(
breakdown.verify(),
"score breakdown factors must sum to the final score"
);
if json {
print_json(
url,
&verdict,
&breakdown,
explain,
&policy.dlp_custom_patterns,
);
} else {
print_human(
url,
&verdict,
&breakdown,
explain,
&policy.dlp_custom_patterns,
);
}
0
}
fn print_json(
url: &str,
verdict: &tirith_core::verdict::Verdict,
breakdown: &ScoreBreakdown,
explain: bool,
custom_patterns: &[String],
) {
let value = build_json_value(url, verdict, breakdown, explain, custom_patterns);
super::write_json_stdout(&value, "tirith: failed to write JSON output");
}
fn build_json_value(
url: &str,
verdict: &tirith_core::verdict::Verdict,
breakdown: &ScoreBreakdown,
explain: bool,
custom_patterns: &[String],
) -> serde_json::Value {
#[derive(serde::Serialize)]
struct ScoreOutput<'a> {
url: String,
score: u32,
risk_level: &'a str,
findings: &'a [tirith_core::verdict::Finding],
#[serde(skip_serializing_if = "Option::is_none")]
score_breakdown: Option<&'a ScoreBreakdown>,
dlp_redaction_incomplete: bool,
}
let compiled = tirith_core::redact::CompiledCustomPatterns::new_silent(custom_patterns);
let mut display = verdict.clone();
tirith_core::redact::redact_verdict_with_compiled(&mut display, &compiled);
tirith_core::verdict::bound_verdict_for_output(&mut display);
let mut safe_breakdown = breakdown.clone();
for factor in &mut safe_breakdown.factors {
factor.label =
tirith_core::redact::redact_sanitize_redact_with_compiled(&factor.label, &compiled);
factor.detail =
tirith_core::redact::redact_sanitize_redact_with_compiled(&factor.detail, &compiled);
}
let out = ScoreOutput {
url: tirith_core::redact::redact_sanitize_redact_with_compiled(url, &compiled),
score: breakdown.score,
risk_level: breakdown.risk_level,
findings: &display.findings,
score_breakdown: if explain { Some(&safe_breakdown) } else { None },
dlp_redaction_incomplete: compiled.incomplete_reason().is_some(),
};
let mut value = serde_json::to_value(out).unwrap_or_else(|_| {
serde_json::json!({
"presentation_truncated": true,
"analysis_incomplete": true,
"error": "score serialization failed"
})
});
tirith_core::redact::redact_json_strings(&mut value, &compiled);
tirith_core::verdict::bound_json_value_for_output(value)
}
fn print_human(
url: &str,
verdict: &tirith_core::verdict::Verdict,
breakdown: &ScoreBreakdown,
explain: bool,
custom_patterns: &[String],
) {
let mut invocation = output::HumanInvocationWriter::new(
std::io::stderr().lock(),
tirith_core::style::use_color_for(tirith_core::style::Stream::Stderr),
);
let compiled = tirith_core::redact::CompiledCustomPatterns::new_silent(custom_patterns);
if let Some(reason) = compiled.incomplete_reason() {
let _ = writeln!(
invocation,
"tirith score: WARNING: DLP redaction plan is incomplete ({reason}); dynamic output fields were fully redacted."
);
}
let safe_url = output::sanitize_human_field_with_compiled(url, &compiled);
if verdict.findings.is_empty() {
let _ = writeln!(
invocation,
"tirith: {} — no issues found (score: 0/100)",
safe_url
);
} else {
let _ = writeln!(
invocation,
"tirith: {} — risk score: {}/100 ({})",
safe_url, breakdown.score, breakdown.risk_level
);
let _ = output::write_human_to_invocation_with_compiled(
verdict,
false,
&compiled,
&mut invocation,
);
}
if explain {
let _ = write_breakdown_human(breakdown, &compiled, &mut invocation);
}
let _ = invocation.finish();
}
fn write_breakdown_human(
breakdown: &ScoreBreakdown,
compiled: &tirith_core::redact::CompiledCustomPatterns,
w: &mut impl std::io::Write,
) -> std::io::Result<()> {
writeln!(w)?;
writeln!(
w,
" score breakdown (each factor is fixed and inspectable — no model, no learned weights):"
)?;
let mut running: i32 = 0;
for factor in &breakdown.factors {
running += factor.points;
let sign = if factor.points >= 0 { "+" } else { "" };
writeln!(
w,
" {sign}{:<4} {} (running total: {running})",
factor.points,
output::sanitize_human_field_with_compiled(&factor.label, compiled)
)?;
writeln!(
w,
" {}",
output::sanitize_human_field_with_compiled(&factor.detail, compiled)
)?;
}
writeln!(
w,
" = {} / {} ({}) — sum of every factor above",
breakdown.score,
scoring::MAX_SCORE,
breakdown.risk_level
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tirith_core::scoring::ScoreFactor;
use tirith_core::verdict::{Evidence, Finding, RuleId, Severity, Timings, Verdict};
fn render(breakdown: &ScoreBreakdown) -> String {
let mut buf: Vec<u8> = Vec::new();
let compiled = tirith_core::redact::CompiledCustomPatterns::new_silent(&[]);
write_breakdown_human(breakdown, &compiled, &mut buf).expect("write to Vec never fails");
String::from_utf8(buf).expect("breakdown output is valid UTF-8")
}
fn finding(rule_id: RuleId, severity: Severity) -> Finding {
Finding {
rule_id,
severity,
title: "test".to_string(),
description: "test".to_string(),
evidence: vec![Evidence::Text {
detail: "t".to_string(),
}],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}
}
#[test]
fn breakdown_human_renders_clean_zero_finding_url() {
let breakdown = scoring::score_findings(&[]);
assert_eq!(breakdown.score, 0);
let out = render(&breakdown);
assert!(
out.contains("score breakdown"),
"must print the breakdown header: {out}"
);
assert!(
out.contains("+0"),
"a zero-finding breakdown must show a +0 factor: {out}"
);
assert!(
!out.contains(" -"),
"a clean URL has no negative (clamp) factor: {out}"
);
assert!(
out.contains("= 0 / 100"),
"total line must read 0/100 for a clean URL: {out}"
);
assert!(
out.contains("(low)"),
"a 0 score is the 'low' risk bucket: {out}"
);
}
#[test]
fn breakdown_human_renders_negative_clamp_factor() {
let findings: Vec<Finding> = (0..5)
.map(|_| finding(RuleId::CurlPipeShell, Severity::Critical))
.collect();
let breakdown = scoring::score_findings(&findings);
assert_eq!(breakdown.score, 100);
let clamp = breakdown
.factors
.iter()
.find(|f| f.id == "clamp")
.expect("clamp factor must exist when the raw sum exceeds 100");
assert_eq!(clamp.points, -10);
let out = render(&breakdown);
assert!(
out.contains(" -10 "),
"clamp factor must render as a bare -10: {out}"
);
assert!(
!out.contains("+-10"),
"the negative clamp factor must not get a '+' prefix: {out}"
);
assert!(
out.contains("= 100 / 100"),
"total line must read 100/100 after clamping: {out}"
);
assert!(
out.contains("(critical)"),
"a 100 score is the 'critical' risk bucket: {out}"
);
}
#[test]
fn score_json_recursively_redacts_zero_width_split_custom_secret() {
let secret = "C02_SCORE_CUSTOM_RECONSTITUTION_CANARY";
let split = format!("{}\u{200b}{}", &secret[..15], &secret[15..]);
let patterns = vec![regex::escape(secret)];
let finding = Finding {
rule_id: RuleId::NonAsciiHostname,
severity: Severity::Low,
title: split.clone(),
description: format!("nested {split}"),
evidence: vec![Evidence::Url { raw: split.clone() }],
human_view: Some(split.clone()),
agent_view: Some(split.clone()),
mitre_id: None,
custom_rule_id: None,
};
let verdict = Verdict::from_findings(vec![finding], 3, Timings::default());
let mut breakdown = scoring::score_verdict(&verdict);
breakdown.factors.push(ScoreFactor {
id: "projection_regression",
label: split.clone(),
points: 0,
detail: format!("factor {split}"),
});
assert!(breakdown.verify());
let value = build_json_value(
&format!("https://example.test/{split}"),
&verdict,
&breakdown,
true,
&patterns,
);
let rendered = serde_json::to_string(&value).unwrap();
assert!(!rendered.contains(secret), "{rendered}");
assert!(!rendered.contains('\u{200b}'), "{rendered}");
assert!(rendered.matches("[REDACTED:custom]").count() >= 5);
assert!(value["url"]
.as_str()
.is_some_and(|url| url.contains("[REDACTED:custom]")));
assert!(value["findings"][0]["evidence"][0]["raw"]
.as_str()
.is_some_and(|raw| raw.contains("[REDACTED:custom]")));
assert!(value["score_breakdown"]["factors"]
.as_array()
.is_some_and(|factors| factors.iter().any(|factor| factor["label"]
.as_str()
.is_some_and(|label| label.contains("[REDACTED:custom]")))));
}
}