Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion crates/maximus-checks/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ pub struct EnvCheckOptions {
pub fn run_env_check_with_options(
project: &ProjectSnapshot,
options: &EnvCheckOptions,
) -> io::Result<CheckOutcome> {
run_env_check_with_missing_concrete_excluded_keys(project, options, &BTreeSet::new())
}

pub fn run_env_check_with_missing_concrete_excluded_keys(
project: &ProjectSnapshot,
options: &EnvCheckOptions,
missing_concrete_excluded_keys: &BTreeSet<String>,
) -> io::Result<CheckOutcome> {
let mut findings = Vec::new();
let mut fixes = Vec::new();
Expand Down Expand Up @@ -312,7 +320,10 @@ pub fn run_env_check_with_options(
.parsed
.order
.iter()
.filter(|key| !provided_keys.contains(*key))
.filter(|key| {
!provided_keys.contains(*key)
&& !missing_concrete_excluded_keys.contains(*key)
})
.cloned()
.collect::<Vec<_>>();

Expand Down
16 changes: 12 additions & 4 deletions crates/maximus-checks/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ use crate::test_runner_config::run_test_runner_config_check_with_ignore_root;
use crate::vite_tsconfig_alias::run_vite_tsconfig_alias_check;
use crate::workspace_config::run_workspace_config_check;
use crate::{
build_structure_report, run_config_duplicate_check, run_env_check_with_options,
run_eslint_prettier_check, run_tsconfig_check, EnvCheckOptions,
build_structure_report, run_config_duplicate_check, run_eslint_prettier_check,
run_tsconfig_check, EnvCheckOptions,
};

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -458,10 +458,18 @@ pub fn run_env_check_with_config_root_and_options(
} else {
discover_project_with_ignore_root(&project.root_dir, &ignored_patterns, ignore_root)?
};
return run_env_check_with_options(&env_project, options);
return crate::env::run_env_check_with_missing_concrete_excluded_keys(
&env_project,
options,
&config.env.missing_concrete_excluded_keys(),
);
}

run_env_check_with_options(project, options)
crate::env::run_env_check_with_missing_concrete_excluded_keys(
project,
options,
&config.env.missing_concrete_excluded_keys(),
)
}

fn run_ignore_file_drift_check_registered(
Expand Down
61 changes: 59 additions & 2 deletions crates/maximus-checks/tests/env_checks.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
Expand All @@ -9,8 +10,8 @@ mod env;

use env::{
render_created_env_example, render_created_env_example_with_sources, render_synced_env_example,
render_synced_env_example_with_sources, run_env_check, run_env_check_with_options,
EnvCheckOptions,
render_synced_env_example_with_sources, run_env_check,
run_env_check_with_missing_concrete_excluded_keys, run_env_check_with_options, EnvCheckOptions,
};
use maximus_core::{
apply_fix, discover_project, EnvTemplateRenderOptions, EnvTemplateSourceGroup, FixOperation,
Expand Down Expand Up @@ -126,6 +127,62 @@ fn env_check_matches_js_findings_for_duplicates_invalid_sync_secret_override_and
);
}

#[test]
fn env_missing_concrete_excludes_configured_exact_keys_only() {
let fixture = TempDir::new().expect("temp dir should exist");

write(
fixture.path().join(".env"),
"API_URL=https://example.test\n",
);
write(
fixture.path().join(".env.example"),
"API_URL=\nGH_COLLECTOR_TOKEN=\nNEXT_PUBLIC_OKTA_DOMAIN=\nVALIDATION_MODE=\nVALIDATION_LABELS=\n",
);

let project = discover_project(fixture.path()).expect("project should discover");
let default_outcome = run_env_check(&project).expect("default check should run");

assert_has_finding(
&default_outcome.findings,
&format!("env-missing-concrete:{}", fixture.path().to_string_lossy()),
Severity::Warn,
"Declared env contract is not satisfied locally",
"No concrete value was found for: GH_COLLECTOR_TOKEN, NEXT_PUBLIC_OKTA_DOMAIN, VALIDATION_MODE, VALIDATION_LABELS.",
"If these are injected by CI, keep the contract documented. Otherwise add them to your local env files.",
Some(fixture.path().join(".env.example")),
false,
&[],
);

let excluded_keys = [
"GH_COLLECTOR_TOKEN",
"NEXT_PUBLIC_OKTA_DOMAIN",
"VALIDATION_*",
]
.into_iter()
.map(String::from)
.collect::<BTreeSet<_>>();
let configured_outcome = run_env_check_with_missing_concrete_excluded_keys(
&project,
&EnvCheckOptions::default(),
&excluded_keys,
)
.expect("configured check should run");

assert_has_finding(
&configured_outcome.findings,
&format!("env-missing-concrete:{}", fixture.path().to_string_lossy()),
Severity::Warn,
"Declared env contract is not satisfied locally",
"No concrete value was found for: VALIDATION_MODE, VALIDATION_LABELS.",
"If these are injected by CI, keep the contract documented. Otherwise add them to your local env files.",
Some(fixture.path().join(".env.example")),
false,
&[],
);
}

#[test]
fn env_example_secret_warning_uses_key_aware_classifier() {
let fixture = TempDir::new().expect("temp dir should exist");
Expand Down
57 changes: 57 additions & 0 deletions crates/maximus-cli/tests/config_and_filtering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,63 @@ fn config_glob_ignore_and_severity_overrides_are_applied() {
);
}

#[test]
fn config_env_policy_excludes_missing_concrete_keys_from_cli_audit() {
let fixture = TempDir::new().expect("temp dir should exist");
write(
fixture.path().join(".env"),
"API_URL=https://example.test\n",
);
write(
fixture.path().join(".env.example"),
"API_URL=\nGH_COLLECTOR_TOKEN=\nNEXT_PUBLIC_OKTA_DOMAIN=\nVALIDATION_MODE=\n",
);
write(fixture.path().join(".gitignore"), ".env\n.env.local\n");
write(
fixture.path().join("maximus.config.json"),
r#"
{
"checks": { "only": ["env"] },
"env": {
"ciInjectedKeys": ["GH_COLLECTOR_TOKEN"],
"optionalLocalKeys": ["NEXT_PUBLIC_OKTA_DOMAIN"]
}
}
"#,
);

let output = maximus_bin()
.args(["audit", fixture.path().to_string_lossy().as_ref(), "--json"])
.output()
.expect("audit should run");

assert_eq!(output.status.code(), Some(1), "{output:?}");

let value = parse_json(&output);
let findings = value["findings"]
.as_array()
.expect("findings should be an array");
assert_eq!(findings.len(), 1);
assert_eq!(
finding_field(
findings[0]
.as_object()
.expect("finding should be an object"),
"id"
),
format!("env-missing-concrete:{}", fixture.path().to_string_lossy())
);
assert_eq!(
finding_field(
findings[0]
.as_object()
.expect("finding should be an object"),
"detail"
),
"No concrete value was found for: VALIDATION_MODE."
);
}

#[test]
fn config_suppression_by_exact_finding_id_hides_finding_and_counts_summary() {
let fixture = TempDir::new().expect("temp dir should exist");
Expand Down
21 changes: 21 additions & 0 deletions crates/maximus-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ pub struct MaximusConfig {
#[serde(default)]
pub checks: CheckFilterConfig,
#[serde(default)]
pub env: EnvConfig,
#[serde(default)]
pub ignore: Vec<String>,
#[serde(default, rename = "ignorePatterns")]
pub ignore_patterns: Vec<String>,
Expand Down Expand Up @@ -85,6 +87,25 @@ pub struct CheckFilterConfig {
pub skip: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvConfig {
#[serde(default, rename = "ciInjectedKeys")]
pub ci_injected_keys: Vec<String>,
#[serde(default, rename = "optionalLocalKeys")]
pub optional_local_keys: Vec<String>,
}

impl EnvConfig {
pub fn missing_concrete_excluded_keys(&self) -> BTreeSet<String> {
self.ci_injected_keys
.iter()
.chain(self.optional_local_keys.iter())
.cloned()
.collect()
}
}

#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigSuppression {
Expand Down
42 changes: 42 additions & 0 deletions crates/maximus-core/tests/config_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ fn parses_jsonc_shape_for_checks_severity_and_report() {
"only": ["env", "tsconfig"],
"skip": ["duplicates"]
},
"env": {
"ciInjectedKeys": ["GH_COLLECTOR_TOKEN"],
"optionalLocalKeys": ["NEXT_PUBLIC_OKTA_DOMAIN"]
},
"ignore": ["dist", "coverage"],
"ignorePatterns": ["**/*.generated.json"],
"severity": {
Expand Down Expand Up @@ -147,6 +151,21 @@ fn parses_jsonc_shape_for_checks_severity_and_report() {

assert_eq!(loaded.config.checks.only, vec!["env", "tsconfig"]);
assert_eq!(loaded.config.checks.skip, vec!["duplicates"]);
assert_eq!(
loaded.config.env.ci_injected_keys,
vec!["GH_COLLECTOR_TOKEN"]
);
assert_eq!(
loaded.config.env.optional_local_keys,
vec!["NEXT_PUBLIC_OKTA_DOMAIN"]
);
assert_eq!(
loaded.config.env.missing_concrete_excluded_keys(),
["GH_COLLECTOR_TOKEN", "NEXT_PUBLIC_OKTA_DOMAIN"]
.into_iter()
.map(String::from)
.collect()
);
assert_eq!(loaded.config.ignore, vec!["dist", "coverage"]);
assert_eq!(loaded.config.ignore_patterns, vec!["**/*.generated.json"]);
assert_eq!(
Expand Down Expand Up @@ -175,6 +194,29 @@ fn parses_jsonc_shape_for_checks_severity_and_report() {
assert_eq!(loaded.config.report.fail_on, Some(FailOnLevel::Error));
}

#[test]
fn parse_errors_when_unknown_env_config_keys_are_present() {
let temp = tempdir().expect("temp dir should exist");
let config_path = temp.path().join("maximus.config.json");
fs::write(
&config_path,
r#"
{
"env": {
"ciInjectedKey": ["GH_COLLECTOR_TOKEN"]
}
}
"#,
)
.expect("config should write");

let error = load_maximus_config(temp.path()).expect_err("unknown env keys should fail");
let rendered = error.to_string();

assert!(rendered.contains(&config_path.to_string_lossy().to_string()));
assert!(rendered.contains("ciInjectedKey"));
}

#[test]
fn returns_none_when_no_config_exists() {
let temp = tempdir().expect("temp dir should exist");
Expand Down