///|
pub(all) enum ExposureLevel {
Clean
Low
Medium
High
Severe
} derive(Debug, Eq, Compare)
///|
pub(all) struct SeverityTally {
mut info : Int
mut warnings : Int
mut dangerous : Int
mut critical : Int
} derive(Debug, Eq)
///|
pub(all) struct DecisionTally {
mut kept : Int
mut dropped : Int
mut marked : Int
} derive(Debug, Eq)
///|
pub(all) struct RiskTally {
risk : RiskKind
mut count : Int
mut info : Int
mut warnings : Int
mut dangerous : Int
mut critical : Int
mut kept : Int
mut dropped : Int
mut marked : Int
mut first_offset : Int
mut last_offset : Int
} derive(Debug, Eq)
///|
pub(all) struct RuleTally {
rule_id : String
risk : RiskKind
mut count : Int
mut info : Int
mut warnings : Int
mut dangerous : Int
mut critical : Int
mut kept : Int
mut dropped : Int
mut marked : Int
mut first_offset : Int
mut last_offset : Int
} derive(Debug, Eq)
///|
pub(all) struct ProfileScore {
points : Int
level : ExposureLevel
label : String
} derive(Debug, Eq)
///|
pub(all) struct AuditProfile {
total_findings : Int
total_events : Int
text_chars : Int
control_events : Int
severity : SeverityTally
decisions : DecisionTally
risks : Array[RiskTally]
rules : Array[RuleTally]
highest_severity : Severity
score : ProfileScore
} derive(Debug, Eq)
///|
pub fn ExposureLevel::name(self : ExposureLevel) -> String {
match self {
Clean => "clean"
Low => "low"
Medium => "medium"
High => "high"
Severe => "severe"
}
}
///|
pub fn exposure_level_name(level : ExposureLevel) -> String {
level.name()
}
///|
pub fn ExposureLevel::description(self : ExposureLevel) -> String {
match self {
Clean => "no terminal-control risk was detected"
Low => "only low-impact terminal controls were detected"
Medium => "reviewable controls were detected and should be sanitized"
High => "dangerous terminal behavior was detected"
Severe => "critical terminal behavior was detected and should block release"
}
}
///|
pub fn exposure_level_description(level : ExposureLevel) -> String {
level.description()
}
///|
fn empty_severity_tally() -> SeverityTally {
{ info: 0, warnings: 0, dangerous: 0, critical: 0 }
}
///|
fn empty_decision_tally() -> DecisionTally {
{ kept: 0, dropped: 0, marked: 0 }
}
///|
pub fn SeverityTally::total(self : SeverityTally) -> Int {
self.info + self.warnings + self.dangerous + self.critical
}
///|
pub fn SeverityTally::highest(self : SeverityTally) -> Severity {
if self.critical > 0 {
Critical
} else if self.dangerous > 0 {
Dangerous
} else if self.warnings > 0 {
Warning
} else {
Info
}
}
///|
pub fn DecisionTally::total(self : DecisionTally) -> Int {
self.kept + self.dropped + self.marked
}
///|
fn increment_severity(tally : SeverityTally, severity : Severity) -> Unit {
match severity {
Info => tally.info += 1
Warning => tally.warnings += 1
Dangerous => tally.dangerous += 1
Critical => tally.critical += 1
}
}
///|
fn increment_decision(tally : DecisionTally, decision : Decision) -> Unit {
match decision {
Keep => tally.kept += 1
Drop => tally.dropped += 1
Mark => tally.marked += 1
}
}
///|
fn severity_points(severity : Severity) -> Int {
match severity {
Info => 1
Warning => 5
Dangerous => 20
Critical => 50
}
}
///|
fn decision_points(decision : Decision) -> Int {
match decision {
Keep => 3
Drop => 0
Mark => 1
}
}
///|
fn risk_weight(risk : RiskKind) -> Int {
match risk {
Styling => 0
CursorMovement => 4
ScreenRewrite => 5
ScreenErase => 6
TerminalQuery => 2
ModeChange => 5
TitleChange => 4
Hyperlink => 7
ClipboardWrite => 10
Notification => 3
WorkingDirectory => 2
FileTransfer => 10
DeviceControl => 9
BidirectionalText => 8
UnknownControl => 5
MalformedSequence => 6
}
}
///|
fn score_level(points : Int, highest : Severity) -> ExposureLevel {
if highest == Critical || points >= 80 {
Severe
} else if highest == Dangerous || points >= 35 {
High
} else if highest == Warning || points >= 10 {
Medium
} else if points > 0 {
Low
} else {
Clean
}
}
///|
fn score_label(level : ExposureLevel) -> String {
match level {
Clean => "clean"
Low => "low terminal-noise risk"
Medium => "review required"
High => "dangerous log manipulation"
Severe => "critical terminal injection"
}
}
///|
fn make_score(points : Int, highest : Severity) -> ProfileScore {
let level = score_level(points, highest)
{ points, level, label: score_label(level) }
}
///|
fn empty_risk_tally(risk : RiskKind, offset : Int) -> RiskTally {
{
risk,
count: 0,
info: 0,
warnings: 0,
dangerous: 0,
critical: 0,
kept: 0,
dropped: 0,
marked: 0,
first_offset: offset,
last_offset: offset,
}
}
///|
fn empty_rule_tally(
rule_id : String,
risk : RiskKind,
offset : Int,
) -> RuleTally {
{
rule_id,
risk,
count: 0,
info: 0,
warnings: 0,
dangerous: 0,
critical: 0,
kept: 0,
dropped: 0,
marked: 0,
first_offset: offset,
last_offset: offset,
}
}
///|
fn update_risk_tally(tally : RiskTally, finding : Finding) -> RiskTally {
let next = tally
next.count += 1
match finding.severity {
Info => next.info += 1
Warning => next.warnings += 1
Dangerous => next.dangerous += 1
Critical => next.critical += 1
}
match finding.decision {
Keep => next.kept += 1
Drop => next.dropped += 1
Mark => next.marked += 1
}
if finding.offset < next.first_offset {
next.first_offset = finding.offset
}
if finding.offset > next.last_offset {
next.last_offset = finding.offset
}
next
}
///|
fn update_rule_tally(tally : RuleTally, finding : Finding) -> RuleTally {
let next = tally
next.count += 1
match finding.severity {
Info => next.info += 1
Warning => next.warnings += 1
Dangerous => next.dangerous += 1
Critical => next.critical += 1
}
match finding.decision {
Keep => next.kept += 1
Drop => next.dropped += 1
Mark => next.marked += 1
}
if finding.offset < next.first_offset {
next.first_offset = finding.offset
}
if finding.offset > next.last_offset {
next.last_offset = finding.offset
}
next
}
///|
fn risk_index(items : Array[RiskTally], risk : RiskKind) -> Int {
let mut i = 0
while i < items.length() {
if items[i].risk == risk {
return i
}
i += 1
}
-1
}
///|
fn rule_index(items : Array[RuleTally], rule_id : String) -> Int {
let mut i = 0
while i < items.length() {
if items[i].rule_id == rule_id {
return i
}
i += 1
}
-1
}
///|
fn add_risk_finding(items : Array[RiskTally], finding : Finding) -> Unit {
let index = risk_index(items, finding.risk)
if index < 0 {
items.push(
update_risk_tally(empty_risk_tally(finding.risk, finding.offset), finding),
)
} else {
items[index] = update_risk_tally(items[index], finding)
}
}
///|
fn add_rule_finding(items : Array[RuleTally], finding : Finding) -> Unit {
let index = rule_index(items, finding.rule_id)
if index < 0 {
items.push(
update_rule_tally(
empty_rule_tally(finding.rule_id, finding.risk, finding.offset),
finding,
),
)
} else {
items[index] = update_rule_tally(items[index], finding)
}
}
///|
fn compute_profile_points(findings : Array[Finding]) -> Int {
let mut points = 0
for finding in findings {
points += severity_points(finding.severity)
points += risk_weight(finding.risk)
points += decision_points(finding.decision)
}
points
}
///|
pub fn profile_audit(result : AuditResult) -> AuditProfile {
let severity = empty_severity_tally()
let decisions = empty_decision_tally()
let risks : Array[RiskTally] = []
let rules : Array[RuleTally] = []
for finding in result.findings {
increment_severity(severity, finding.severity)
increment_decision(decisions, finding.decision)
add_risk_finding(risks, finding)
add_rule_finding(rules, finding)
}
let highest = severity.highest()
let points = compute_profile_points(result.findings)
{
total_findings: result.findings.length(),
total_events: result.events.length(),
text_chars: result.stats.text_chars,
control_events: result.stats.control_events,
severity,
decisions,
risks,
rules,
highest_severity: highest,
score: make_score(points, highest),
}
}
///|
pub fn AuditResult::profile(self : AuditResult) -> AuditProfile {
profile_audit(self)
}
///|
pub fn risk_tally(profile : AuditProfile, risk : RiskKind) -> RiskTally? {
for item in profile.risks {
if item.risk == risk {
return Some(item)
}
}
None
}
///|
pub fn rule_tally(profile : AuditProfile, rule_id : String) -> RuleTally? {
for item in profile.rules {
if item.rule_id == rule_id {
return Some(item)
}
}
None
}
///|
pub fn risk_count(profile : AuditProfile, risk : RiskKind) -> Int {
match risk_tally(profile, risk) {
Some(item) => item.count
None => 0
}
}
///|
pub fn rule_count(profile : AuditProfile, rule_id : String) -> Int {
match rule_tally(profile, rule_id) {
Some(item) => item.count
None => 0
}
}
///|
fn tally_highest(
_info : Int,
warnings : Int,
dangerous : Int,
critical : Int,
) -> Severity {
if critical > 0 {
Critical
} else if dangerous > 0 {
Dangerous
} else if warnings > 0 {
Warning
} else {
Info
}
}
///|
pub fn RiskTally::highest(self : RiskTally) -> Severity {
tally_highest(self.info, self.warnings, self.dangerous, self.critical)
}
///|
pub fn RuleTally::highest(self : RuleTally) -> Severity {
tally_highest(self.info, self.warnings, self.dangerous, self.critical)
}
///|
fn risk_tally_score(item : RiskTally) -> Int {
item.count * 10 + severity_rank(item.highest()) * 100 + risk_weight(item.risk)
}
///|
fn rule_tally_score(item : RuleTally) -> Int {
item.count * 10 + severity_rank(item.highest()) * 100 + risk_weight(item.risk)
}
///|
pub fn top_risk(profile : AuditProfile) -> RiskTally? {
if profile.risks.length() == 0 {
return None
}
let mut best = profile.risks[0]
let mut best_score = risk_tally_score(best)
let mut i = 1
while i < profile.risks.length() {
let score = risk_tally_score(profile.risks[i])
if score > best_score {
best = profile.risks[i]
best_score = score
}
i += 1
}
Some(best)
}
///|
pub fn top_rule(profile : AuditProfile) -> RuleTally? {
if profile.rules.length() == 0 {
return None
}
let mut best = profile.rules[0]
let mut best_score = rule_tally_score(best)
let mut i = 1
while i < profile.rules.length() {
let score = rule_tally_score(profile.rules[i])
if score > best_score {
best = profile.rules[i]
best_score = score
}
i += 1
}
Some(best)
}
///|
pub fn AuditProfile::has_critical(self : AuditProfile) -> Bool {
self.severity.critical > 0
}
///|
pub fn AuditProfile::has_dangerous(self : AuditProfile) -> Bool {
self.severity.dangerous > 0 || self.severity.critical > 0
}
///|
pub fn AuditProfile::is_clean(self : AuditProfile) -> Bool {
self.total_findings == 0
}
///|
pub fn AuditProfile::blocking(self : AuditProfile) -> Bool {
self.has_critical() || self.score.level == Severe
}
///|
pub fn AuditProfile::summary(self : AuditProfile) -> String {
if self.is_clean() {
return "clean: no terminal-control findings"
}
let top = match top_risk(self) {
Some(item) => risk_name(item.risk)
None => "none"
}
"\{self.score.label}: \{self.total_findings} finding(s), " +
"highest=\{severity_name(self.highest_severity)}, top-risk=\{top}, " +
"score=\{self.score.points}"
}
///|
pub fn profile_summary(profile : AuditProfile) -> String {
profile.summary()
}
///|
fn write_profile_tally_json(out : StringBuilder, tally : SeverityTally) -> Unit {
out.write_string("{\"info\":")
out.write_string(tally.info.to_string())
out.write_string(",\"warnings\":")
out.write_string(tally.warnings.to_string())
out.write_string(",\"dangerous\":")
out.write_string(tally.dangerous.to_string())
out.write_string(",\"critical\":")
out.write_string(tally.critical.to_string())
out.write_char('}')
}
///|
fn write_decision_tally_json(
out : StringBuilder,
tally : DecisionTally,
) -> Unit {
out.write_string("{\"kept\":")
out.write_string(tally.kept.to_string())
out.write_string(",\"dropped\":")
out.write_string(tally.dropped.to_string())
out.write_string(",\"marked\":")
out.write_string(tally.marked.to_string())
out.write_char('}')
}
///|
fn write_risk_tally_json(out : StringBuilder, item : RiskTally) -> Unit {
out.write_string("{\"risk\":")
out.write_string(json_quote(risk_name(item.risk)))
out.write_string(",\"count\":")
out.write_string(item.count.to_string())
out.write_string(",\"highest\":")
out.write_string(json_quote(severity_name(item.highest())))
out.write_string(",\"info\":")
out.write_string(item.info.to_string())
out.write_string(",\"warnings\":")
out.write_string(item.warnings.to_string())
out.write_string(",\"dangerous\":")
out.write_string(item.dangerous.to_string())
out.write_string(",\"critical\":")
out.write_string(item.critical.to_string())
out.write_string(",\"kept\":")
out.write_string(item.kept.to_string())
out.write_string(",\"dropped\":")
out.write_string(item.dropped.to_string())
out.write_string(",\"marked\":")
out.write_string(item.marked.to_string())
out.write_string(",\"first_offset\":")
out.write_string(item.first_offset.to_string())
out.write_string(",\"last_offset\":")
out.write_string(item.last_offset.to_string())
out.write_char('}')
}
///|
fn write_rule_tally_json(out : StringBuilder, item : RuleTally) -> Unit {
out.write_string("{\"rule_id\":")
out.write_string(json_quote(item.rule_id))
out.write_string(",\"risk\":")
out.write_string(json_quote(risk_name(item.risk)))
out.write_string(",\"count\":")
out.write_string(item.count.to_string())
out.write_string(",\"highest\":")
out.write_string(json_quote(severity_name(item.highest())))
out.write_string(",\"info\":")
out.write_string(item.info.to_string())
out.write_string(",\"warnings\":")
out.write_string(item.warnings.to_string())
out.write_string(",\"dangerous\":")
out.write_string(item.dangerous.to_string())
out.write_string(",\"critical\":")
out.write_string(item.critical.to_string())
out.write_string(",\"kept\":")
out.write_string(item.kept.to_string())
out.write_string(",\"dropped\":")
out.write_string(item.dropped.to_string())
out.write_string(",\"marked\":")
out.write_string(item.marked.to_string())
out.write_string(",\"first_offset\":")
out.write_string(item.first_offset.to_string())
out.write_string(",\"last_offset\":")
out.write_string(item.last_offset.to_string())
out.write_char('}')
}
///|
pub fn profile_json(profile : AuditProfile) -> String {
let out = StringBuilder::new()
out.write_string("{\"schema\":\"urn:termproof:profile-v1\"")
out.write_string(",\"total_findings\":")
out.write_string(profile.total_findings.to_string())
out.write_string(",\"total_events\":")
out.write_string(profile.total_events.to_string())
out.write_string(",\"text_chars\":")
out.write_string(profile.text_chars.to_string())
out.write_string(",\"control_events\":")
out.write_string(profile.control_events.to_string())
out.write_string(",\"highest_severity\":")
out.write_string(json_quote(severity_name(profile.highest_severity)))
out.write_string(",\"score\":{\"points\":")
out.write_string(profile.score.points.to_string())
out.write_string(",\"level\":")
out.write_string(json_quote(profile.score.level.name()))
out.write_string(",\"label\":")
out.write_string(json_quote(profile.score.label))
out.write_char('}')
out.write_string(",\"severity\":")
write_profile_tally_json(out, profile.severity)
out.write_string(",\"decisions\":")
write_decision_tally_json(out, profile.decisions)
out.write_string(",\"risks\":[")
let mut first = true
for item in profile.risks {
if !first {
out.write_char(',')
}
first = false
write_risk_tally_json(out, item)
}
out.write_string("],\"rules\":[")
first = true
for item in profile.rules {
if !first {
out.write_char(',')
}
first = false
write_rule_tally_json(out, item)
}
out.write_string("]}")
out.to_string()
}
///|
fn write_markdown_metric(
out : StringBuilder,
label : String,
value : String,
) -> Unit {
out.write_string("| ")
out.write_string(markdown_escape(label))
out.write_string(" | ")
out.write_string(markdown_escape(value))
out.write_string(" |\n")
}
///|
fn write_risk_markdown_row(out : StringBuilder, item : RiskTally) -> Unit {
out.write_string("| ")
out.write_string(markdown_escape(risk_name(item.risk)))
out.write_string(" | ")
out.write_string(item.count.to_string())
out.write_string(" | ")
out.write_string(markdown_escape(severity_name(item.highest())))
out.write_string(" | ")
out.write_string(item.first_offset.to_string())
out.write_string(" | ")
out.write_string(item.last_offset.to_string())
out.write_string(" |\n")
}
///|
fn write_rule_markdown_row(out : StringBuilder, item : RuleTally) -> Unit {
out.write_string("| ")
out.write_string(markdown_escape(item.rule_id))
out.write_string(" | ")
out.write_string(markdown_escape(risk_name(item.risk)))
out.write_string(" | ")
out.write_string(item.count.to_string())
out.write_string(" | ")
out.write_string(markdown_escape(severity_name(item.highest())))
out.write_string(" | ")
out.write_string(item.first_offset.to_string())
out.write_string(" |\n")
}
///|
pub fn profile_markdown(profile : AuditProfile) -> String {
let out = StringBuilder::new()
out.write_string("## Risk profile\n\n")
out.write_string("| Metric | Value |\n|---|---:|\n")
write_markdown_metric(
out,
"Total findings",
profile.total_findings.to_string(),
)
write_markdown_metric(
out,
"Control events",
profile.control_events.to_string(),
)
write_markdown_metric(
out,
"Highest severity",
severity_name(profile.highest_severity),
)
write_markdown_metric(out, "Exposure level", profile.score.level.name())
write_markdown_metric(out, "Score", profile.score.points.to_string())
write_markdown_metric(
out,
"Kept decisions",
profile.decisions.kept.to_string(),
)
write_markdown_metric(
out,
"Dropped decisions",
profile.decisions.dropped.to_string(),
)
write_markdown_metric(
out,
"Marked decisions",
profile.decisions.marked.to_string(),
)
if profile.risks.length() > 0 {
out.write_string("\n### Risks\n\n")
out.write_string(
"| Risk | Count | Highest | First offset | Last offset |\n",
)
out.write_string("|---|---:|---|---:|---:|\n")
for item in profile.risks {
write_risk_markdown_row(out, item)
}
}
if profile.rules.length() > 0 {
out.write_string("\n### Rules\n\n")
out.write_string("| Rule | Risk | Count | Highest | First offset |\n")
out.write_string("|---|---|---:|---|---:|\n")
for item in profile.rules {
write_rule_markdown_row(out, item)
}
}
out.to_string()
}
///|
pub fn profile_text(profile : AuditProfile) -> String {
let out = StringBuilder::new()
out.write_string(profile.summary())
out.write_char('\n')
out.write_string("severity: info=")
out.write_string(profile.severity.info.to_string())
out.write_string(", warning=")
out.write_string(profile.severity.warnings.to_string())
out.write_string(", dangerous=")
out.write_string(profile.severity.dangerous.to_string())
out.write_string(", critical=")
out.write_string(profile.severity.critical.to_string())
out.write_char('\n')
out.write_string("decisions: kept=")
out.write_string(profile.decisions.kept.to_string())
out.write_string(", dropped=")
out.write_string(profile.decisions.dropped.to_string())
out.write_string(", marked=")
out.write_string(profile.decisions.marked.to_string())
out.write_char('\n')
if profile.risks.length() > 0 {
out.write_string("risks:\n")
for item in profile.risks {
out.write_string("- ")
out.write_string(risk_name(item.risk))
out.write_string(": ")
out.write_string(item.count.to_string())
out.write_string(" (")
out.write_string(severity_name(item.highest()))
out.write_string(")\n")
}
}
out.to_string()
}
///|
pub fn profile_has_risk(profile : AuditProfile, risk : RiskKind) -> Bool {
risk_count(profile, risk) > 0
}
///|
pub fn profile_has_rule(profile : AuditProfile, rule_id : String) -> Bool {
rule_count(profile, rule_id) > 0
}
///|
pub fn profile_release_gate(profile : AuditProfile) -> String {
if profile.is_clean() {
"pass"
} else if profile.has_critical() {
"block"
} else if profile.has_dangerous() {
"review"
} else {
"warn"
}
}
///|
pub fn profile_short_advice(profile : AuditProfile) -> String {
match profile_release_gate(profile) {
"pass" => "safe to display without terminal-control remediation"
"warn" => "sanitize before publishing and keep findings for audit trail"
"review" => "review the affected lines before publishing generated logs"
"block" => "block release until critical terminal injection is removed"
_ => "review audit profile"
}
}