fix(ops): harden runtime placeholder guards

This commit is contained in:
igor04091968
2026-06-03 19:49:06 +03:00
parent 2e64f9b26c
commit 1312ee3ee5
11 changed files with 337 additions and 50 deletions
+3
View File
@@ -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:
+2
View File
@@ -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",
+23
View File
@@ -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
+138
View File
@@ -65,6 +65,103 @@ pub fn parse_utc_rfc3339(value: &str) -> Result<DateTime<Utc>> {
.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<Item = &'a String>,
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("<TOKEN>"));
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"));
}
}
@@ -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
+21 -22
View File
@@ -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<Utc> {
@@ -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]
@@ -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
@@ -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<Utc> {
@@ -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]
+12 -2
View File
@@ -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 перед хотфиксами
@@ -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_*`, `<TOKEN>`.
Кроме того, разовый запуск 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.
+107
View File
@@ -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"