use super::{GateFinding, GateLevel, GateThresholds};
use crate::core::script_secret_lint::ScriptLeakFinding;
use crate::core::types::{ForjarConfig, PolicyViolation, Resource};
use crate::core::{codegen, compliance_gate, script_secret_lint, secrets};
use std::collections::HashSet;
pub struct GeneratedScript {
pub resource_id: String,
pub kind: &'static str,
pub text: String,
}
pub fn generate_scripts(config: &ForjarConfig) -> Vec<GeneratedScript> {
let mut out = Vec::new();
for (id, resource) in &config.resources {
for (kind, result) in [
("check", codegen::check_script(resource)),
("apply", codegen::apply_script(resource)),
("state_query", codegen::state_query_script(resource)),
] {
if let Ok(text) = result {
out.push(GeneratedScript {
resource_id: id.clone(),
kind,
text,
});
}
}
}
out
}
fn cyclomatic(script: &str) -> Option<usize> {
let mut parser = bashrs::bash_parser::BashParser::new(script).ok()?;
let ast = parser.parse().ok()?;
let cfg = bashrs::quality::build_cfg_from_ast(&ast.statements);
Some(bashrs::quality::ComplexityMetrics::from_cfg(&cfg).cyclomatic)
}
pub fn check_shell_complexity(
scripts: &[GeneratedScript],
thresholds: &GateThresholds,
out: &mut Vec<GateFinding>,
) {
let Some(max) = thresholds.max_cyclomatic else {
return;
};
let level = if thresholds.complexity_is_error {
GateLevel::Error
} else {
GateLevel::Warning
};
for script in scripts {
let Some(value) = cyclomatic(&script.text) else {
continue;
};
if value > max {
out.push(
GateFinding::new(
"FJQ-CPX-001",
level,
&script.resource_id,
format!(
"generated {} script has cyclomatic complexity ~{value}, over the ceiling of {max}",
script.kind
),
)
.in_script(script.kind, 1)
.with_remediation(Some(
"the shell is emitted by forjar's codegen — split the resource, \
or raise the ceiling if the emitter is doing the right thing"
.to_string(),
)),
);
}
}
}
fn unsealed_leaks(text: &str, skip_comments: bool) -> Vec<ScriptLeakFinding> {
let lines: Vec<&str> = text.lines().collect();
script_secret_lint::scan_text(text, skip_comments)
.findings
.into_iter()
.filter(|f| {
!lines
.get(f.line.saturating_sub(1))
.is_some_and(|l| secrets::has_encrypted_markers(l))
})
.collect()
}
fn scan_generated_scripts(scripts: &[GeneratedScript], out: &mut Vec<GateFinding>) {
for script in scripts {
for leak in unsealed_leaks(&script.text, true) {
out.push(
GateFinding::new(
"FJQ-SEC-001",
GateLevel::Error,
&script.resource_id,
format!(
"generated {} script leaks a secret ({}): {}",
script.kind, leak.pattern_name, leak.matched_text
),
)
.in_script(script.kind, leak.line),
);
}
}
}
fn secret_bearing_fields(resource: &Resource) -> Vec<(&'static str, &str)> {
let mut fields = Vec::new();
for (name, value) in [
("content", &resource.content),
("command", &resource.command),
("source", &resource.source),
] {
if let Some(v) = value {
fields.push((name, v.as_str()));
}
}
for value in &resource.environment {
fields.push(("environment", value.as_str()));
}
fields
}
fn scan_config_fields(config: &ForjarConfig, out: &mut Vec<GateFinding>) {
for (id, resource) in &config.resources {
for (field, value) in secret_bearing_fields(resource) {
for leak in unsealed_leaks(value, false) {
out.push(GateFinding::new(
"FJQ-SEC-002",
GateLevel::Error,
id,
format!(
"unencrypted secret in `{field}` ({}): {} — seal it as ENC[age,…]",
leak.pattern_name, leak.matched_text
),
));
}
}
}
}
pub fn check_plaintext_secrets(
config: &ForjarConfig,
scripts: &[GeneratedScript],
out: &mut Vec<GateFinding>,
) {
scan_generated_scripts(scripts, out);
scan_config_fields(config, out);
}
pub fn heredoc_line_set(script: &str) -> HashSet<usize> {
let mut inside = HashSet::new();
let mut in_heredoc = false;
for (i, line) in script.lines().enumerate() {
let trimmed = line.trim();
if in_heredoc {
if trimmed == "FORJAR_EOF" || trimmed == "FORJAR_SUDO" {
in_heredoc = false;
} else {
inside.insert(i + 1); }
} else if trimmed.contains("<<'FORJAR_EOF'") || trimmed.contains("<<'FORJAR_SUDO'") {
in_heredoc = true;
}
}
inside
}
pub fn check_shell_injection(scripts: &[GeneratedScript], out: &mut Vec<GateFinding>) {
use bashrs::linter::Severity;
for script in scripts {
let sanitised = crate::transport::strip_data_payloads(&script.text);
let heredoc = heredoc_line_set(&sanitised);
for d in &crate::core::purifier::lint_script(&sanitised).diagnostics {
if heredoc.contains(&d.span.start_line) || d.severity < Severity::Warning {
continue;
}
let level = if d.severity == Severity::Error {
GateLevel::Error
} else {
GateLevel::Warning
};
out.push(
GateFinding::new(
format!("FJQ-SH-{}", d.code),
level,
&script.resource_id,
d.message.clone(),
)
.in_script(script.kind, d.span.start_line),
);
}
}
}
pub fn violation_to_finding(v: &PolicyViolation) -> GateFinding {
use crate::core::types::PolicySeverity;
let rule_id = v
.policy_id
.clone()
.unwrap_or_else(|| format!("forjar/{:?}", v.rule_type).to_lowercase());
let level = match v.severity {
PolicySeverity::Error => GateLevel::Error,
PolicySeverity::Warning => GateLevel::Warning,
PolicySeverity::Info => GateLevel::Note,
};
GateFinding::new(rule_id, level, &v.resource_id, v.rule_message.clone())
.with_remediation(v.remediation.clone())
}
pub fn check_compliance(
config: &ForjarConfig,
thresholds: &GateThresholds,
out: &mut Vec<GateFinding>,
) {
for v in &crate::core::parser::evaluate_policies_full(config).violations {
out.push(violation_to_finding(v));
}
let Some(dir) = &thresholds.policy_dir else {
return;
};
match compliance_gate::check_compliance_gate(dir, config, false) {
Ok(result) => {
for pack in &result.results {
for rule in pack.results.iter().filter(|r| !r.passed) {
out.push(GateFinding::new(
rule.rule_id.clone(),
GateLevel::from_severity_str(&rule.severity),
"",
format!("{}: {}", pack.pack_name, rule.message),
));
}
}
}
Err(e) => out.push(
GateFinding::new(
"FJQ-CMP-000",
GateLevel::Error,
"",
format!("compliance packs could not be evaluated: {e}"),
)
.with_remediation(Some(
"make --policy-dir and every pack under it readable, or drop the \
flag — the gate cannot report compliant over packs it could not see"
.to_string(),
)),
),
}
}