mod collaboration;
mod delivery;
mod documentation;
mod local_dev;
mod security;
mod testing;
use crate::discovery::RepoContext;
use crate::evidence::Finding;
pub trait Rule: Send + Sync {
fn id(&self) -> &'static str;
fn evaluate(&self, ctx: &RepoContext) -> Finding;
}
pub fn evaluate_all(ctx: &RepoContext) -> Vec<Finding> {
use rayon::prelude::*;
let rules = all_rules();
let mut findings: Vec<Finding> = rules.par_iter().map(|rule| rule.evaluate(ctx)).collect();
findings.sort_by(|a, b| a.rule.cmp(&b.rule));
findings
}
pub fn all_rules() -> Vec<Box<dyn Rule>> {
let mut rules: Vec<Box<dyn Rule>> = Vec::new();
rules.extend(documentation::rules());
rules.extend(local_dev::rules());
rules.extend(testing::rules());
rules.extend(security::rules());
rules.extend(delivery::rules());
rules.extend(collaboration::rules());
rules
}
pub(crate) mod helpers {
use crate::discovery::RepoContext;
use crate::evidence::{Category, Confidence, EvidenceItem, Finding, Status};
pub fn file_presence(
ctx: &RepoContext,
rule: &str,
category: Category,
candidates: &[&str],
present_summary: &str,
missing_summary: &str,
remediation: &str,
) -> Finding {
if let Some(path) = ctx.first_existing(candidates) {
Finding::builder(rule, category)
.status(Status::Present)
.confidence(Confidence::High)
.summary(present_summary)
.push_evidence(EvidenceItem::path(path))
.build()
} else {
Finding::builder(rule, category)
.status(Status::Missing)
.confidence(Confidence::High)
.summary(missing_summary)
.remediation(remediation)
.build()
}
}
#[allow(clippy::too_many_arguments)]
pub fn file_presence_with_keywords(
ctx: &RepoContext,
rule: &str,
category: Category,
candidates: &[&str],
keywords: &[&str],
enforced_summary: &str,
present_summary: &str,
missing_summary: &str,
remediation: &str,
) -> Finding {
let Some(path) = ctx.first_existing(candidates) else {
return Finding::builder(rule, category)
.status(Status::Missing)
.confidence(Confidence::High)
.summary(missing_summary)
.remediation(remediation)
.build();
};
let content = ctx.read_text(path).unwrap_or_default();
let lower = content.to_ascii_lowercase();
let hits: Vec<&str> = keywords
.iter()
.copied()
.filter(|k| lower.contains(&k.to_ascii_lowercase()))
.collect();
if !hits.is_empty() {
Finding::builder(rule, category)
.status(Status::Enforced)
.confidence(Confidence::High)
.summary(enforced_summary)
.push_evidence(EvidenceItem::path_detail(
path,
format!("matched keywords: {}", hits.join(", ")),
))
.build()
} else {
Finding::builder(rule, category)
.status(Status::Present)
.confidence(Confidence::High)
.summary(present_summary)
.push_evidence(EvidenceItem::path(path))
.build()
}
}
pub fn ci_mentions(ctx: &RepoContext, needles: &[&str]) -> Vec<EvidenceItem> {
let signals = ctx.detect_signals();
let mut items = Vec::new();
for path in &signals.ci_workflow_paths {
let Some(content) = ctx.read_text(path) else {
continue;
};
let lower = content.to_ascii_lowercase();
let matched: Vec<&str> = needles
.iter()
.copied()
.filter(|n| lower.contains(&n.to_ascii_lowercase()))
.collect();
if !matched.is_empty() {
items.push(EvidenceItem::path_detail(
path,
format!("mentions: {}", matched.join(", ")),
));
}
}
items
}
pub fn find_configs(ctx: &RepoContext, names: &[&str]) -> Vec<String> {
ctx.inventory
.find_by_basenames(names)
.into_iter()
.map(|e| e.relative.clone())
.collect()
}
}