use super::*;
use crate::core::types::ForjarConfig;
fn config(yaml: &str) -> ForjarConfig {
serde_yaml_ng::from_str(yaml).expect("fixture parses")
}
fn base(policies: &str) -> String {
format!(
"version: \"1.0\"\n\
name: remediate-fixture\n\
# this comment must survive\n\
machines:\n\
\x20 box:\n\
\x20 hostname: box\n\
\x20 addr: 127.0.0.1\n\
resources:\n\
\x20 web-conf:\n\
\x20 type: file\n\
\x20 machine: box\n\
\x20 path: /etc/web.conf\n\
\x20 mode: \"0777\"\n\
\x20 content: |\n\
\x20 listen 80;\n\
policies:\n{policies}"
)
}
const ASSERT_0644: &str = " - type: assert\n\
\x20 id: SEC-MODE\n\
\x20 message: files must be 0644\n\
\x20 resource_type: file\n\
\x20 condition_field: mode\n\
\x20 condition_value: \"0644\"\n";
const DENY_0777: &str = " - type: deny\n\
\x20 id: SEC-NO-0777\n\
\x20 message: 0777 is forbidden\n\
\x20 resource_type: file\n\
\x20 condition_field: mode\n\
\x20 condition_value: \"0777\"\n";
fn run(source: &str) -> Report {
let cfg = config(source);
remediate(source, &cfg, None).expect("remediation ran")
}
#[test]
fn the_target_value_comes_from_the_rule() {
let source = base(ASSERT_0644);
let report = run(&source);
assert_eq!(report.applied.len(), 1);
assert_eq!(report.applied[0].to, "0644");
assert_eq!(report.applied[0].from.as_deref(), Some("0777"));
assert_eq!(report.applied[0].policy_id, "SEC-MODE");
assert!(report.updated_yaml.contains("mode: \"0644\""));
assert!(report.changed);
}
#[test]
fn a_different_rule_yields_a_different_value() {
let source = base(&ASSERT_0644.replace("\"0644\"", "\"0600\""));
let report = run(&source);
assert_eq!(report.applied[0].to, "0600");
assert!(report.updated_yaml.contains("mode: \"0600\""));
}
#[test]
fn every_untouched_byte_survives() {
let source = base(ASSERT_0644);
let report = run(&source);
assert_eq!(report.updated_yaml.lines().count(), source.lines().count());
assert!(report.updated_yaml.contains("# this comment must survive"));
assert!(report.updated_yaml.contains(" content: |"));
assert!(report.updated_yaml.contains(" listen 80;"));
let differing = source
.lines()
.zip(report.updated_yaml.lines())
.filter(|(a, b)| a != b)
.count();
assert_eq!(differing, 1);
}
#[test]
fn a_deny_rule_is_reported_unfixable_never_guessed() {
let source = base(DENY_0777);
let report = run(&source);
assert!(report.applied.is_empty());
assert!(!report.changed);
assert_eq!(report.updated_yaml, source);
assert_eq!(report.remaining.len(), 1);
assert!(report.remaining[0].reason.contains("FORBIDDEN"));
assert_eq!(report.hash_before, report.hash_after);
}
#[test]
fn a_require_rule_is_reported_unfixable() {
let policies = " - type: require\n\
\x20 id: OWN-001\n\
\x20 message: files must declare an owner\n\
\x20 resource_type: file\n\
\x20 field: owner\n";
let report = run(&base(policies));
assert!(report.applied.is_empty());
assert_eq!(report.remaining.len(), 1);
assert!(report.remaining[0].reason.contains("not the value"));
}
#[test]
fn a_limit_rule_is_reported_unfixable() {
let policies = " - type: limit\n\
\x20 id: TAG-001\n\
\x20 message: at least one tag\n\
\x20 resource_type: file\n\
\x20 field: tags\n\
\x20 min_count: 1\n";
let report = run(&base(policies));
assert!(report.applied.is_empty());
assert!(report.remaining[0]
.reason
.contains("bounds the size of a list"));
}
#[test]
fn an_unsettable_field_is_refused_with_the_settable_list() {
let policies = ASSERT_0644
.replace("condition_field: mode", "condition_field: content")
.replace("condition_value: \"0644\"", "condition_value: \"x\"");
let report = run(&base(&policies));
assert!(report.applied.is_empty());
assert!(report.remaining[0]
.reason
.contains("scalar fields forjar will rewrite"));
}
#[test]
fn remaining_is_re_evaluated_not_bookkept() {
let source = base(&format!("{ASSERT_0644}{DENY_0777}"));
let report = run(&source);
assert_eq!(report.applied.len(), 1);
assert!(
report.remaining.is_empty(),
"unexpected remaining: {:?}",
report.remaining
);
}
#[test]
fn remediation_is_idempotent() {
let source = base(ASSERT_0644);
let once = run(&source);
let twice = run(&once.updated_yaml);
assert!(!twice.changed);
assert!(twice.applied.is_empty());
assert_eq!(twice.updated_yaml, once.updated_yaml);
assert_eq!(twice.hash_before, twice.hash_after);
}
#[test]
fn a_flow_style_resource_fails_closed() {
let source = "version: \"1.0\"\n\
name: flow\n\
machines:\n\
\x20 box:\n\
\x20 hostname: box\n\
\x20 addr: 127.0.0.1\n\
resources:\n\
\x20 web-conf: {type: file, machine: box, path: /etc/w, mode: \"0777\"}\n\
policies:\n"
.to_string()
+ ASSERT_0644;
let report = run(&source);
assert!(report.applied.is_empty());
assert_eq!(report.updated_yaml, source);
assert!(report.remaining[0].reason.contains("flow style"));
}
#[test]
fn a_value_the_parser_resolved_differently_is_refused() {
let source = base(ASSERT_0644);
let mut cfg = config(&source);
if let Some(r) = cfg.resources.get_mut("web-conf") {
r.mode = Some("0755".to_string());
}
let report = remediate(&source, &cfg, None).expect("ran");
assert!(report.applied.is_empty());
assert_eq!(report.updated_yaml, source);
assert!(report.remaining[0].reason.contains("expansion"));
}
#[test]
fn a_resource_from_an_include_names_the_file() {
let source = base(ASSERT_0644);
let mut cfg = config(&source);
cfg.resources.shift_remove("web-conf");
let mut extra: crate::core::types::Resource = serde_yaml_ng::from_str(
"type: file\nmachine: box\npath: /etc/other.conf\nmode: \"0777\"\n",
)
.expect("resource parses");
extra.mode = Some("0777".to_string());
cfg.resources.insert("other-conf".to_string(), extra);
cfg.include_provenance
.insert("resource:other-conf".to_string(), "extra.yaml".to_string());
let report = remediate(&source, &cfg, None).expect("ran");
assert!(report.applied.is_empty());
assert!(report.remaining[0].reason.contains("extra.yaml"));
}
#[test]
fn policy_ids_filter_which_rules_run() {
let source = base(&format!("{ASSERT_0644}{DENY_0777}"));
let cfg = config(&source);
let only_deny = vec!["SEC-NO-0777".to_string()];
let report = remediate(&source, &cfg, Some(&only_deny)).expect("ran");
assert!(report.applied.is_empty(), "SEC-MODE was not selected");
let by_id: Vec<&str> = report
.remaining
.iter()
.map(|v| v.policy_id.as_str())
.collect();
assert!(by_id.contains(&"SEC-MODE"));
assert!(by_id.contains(&"SEC-NO-0777"));
let sec_mode = report
.remaining
.iter()
.find(|v| v.policy_id == "SEC-MODE")
.expect("present");
assert_eq!(sec_mode.reason, "not selected by policy_ids");
}
#[test]
fn a_config_with_no_policies_says_what_it_did_not_look_at() {
let source = base("");
let cfg = config(&source);
let report = remediate(&source, &cfg, None).expect("ran");
assert!(report.applied.is_empty());
let note = report.scope_note.expect("a scope note");
assert!(note.contains("compliance packs"));
}
#[test]
fn a_rule_without_an_id_still_reports_under_its_generated_id() {
let policies = ASSERT_0644.replace(" id: SEC-MODE\n", "");
let report = run(&base(&policies));
assert_eq!(report.applied.len(), 1);
assert!(report.applied[0].policy_id.starts_with("RULE-"));
}
#[test]
fn the_documents_own_quote_style_survives_the_edit() {
let double = base(ASSERT_0644);
assert!(run(&double).updated_yaml.contains("mode: \"0644\""));
let single = double.replace("mode: \"0777\"", "mode: '0777'");
assert!(run(&single).updated_yaml.contains("mode: '0644'"));
}
#[test]
fn a_value_that_would_need_escaping_is_not_hand_quoted() {
let policies = ASSERT_0644
.replace("condition_field: mode", "condition_field: owner")
.replace("condition_value: \"0644\"", "condition_value: 'a\"b\\\\c'");
let source = base(&policies).replace(
" mode: \"0777\"\n",
" mode: \"0777\"\n owner: \"root\"\n",
);
let report = run(&source);
assert_eq!(report.applied.len(), 1, "{:?}", report.remaining);
let want = &report.applied[0].to;
let round_tripped = config(&report.updated_yaml);
assert_eq!(
round_tripped
.resources
.get("web-conf")
.and_then(|r| r.owner.as_deref()),
Some(want.as_str()),
"the emitted scalar did not parse back to the value the rule named"
);
}
#[test]
fn two_rules_that_demand_different_values_are_both_refused() {
let other = ASSERT_0644
.replace("id: SEC-MODE", "id: SEC-MODE-B")
.replace("condition_value: \"0644\"", "condition_value: \"0600\"");
let source = base(&format!("{ASSERT_0644}{other}"));
let report = run(&source);
assert!(
report.applied.is_empty(),
"sort order picked a winner between two contradictory rules: {:?}",
report.applied
);
assert_eq!(report.updated_yaml, source);
assert_eq!(report.remaining.len(), 2);
for v in &report.remaining {
assert!(
v.reason.contains("will not choose between them"),
"the reason blames something other than the contradiction: {}",
v.reason
);
assert!(
v.reason.contains("`0600`") && v.reason.contains("`0644`"),
"{}",
v.reason
);
}
}