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
2 changes: 1 addition & 1 deletion crates/maximus-checks/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ pub fn run_env_check_with_options(

for contract_record in &contract_records {
for entry in &contract_record.parsed.entries {
if !looks_like_secret(&entry.value) {
if !looks_like_secret(&entry.key, &entry.value) {
continue;
}

Expand Down
69 changes: 69 additions & 0 deletions crates/maximus-checks/tests/env_checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,75 @@ fn env_check_matches_js_findings_for_duplicates_invalid_sync_secret_override_and
);
}

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

write(
fixture.path().join(".env.example"),
"\
AUTH_TOKEN=github-token
SUPABASE_SERVICE_KEY=service-role-key
SUPABASE_SERVICE_ROLE_KEY=service-role-key
GOOGLE_SERVICE_ACCOUNT_KEY=service-account-key
PRIVATE_KEY=private-key-value
SHARED=sk_live_1234567890abcdef
NEXT_PUBLIC_OKTA_CLIENT_ID=0oa1234567890abcdefghijkl
VALIDATION_ISSUE_REPO=JeremyDev87/maximus-audit-signal
VALIDATION_ISSUE_DASHBOARD_URL=https://github.com/JeremyDev87/maximus/issues/107
VALIDATION_LABELS=enhancement,test,key-aware-env
WINDOW_START_DATE=2026-05-05
ERROR_PERCENT=100
SYNC_HOURS=24
SERVICE_WORKER_CACHE_KEY=v1
PLACEHOLDER_TOKEN=change-me
",
);

let project = discover_project(fixture.path()).expect("project should discover");
let outcome = run_env_check(&project).expect("check should run");
let env_file = fixture.path().join(".env.example");

for key in [
"AUTH_TOKEN",
"SUPABASE_SERVICE_KEY",
"SUPABASE_SERVICE_ROLE_KEY",
"GOOGLE_SERVICE_ACCOUNT_KEY",
"PRIVATE_KEY",
"SHARED",
] {
assert_has_finding(
&outcome.findings,
&format!("env-example-secret:{}:{key}", env_file.to_string_lossy()),
Severity::Warn,
&format!(".env.example appears to contain a real value for \"{key}\""),
"Contract files should describe the interface, not ship concrete secrets.",
"Replace the value with a blank or placeholder string before sharing the repo.",
Some(env_file.clone()),
false,
&[],
);
}

for key in [
"NEXT_PUBLIC_OKTA_CLIENT_ID",
"VALIDATION_ISSUE_REPO",
"VALIDATION_ISSUE_DASHBOARD_URL",
"VALIDATION_LABELS",
"WINDOW_START_DATE",
"ERROR_PERCENT",
"SYNC_HOURS",
"SERVICE_WORKER_CACHE_KEY",
"PLACEHOLDER_TOKEN",
] {
let id = format!("env-example-secret:{}:{key}", env_file.to_string_lossy());
assert!(
!outcome.findings.iter().any(|finding| finding.id == id),
"{key} should not produce env-example-secret"
);
}
}

#[test]
fn env_check_plans_example_creation_when_runtime_env_files_exist_without_contract() {
let fixture = TempDir::new().expect("temp dir should exist");
Expand Down
94 changes: 89 additions & 5 deletions crates/maximus-core/src/env_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ pub fn is_concrete_env_file_name(name: &str) -> bool {
is_env_file_name(name) && !is_template_env_file_name(name)
}

pub fn looks_like_secret(value: &str) -> bool {
pub fn looks_like_secret(key: &str, value: &str) -> bool {
if value.is_empty() {
return false;
}
Expand All @@ -198,10 +198,11 @@ pub fn looks_like_secret(value: &str) -> bool {
return false;
}

value.len() >= 16
&& value
.chars()
.all(|character| character.is_ascii_alphanumeric() || "/_+=-".contains(character))
if has_high_confidence_secret_value(value) {
return true;
}

is_secret_like_env_key(key)
}

fn split_env_assignment(line: &str) -> Option<(&str, &str)> {
Expand Down Expand Up @@ -280,6 +281,89 @@ fn is_placeholder_value(value: &str) -> bool {
.unwrap_or(false)
}

fn has_high_confidence_secret_value(value: &str) -> bool {
let lower = value.to_ascii_lowercase();

value.contains("-----BEGIN PRIVATE KEY-----")
|| lower.starts_with("sk_live_")
|| lower.starts_with("sk_test_")
|| lower.starts_with("ghp_")
|| lower.starts_with("github_pat_")
|| lower.starts_with("xoxb-")
|| lower.starts_with("xoxp-")
|| lower.starts_with("xoxa-")
|| (value.len() == 20
&& value.starts_with("AKIA")
&& value
.chars()
.all(|character| character.is_ascii_uppercase() || character.is_ascii_digit()))
|| (value.len() >= 35
&& value.starts_with("AIza")
&& value
.chars()
.all(|character| character.is_ascii_alphanumeric() || "-_".contains(character)))
}

fn is_secret_like_env_key(key: &str) -> bool {
let segments = env_key_segments(key);
if segments.is_empty() {
return false;
}

if contains_adjacent_segments(&segments, "PRIVATE", "KEY")
|| contains_service_key_segments(&segments)
{
return true;
}

if segments.iter().any(|segment| {
matches!(
segment.as_str(),
"TOKEN" | "SECRET" | "PASSWORD" | "PASSWD" | "PWD"
)
}) {
return true;
}

if contains_adjacent_segments(&segments, "API", "KEY")
|| contains_adjacent_segments(&segments, "ACCESS", "KEY")
{
return !is_public_key_identifier(&segments);
}

false
}

fn env_key_segments(key: &str) -> Vec<String> {
key.split(|character: char| !character.is_ascii_alphanumeric())
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_ascii_uppercase())
.collect()
}

fn contains_adjacent_segments(segments: &[String], left: &str, right: &str) -> bool {
segments
.windows(2)
.any(|window| window[0] == left && window[1] == right)
}

fn contains_service_key_segments(segments: &[String]) -> bool {
segments
.windows(2)
.any(|window| window[0] == "SERVICE" && window[1] == "KEY")
|| segments.windows(3).any(|window| {
window[0] == "SERVICE"
&& matches!(window[1].as_str(), "ROLE" | "ACCOUNT")
&& window[2] == "KEY"
})
}

fn is_public_key_identifier(segments: &[String]) -> bool {
contains_adjacent_segments(segments, "PUBLIC", "KEY")
|| contains_adjacent_segments(segments, "ANON", "KEY")
|| contains_adjacent_segments(segments, "CLIENT", "ID")
}

fn normalize_env_template_source_group(group: EnvTemplateSourceGroup) -> EnvTemplateSourceGroup {
let mut unique_keys = group
.keys
Expand Down
62 changes: 59 additions & 3 deletions crates/maximus-core/tests/core_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,65 @@ fn env_helpers_match_current_js_behavior() {
assert!(is_concrete_env_file_name(".env.local"));
assert!(!is_concrete_env_file_name(".env."));
assert!(!is_concrete_env_file_name(".env.template"));
assert!(looks_like_secret("supersecretvalue12345"));
assert!(!looks_like_secret("localhost"));
assert!(!looks_like_secret("your-api-key"));
assert!(looks_like_secret("AUTH_TOKEN", "supersecretvalue12345"));
assert!(!looks_like_secret("AUTH_TOKEN", "localhost"));
assert!(!looks_like_secret("AUTH_TOKEN", "your-api-key"));
}

#[test]
fn env_secret_helper_is_key_aware() {
for (key, value) in [
("AUTH_TOKEN", "github-token"),
("DATABASE_SECRET", "secret-value"),
("SUPABASE_SERVICE_KEY", "service-role-key"),
("SUPABASE_SERVICE_ROLE_KEY", "service-role-key"),
("GOOGLE_SERVICE_ACCOUNT_KEY", "service-account-key"),
("PRIVATE_KEY", "private-key-value"),
("API_KEY", "api-key-value"),
("SHARED", "sk_live_1234567890abcdef"),
] {
assert!(
looks_like_secret(key, value),
"{key}={value} should be treated as a secret-like template value"
);
}

for (key, value) in [
("NEXT_PUBLIC_OKTA_CLIENT_ID", "0oa1234567890abcdefghijkl"),
("VALIDATION_ISSUE_REPO", "JeremyDev87/maximus-audit-signal"),
(
"VALIDATION_ISSUE_DASHBOARD_URL",
"https://github.com/JeremyDev87/maximus/issues/107",
),
("VALIDATION_LABELS", "enhancement,test,key-aware-env"),
("WINDOW_START_DATE", "2026-05-05"),
("ERROR_PERCENT", "100"),
("SYNC_HOURS", "24"),
("SUPABASE_ANON_KEY", "supabase-anon-public-key"),
("PUBLIC_KEY", "public-key-identifier"),
("SERVICE_WORKER_CACHE_KEY", "v1"),
] {
assert!(
!looks_like_secret(key, value),
"{key}={value} should be treated as public/config template data"
);
}

for placeholder in [
"change-me",
"placeholder",
"your-api-key",
"example",
"true",
"false",
"0",
"1",
] {
assert!(
!looks_like_secret("AUTH_TOKEN", placeholder),
"{placeholder} should remain a non-warning placeholder"
);
}
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion src/checks/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export async function runEnvCheck(project) {

for (const contractRecord of contractRecords) {
for (const entry of contractRecord.parsed.entries) {
if (!looksLikeSecret(entry.value)) {
if (!looksLikeSecret(entry.key, entry.value)) {
continue;
}

Expand Down
62 changes: 60 additions & 2 deletions src/lib/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function isConcreteEnvFileName(name) {
return /^\.env(?:\..+)?$/u.test(name) && !isTemplateEnvFileName(name);
}

export function looksLikeSecret(value) {
export function looksLikeSecret(key, value) {
if (!value) {
return false;
}
Expand All @@ -94,13 +94,71 @@ export function looksLikeSecret(value) {
return false;
}

if (/^[A-Za-z0-9/_+=-]{16,}$/u.test(value)) {
if (hasHighConfidenceSecretValue(value)) {
return true;
}

return isSecretLikeEnvKey(key);
}

function hasHighConfidenceSecretValue(value) {
return (
value.includes("-----BEGIN PRIVATE KEY-----") ||
/^(?:sk_live_|sk_test_|ghp_|github_pat_|xox[abp]-)/iu.test(value) ||
/^AKIA[A-Z0-9]{16}$/u.test(value) ||
/^AIza[A-Za-z0-9_-]{31,}$/u.test(value)
);
}

function isSecretLikeEnvKey(key) {
const segments = key
.split(/[^A-Za-z0-9]+/u)
.filter(Boolean)
.map((segment) => segment.toUpperCase());

if (segments.length === 0) {
return false;
}

if (containsAdjacentSegments(segments, "PRIVATE", "KEY") || containsServiceKeySegments(segments)) {
return true;
}

if (segments.some((segment) => ["TOKEN", "SECRET", "PASSWORD", "PASSWD", "PWD"].includes(segment))) {
return true;
}

if (containsAdjacentSegments(segments, "API", "KEY") || containsAdjacentSegments(segments, "ACCESS", "KEY")) {
return !isPublicKeyIdentifier(segments);
}

return false;
}

function containsAdjacentSegments(segments, left, right) {
return segments.some((segment, index) => segment === left && segments[index + 1] === right);
}

function containsServiceKeySegments(segments) {
return segments.some((segment, index) => {
if (segment !== "SERVICE") {
return false;
}

const next = segments[index + 1];
const following = segments[index + 2];
return next === "KEY" || ((next === "ROLE" || next === "ACCOUNT") && following === "KEY");
});
}

function isPublicKeyIdentifier(segments) {
return (
containsAdjacentSegments(segments, "PUBLIC", "KEY") ||
containsAdjacentSegments(segments, "ANON", "KEY") ||
containsAdjacentSegments(segments, "CLIENT", "ID")
);
}

export function parseExactGitignorePatterns(text) {
return text
.split(/\r?\n/u)
Expand Down