From 1312ee3ee5d29b052556d2ec7d6c72dab0a27b7e Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 3 Jun 2026 19:49:06 +0300 Subject: [PATCH] fix(ops): harden runtime placeholder guards --- .github/workflows/ci.yml | 3 + adk-rust/Cargo.lock | 2 + adk-rust/RUNBOOK.md | 23 +++ adk-rust/crates/detmir-core/src/lib.rs | 138 ++++++++++++++++++ .../crates/dlp-influx-exporter/Cargo.toml | 1 + .../crates/dlp-influx-exporter/src/main.rs | 43 +++--- .../worktime-influx-exporter/Cargo.toml | 1 + .../worktime-influx-exporter/src/main.rs | 43 +++--- ansible/deploy_aw_server.yml | 14 +- ...Grafana-and-Prometheus-Monitoring-Stack.md | 12 +- ...check_production_inventory_placeholders.sh | 107 ++++++++++++++ 11 files changed, 337 insertions(+), 50 deletions(-) create mode 100755 scripts/check_production_inventory_placeholders.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3511cc..de4f4bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,9 @@ jobs: run: | find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016 + - name: Run production inventory placeholder guard self-test + run: bash scripts/check_production_inventory_placeholders.sh --self-test + powershell-analyzer: runs-on: ubuntu-latest steps: diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock index a978f38..3b4cfcc 100644 --- a/adk-rust/Cargo.lock +++ b/adk-rust/Cargo.lock @@ -743,6 +743,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "detmir-core", "reqwest", "serde", "serde_json", @@ -2873,6 +2874,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "detmir-core", "reqwest", "serde", "serde_json", diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index 32bafc2..67634f1 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -618,6 +618,29 @@ cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` +Production readiness checklist: + +- [ ] public defaults are still sanitized: no live hosts, IPs, domains, tokens, + evidence paths, case IDs, hashes, or operator home paths in tracked files; +- [ ] live values are supplied only through private inventory/env or server-side + runtime env; +- [ ] enabled Influx exporters have live URL, org, bucket, token and hosts; +- [ ] private production config passes: + + ```bash + scripts/check_production_inventory_placeholders.sh private-config/runtime.env private-config/ansible-vars.yml + ``` + + Run this guard only on private production override files. Public tracked + default/example files may intentionally contain `HOST-EXAMPLE` and TEST-NET + values for release hygiene. + +- [ ] `ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml --syntax-check` passes; +- [ ] `aw-worktime-influx-exporter.service` and `aw-dlp-influx-exporter.service` + complete once and write points; +- [ ] `detmir-check --json`, `detmir-status --json` and Grafana check are green; +- [ ] rollback path for changed binaries/env files is known. + На Proxmox: ```bash diff --git a/adk-rust/crates/detmir-core/src/lib.rs b/adk-rust/crates/detmir-core/src/lib.rs index a86c80d..d57d156 100644 --- a/adk-rust/crates/detmir-core/src/lib.rs +++ b/adk-rust/crates/detmir-core/src/lib.rs @@ -65,6 +65,103 @@ pub fn parse_utc_rfc3339(value: &str) -> Result> { .map(|ts| ts.with_timezone(&Utc)) } +pub mod runtime_guard { + use anyhow::{Result, bail}; + + const TEST_NET_MARKERS: [&str; 3] = ["192.0.2.", "198.51.100.", "203.0.113."]; + const CONTAINS_PLACEHOLDERS: [&str; 2] = ["HOST-EXAMPLE", "WINDOWS_USER_EXAMPLE"]; + const EXACT_PLACEHOLDERS: [&str; 9] = [ + "CHANGE_ME", + "CHANGEME", + "REPLACE_ME", + "REPLACE-ME", + "YOUR_TOKEN", + "YOUR_API_KEY", + "TOKEN", + "SECRET", + "PASSWORD", + ]; + + pub fn is_runtime_placeholder(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() { + return true; + } + let normalized = trimmed.to_ascii_uppercase(); + TEST_NET_MARKERS + .iter() + .any(|marker| normalized.contains(marker)) + || CONTAINS_PLACEHOLDERS + .iter() + .any(|placeholder| normalized.contains(placeholder)) + || EXACT_PLACEHOLDERS + .iter() + .any(|placeholder| normalized == *placeholder) + || normalized == "EXAMPLE" + || normalized.contains("-EXAMPLE") + || normalized.contains("_EXAMPLE") + || normalized.contains("EXAMPLE.") + || normalized.contains(".EXAMPLE") + || normalized.starts_with("YOUR_") + || (normalized.starts_with('<') && normalized.ends_with('>')) + } + + pub fn is_secret_placeholder(value: &str) -> bool { + is_runtime_placeholder(value) + || matches!( + value.trim().to_ascii_uppercase().as_str(), + "API_KEY" | "INFLUX_TOKEN" | "WRITE_TOKEN" | "BEARER_TOKEN" + ) + } + + pub fn ensure_runtime_value(name: &str, value: &str, context: &str) -> Result<()> { + if is_runtime_placeholder(value) { + bail!("{name} contains an empty/example/TEST-NET value while {context}"); + } + Ok(()) + } + + pub fn ensure_secret_value(name: &str, value: &str, context: &str) -> Result<()> { + if is_secret_placeholder(value) { + bail!("{name} contains an empty/example secret value while {context}"); + } + Ok(()) + } + + pub fn ensure_runtime_values<'a>( + name: &str, + values: impl IntoIterator, + context: &str, + ) -> Result<()> { + let mut empty = true; + for value in values { + empty = false; + ensure_runtime_value(name, value, context)?; + } + if empty { + bail!("{name} contains an empty/example value while {context}"); + } + Ok(()) + } + + pub fn ensure_influx_runtime_config( + prefix: &str, + url: &str, + org: &str, + bucket: &str, + token: &str, + hosts: &[String], + ) -> Result<()> { + let context = format!("{prefix}_ENABLED=true"); + ensure_runtime_value(&format!("{prefix}_URL"), url, &context)?; + ensure_runtime_value(&format!("{prefix}_ORG"), org, &context)?; + ensure_runtime_value(&format!("{prefix}_BUCKET"), bucket, &context)?; + ensure_secret_value(&format!("{prefix}_TOKEN"), token, &context)?; + ensure_runtime_values(&format!("{prefix}_HOSTS"), hosts, &context)?; + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -85,4 +182,45 @@ mod tests { "2026-05-31T10:20:30Z" ); } + + #[test] + fn runtime_guard_detects_public_placeholders() { + assert!(runtime_guard::is_runtime_placeholder("HOST-EXAMPLE")); + assert!(runtime_guard::is_runtime_placeholder( + "http://192.0.2.10:8086" + )); + assert!(runtime_guard::is_runtime_placeholder("")); + assert!(runtime_guard::is_secret_placeholder("CHANGE_ME")); + assert!(!runtime_guard::is_runtime_placeholder("aw_metrics")); + assert!(!runtime_guard::is_runtime_placeholder("proxmox")); + assert!(!runtime_guard::is_secret_placeholder( + "prod-write-token-value" + )); + } + + #[test] + fn runtime_guard_validates_full_influx_config() { + let hosts = vec!["WINDOWS-HOST".to_string()]; + runtime_guard::ensure_influx_runtime_config( + "AW_WORKTIME_INFLUX", + "http://influxdb.internal:8086", + "proxmox", + "aw_metrics", + "prod-write-token-value", + &hosts, + ) + .unwrap(); + + let err = runtime_guard::ensure_influx_runtime_config( + "AW_WORKTIME_INFLUX", + "http://influxdb.internal:8086", + "proxmox", + "aw_metrics", + "CHANGE_ME", + &hosts, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("AW_WORKTIME_INFLUX_TOKEN")); + } } diff --git a/adk-rust/crates/dlp-influx-exporter/Cargo.toml b/adk-rust/crates/dlp-influx-exporter/Cargo.toml index 8f2d9c9..5886e09 100644 --- a/adk-rust/crates/dlp-influx-exporter/Cargo.toml +++ b/adk-rust/crates/dlp-influx-exporter/Cargo.toml @@ -10,6 +10,7 @@ publish = false anyhow.workspace = true chrono.workspace = true clap.workspace = true +detmir-core.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/adk-rust/crates/dlp-influx-exporter/src/main.rs b/adk-rust/crates/dlp-influx-exporter/src/main.rs index 16afe9e..9287c0b 100644 --- a/adk-rust/crates/dlp-influx-exporter/src/main.rs +++ b/adk-rust/crates/dlp-influx-exporter/src/main.rs @@ -3,6 +3,7 @@ use std::{thread, time::Duration}; use anyhow::{Context, Result, anyhow, bail}; use chrono::{DateTime, TimeDelta, Utc}; use clap::Parser; +use detmir_core::runtime_guard::ensure_influx_runtime_config; use reqwest::blocking::Client; use serde::Serialize; use serde_json::Value; @@ -120,31 +121,18 @@ fn load_config() -> Config { } } -fn is_runtime_placeholder(value: &str) -> bool { - let normalized = value.trim().to_ascii_uppercase(); - normalized.is_empty() - || normalized.contains("HOST-EXAMPLE") - || normalized.contains("WINDOWS_USER_EXAMPLE") - || normalized.contains("192.0.2.") - || normalized.contains("198.51.100.") - || normalized.contains("203.0.113.") -} - fn validate_runtime_config(config: &Config) -> Result<()> { if !config.influx_enabled { return Ok(()); } - if is_runtime_placeholder(&config.influx_url) { - bail!( - "AW_DLP_INFLUX_URL contains an empty/example/TEST-NET value while AW_DLP_INFLUX_ENABLED=true" - ); - } - if config.hosts.is_empty() || config.hosts.iter().any(|host| is_runtime_placeholder(host)) { - bail!( - "AW_DLP_INFLUX_HOSTS contains an empty/example value while AW_DLP_INFLUX_ENABLED=true" - ); - } - Ok(()) + ensure_influx_runtime_config( + "AW_DLP_INFLUX", + &config.influx_url, + &config.influx_org, + &config.influx_bucket, + &config.influx_token, + &config.hosts, + ) } fn utc_now() -> DateTime { @@ -1056,9 +1044,20 @@ mod tests { let err = validate_runtime_config(&config).unwrap_err().to_string(); assert!(err.contains("AW_DLP_INFLUX_URL")); - config.influx_url = "http://influxdb.example.internal:8086".to_string(); + config.influx_url = "http://influxdb.internal:8086".to_string(); + config.influx_token = "prod-write-token-value".to_string(); let err = validate_runtime_config(&config).unwrap_err().to_string(); assert!(err.contains("AW_DLP_INFLUX_HOSTS")); + + config.hosts = vec!["WINDOWS-HOST".to_string()]; + config.influx_bucket = "BUCKET-EXAMPLE".to_string(); + let err = validate_runtime_config(&config).unwrap_err().to_string(); + assert!(err.contains("AW_DLP_INFLUX_BUCKET")); + + config.influx_bucket = DEFAULT_INFLUX_BUCKET.to_string(); + config.influx_token = "CHANGE_ME".to_string(); + let err = validate_runtime_config(&config).unwrap_err().to_string(); + assert!(err.contains("AW_DLP_INFLUX_TOKEN")); } #[test] diff --git a/adk-rust/crates/worktime-influx-exporter/Cargo.toml b/adk-rust/crates/worktime-influx-exporter/Cargo.toml index 94a7849..38a3b6a 100644 --- a/adk-rust/crates/worktime-influx-exporter/Cargo.toml +++ b/adk-rust/crates/worktime-influx-exporter/Cargo.toml @@ -10,6 +10,7 @@ publish = false anyhow.workspace = true chrono.workspace = true clap.workspace = true +detmir-core.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/adk-rust/crates/worktime-influx-exporter/src/main.rs b/adk-rust/crates/worktime-influx-exporter/src/main.rs index b94e6e7..e70ad01 100644 --- a/adk-rust/crates/worktime-influx-exporter/src/main.rs +++ b/adk-rust/crates/worktime-influx-exporter/src/main.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, thread, time::Duration}; use anyhow::{Context, Result, anyhow, bail}; use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeDelta, TimeZone, Timelike, Utc}; use clap::Parser; +use detmir_core::runtime_guard::ensure_influx_runtime_config; use reqwest::blocking::Client; use serde::Serialize; use serde_json::Value; @@ -204,31 +205,18 @@ fn load_config() -> Config { } } -fn is_runtime_placeholder(value: &str) -> bool { - let normalized = value.trim().to_ascii_uppercase(); - normalized.is_empty() - || normalized.contains("HOST-EXAMPLE") - || normalized.contains("WINDOWS_USER_EXAMPLE") - || normalized.contains("192.0.2.") - || normalized.contains("198.51.100.") - || normalized.contains("203.0.113.") -} - fn validate_runtime_config(config: &Config) -> Result<()> { if !config.influx_enabled { return Ok(()); } - if is_runtime_placeholder(&config.influx_url) { - bail!( - "AW_WORKTIME_INFLUX_URL contains an empty/example/TEST-NET value while AW_WORKTIME_INFLUX_ENABLED=true" - ); - } - if config.hosts.is_empty() || config.hosts.iter().any(|host| is_runtime_placeholder(host)) { - bail!( - "AW_WORKTIME_INFLUX_HOSTS contains an empty/example value while AW_WORKTIME_INFLUX_ENABLED=true" - ); - } - Ok(()) + ensure_influx_runtime_config( + "AW_WORKTIME_INFLUX", + &config.influx_url, + &config.influx_org, + &config.influx_bucket, + &config.influx_token, + &config.hosts, + ) } fn utc_now() -> DateTime { @@ -1042,9 +1030,20 @@ mod tests { let err = validate_runtime_config(&config).unwrap_err().to_string(); assert!(err.contains("AW_WORKTIME_INFLUX_URL")); - config.influx_url = "http://influxdb.example.internal:8086".to_string(); + config.influx_url = "http://influxdb.internal:8086".to_string(); + config.influx_token = "prod-write-token-value".to_string(); let err = validate_runtime_config(&config).unwrap_err().to_string(); assert!(err.contains("AW_WORKTIME_INFLUX_HOSTS")); + + config.hosts = vec!["WINDOWS-HOST".to_string()]; + config.influx_bucket = "BUCKET-EXAMPLE".to_string(); + let err = validate_runtime_config(&config).unwrap_err().to_string(); + assert!(err.contains("AW_WORKTIME_INFLUX_BUCKET")); + + config.influx_bucket = DEFAULT_INFLUX_BUCKET.to_string(); + config.influx_token = "CHANGE_ME".to_string(); + let err = validate_runtime_config(&config).unwrap_err().to_string(); + assert!(err.contains("AW_WORKTIME_INFLUX_TOKEN")); } #[test] diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 2e89600..365b1ca 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -611,6 +611,7 @@ that: - aw_effective_worktime_influx_token is defined - aw_effective_worktime_influx_token | length > 0 + - (aw_effective_worktime_influx_token | string | lower | regex_search('^(change_me|changeme|replace-me|replace_me|token|secret|password|api_key|influx_token|write_token|your_.*|<.*>)$')) is none fail_msg: "aw_worktime_influx_enabled=true, но token пуст и в локальном env, и в текущем /etc/activitywatch/aw-server.env. Exporter будет падать и Grafana не получит worktime-ряды." when: aw_worktime_influx_enabled | default(false) | bool @@ -619,6 +620,7 @@ that: - aw_effective_dlp_influx_token is defined - aw_effective_dlp_influx_token | length > 0 + - (aw_effective_dlp_influx_token | string | lower | regex_search('^(change_me|changeme|replace-me|replace_me|token|secret|password|api_key|influx_token|write_token|your_.*|<.*>)$')) is none fail_msg: "aw_dlp_influx_enabled=true, но token пуст и в локальном env, и в текущем /etc/activitywatch/aw-server.env. Exporter будет падать и Grafana не получит DLP-ряды." when: aw_dlp_influx_enabled | default(false) | bool @@ -629,10 +631,14 @@ - "'192.0.2.' not in (aw_worktime_influx_url | default('') | string)" - "'198.51.100.' not in (aw_worktime_influx_url | default('') | string)" - "'203.0.113.' not in (aw_worktime_influx_url | default('') | string)" + - (aw_worktime_influx_org | default('') | string | length) > 0 + - (aw_worktime_influx_org | default('') | string | lower | regex_search('example|change_me|changeme|replace-me|replace_me|your_|<|>')) is none + - (aw_worktime_influx_bucket | default('') | string | length) > 0 + - (aw_worktime_influx_bucket | default('') | string | lower | regex_search('example|change_me|changeme|replace-me|replace_me|your_|<|>')) is none - (aw_worktime_influx_hosts | default('') | string | length) > 0 - "'HOST-EXAMPLE' not in (aw_worktime_influx_hosts | default('') | string)" - "'WINDOWS_USER_EXAMPLE' not in (aw_worktime_influx_hosts | default('') | string)" - fail_msg: "aw_worktime_influx_enabled=true, но URL/hosts похожи на public example/TEST-NET значения. Задайте live значения в private inventory/env, не в public repo." + fail_msg: "aw_worktime_influx_enabled=true, но URL/org/bucket/hosts похожи на public example/TEST-NET значения. Задайте live значения в private inventory/env, не в public repo." when: aw_worktime_influx_enabled | default(false) | bool - name: Проверить destination для AW DLP Influx exporter @@ -642,10 +648,14 @@ - "'192.0.2.' not in (aw_dlp_influx_url | default('') | string)" - "'198.51.100.' not in (aw_dlp_influx_url | default('') | string)" - "'203.0.113.' not in (aw_dlp_influx_url | default('') | string)" + - (aw_dlp_influx_org | default('') | string | length) > 0 + - (aw_dlp_influx_org | default('') | string | lower | regex_search('example|change_me|changeme|replace-me|replace_me|your_|<|>')) is none + - (aw_dlp_influx_bucket | default('') | string | length) > 0 + - (aw_dlp_influx_bucket | default('') | string | lower | regex_search('example|change_me|changeme|replace-me|replace_me|your_|<|>')) is none - (aw_dlp_influx_hosts | default('') | string | length) > 0 - "'HOST-EXAMPLE' not in (aw_dlp_influx_hosts | default('') | string)" - "'WINDOWS_USER_EXAMPLE' not in (aw_dlp_influx_hosts | default('') | string)" - fail_msg: "aw_dlp_influx_enabled=true, но URL/hosts похожи на public example/TEST-NET значения. Задайте live значения в private inventory/env, не в public repo." + fail_msg: "aw_dlp_influx_enabled=true, но URL/org/bucket/hosts похожи на public example/TEST-NET значения. Задайте live значения в private inventory/env, не в public repo." when: aw_dlp_influx_enabled | default(false) | bool - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами diff --git a/docs/wiki/Grafana-and-Prometheus-Monitoring-Stack.md b/docs/wiki/Grafana-and-Prometheus-Monitoring-Stack.md index 6f6468c..bfd1fec 100644 --- a/docs/wiki/Grafana-and-Prometheus-Monitoring-Stack.md +++ b/docs/wiki/Grafana-and-Prometheus-Monitoring-Stack.md @@ -36,8 +36,11 @@ Grafana datasource `InfluxDB-AW` читает тот же bucket. - если `aw_worktime_influx_enabled=true`, `aw_worktime_influx_token` обязан быть непустым; - если `aw_dlp_influx_enabled=true`, `aw_dlp_influx_token` обязан быть непустым. -- если exporter включен, Influx URL и список hosts не могут быть пустыми, - `HOST-EXAMPLE`, `WINDOWS_USER_EXAMPLE` или TEST-NET адресами. +- если exporter включен, Influx URL, org, bucket и список hosts не могут быть + пустыми, `HOST-EXAMPLE`, `WINDOWS_USER_EXAMPLE` или TEST-NET/example + значениями; +- token не может быть пустым или placeholder вроде `CHANGE_ME`, `TOKEN`, + `YOUR_*`, ``. Кроме того, разовый запуск exporters больше не маскируется `failed_when: false`. Если запись в Influx сломана, playbook должен явно упасть, а не оставлять Grafana со старыми рядами. @@ -59,9 +62,10 @@ aw_dlp_influx_hosts: "HOST-EXAMPLE" Защита от повторения ошибки: - Ansible не даст записать `/etc/activitywatch/aw-server.env`, если включенный - exporter получил public placeholder вместо live destination; + exporter получил public placeholder вместо live destination/token; - Rust exporter дополнительно завершится с понятной ошибкой по переменной - `AW_*_INFLUX_URL` или `AW_*_INFLUX_HOSTS`, если такой env все же попал в + `AW_*_INFLUX_URL`, `AW_*_INFLUX_ORG`, `AW_*_INFLUX_BUCKET`, + `AW_*_INFLUX_TOKEN` или `AW_*_INFLUX_HOSTS`, если такой env все же попал в runtime; - живые URL, hosts и tokens не переносятся в tracked files. diff --git a/scripts/check_production_inventory_placeholders.sh b/scripts/check_production_inventory_placeholders.sh new file mode 100755 index 0000000..801d136 --- /dev/null +++ b/scripts/check_production_inventory_placeholders.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/check_production_inventory_placeholders.sh [--allow-missing] FILE... + DETMIR_PRODUCTION_CONFIG_PATHS="file1:file2" scripts/check_production_inventory_placeholders.sh + scripts/check_production_inventory_placeholders.sh --self-test + +Fails when production inventory/env files contain public placeholder values: +TEST-NET addresses, HOST-EXAMPLE, WINDOWS_USER_EXAMPLE, CHANGE_ME, YOUR_*, +replace-me, or angle-bracket placeholders. +EOF +} + +pattern='(192\.0\.2\.|198\.51\.100\.|203\.0\.113\.|HOST-EXAMPLE|WINDOWS_USER_EXAMPLE|CHANGE_ME|CHANGEME|REPLACE_ME|replace-me|YOUR_[A-Z0-9_]*|<[A-Z0-9_ -]+>)' +allow_missing=0 +self_test=0 +paths=() + +while (($#)); do + case "$1" in + --allow-missing) + allow_missing=1 + ;; + --self-test) + self_test=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + paths+=("$1") + ;; + esac + shift +done + +run_scan() { + local file + local found=0 + for file in "$@"; do + if [[ ! -e "$file" ]]; then + if (( allow_missing == 0 )); then + printf 'missing production config path: %s\n' "$file" >&2 + found=1 + fi + continue + fi + if [[ -d "$file" ]]; then + while IFS= read -r -d '' child; do + if grep -nE "$pattern" "$child" >/dev/null; then + printf 'placeholder found in %s\n' "$child" >&2 + grep -nE "$pattern" "$child" | sed -E 's/^([0-9]+):.*/ line \1: placeholder marker/' >&2 + found=1 + fi + done < <(find "$file" -type f \( -name '*.env' -o -name '*.ini' -o -name '*.yml' -o -name '*.yaml' \) -print0) + elif grep -nE "$pattern" "$file" >/dev/null; then + printf 'placeholder found in %s\n' "$file" >&2 + grep -nE "$pattern" "$file" | sed -E 's/^([0-9]+):.*/ line \1: placeholder marker/' >&2 + found=1 + fi + done + return "$found" +} + +if (( self_test == 1 )); then + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + good="$tmp_dir/good.env" + bad="$tmp_dir/bad.env" + cat >"$good" <<'EOF' +AW_WORKTIME_INFLUX_URL=http://influxdb.internal:8086 +AW_WORKTIME_INFLUX_HOSTS=WINDOWS-HOST +AW_WORKTIME_INFLUX_TOKEN=prod-write-token-value +EOF + cat >"$bad" <<'EOF' +AW_WORKTIME_INFLUX_URL=http://192.0.2.10:8086 +AW_WORKTIME_INFLUX_HOSTS=HOST-EXAMPLE +AW_WORKTIME_INFLUX_TOKEN=CHANGE_ME +EOF + run_scan "$good" + if run_scan "$bad" >/dev/null 2>&1; then + echo "self-test failed: bad fixture was accepted" >&2 + exit 1 + fi + echo "production inventory placeholder guard self-test: OK" + exit 0 +fi + +if ((${#paths[@]} == 0)) && [[ -n "${DETMIR_PRODUCTION_CONFIG_PATHS:-}" ]]; then + IFS=':' read -r -a paths <<<"$DETMIR_PRODUCTION_CONFIG_PATHS" +fi + +if ((${#paths[@]} == 0)); then + if (( allow_missing == 1 )); then + echo "production inventory placeholder guard: skipped (no production paths)" + exit 0 + fi + usage >&2 + exit 2 +fi + +run_scan "${paths[@]}" +echo "production inventory placeholder guard: OK"