Skip to content

Instantly share code, notes, and snippets.

@Kenya-West
Created August 2, 2026 11:53
Show Gist options
  • Select an option

  • Save Kenya-West/402bd5a4cdd53796c4ca925a4eab03ff to your computer and use it in GitHub Desktop.

Select an option

Save Kenya-West/402bd5a4cdd53796c4ca925a4eab03ff to your computer and use it in GitHub Desktop.
Bash script that masks output of .env files, replacing secrets with appropriate fillers

dotenv-mask-output

A single-file bash script that masks sensitive values in a .env-style file so you can safely paste it into a bug report, CI log, chat message, or gist — without leaking secrets. Keys are never touched. Non-sensitive values (PORT=3000, NODE_ENV=production, ...) are left alone too — only values belonging to sensitive-looking keys get masked.

DB_PASSWORD="hunter2"              ->  DB_PASSWORD="<password>"
API_KEY=abcdef1234567890           ->  API_KEY=<key>
WEBHOOK_URL="https://hooks.x.com"  ->  WEBHOOK_URL="https://<url>"
SERVER_IP=192.168.1.42             ->  SERVER_IP=<ip>
PORT=3000                          ->  PORT=3000   (untouched — not sensitive)

Why

Asterisks (****) hide a secret but throw away useful context: was that a token, a URL, a phone number? This script masks with descriptive <word> tags instead (asterisks are still available via --mode asterisk) so a masked file stays readable and self-documenting while still leaking nothing.

Install

It's a single file — download it and make it executable:

curl -o dotenv-mask-output.sh https://gist.githubusercontent.com/<you>/<gist-id>/raw/dotenv-mask-output.sh
chmod +x dotenv-mask-output.sh

Requires bash 4.0+. On Windows, run it via Git Bash / WSL.

Usage

./dotenv-mask-output.sh

With no arguments it auto-detects the nearest env file in the current directory (see File detection) and prints the masked result to stdout.

./dotenv-mask-output.sh -f .env.production -o .env.production.masked
./dotenv-mask-output.sh -m asterisk -c '#'
./dotenv-mask-output.sh --dry-run -v
./dotenv-mask-output.sh -k stripe,slack -e port,app_name

Run ./dotenv-mask-output.sh --help for the full option reference.

File detection

Auto-detection checks the current directory for these filenames, in this order, and uses the first one it finds:

.env
.env.local
.env.development.local
.env.development
.env.dev
.env.test.local
.env.test
.env.staging.local
.env.staging
.env.production.local
.env.production
.env.prod

.env always wins if present. Pass -f/--file to bypass detection entirely (use -f - to read from stdin), or -r/--walk-up to also search parent directories if nothing is found in the current one. -L/--list shows which files exist and which one would be selected, without masking anything.

.env.example / .env.sample / .env.template files are intentionally excluded from auto-detection (they don't usually hold real secrets) — pass them explicitly with -f if you want to mask one anyway.

What counts as "sensitive"

A value is masked only if its key contains one of a built-in list of substrings — things like password, pass, secret, token, key, auth, credential, jwt, session, cookie, cert, salt, hash, license, webhook, url, uri, domain, host, email, mail, phone, dsn, connection, database, user, date, time, expiry/expires, plus boundary-checked ip, id, db, pin, otp, access, and private. Matching is substring-based (a key containing domain anywhere is treated as sensitive), except for a few very short, ambiguous ones (ip, id, db, pin, otp) which require an underscore or start/end boundary so e.g. SHIP_DATE isn't caught by ip and VALID isn't caught by id.

Everything else — PORT, NODE_ENV, APP_NAME, DEBUG, plain APP_VERSION, etc. — passes through unchanged.

Tune this with:

  • -k/--extra-keys LIST — comma-separated substrings that additionally count as sensitive (e.g. -k stripe,internal_id).
  • -e/--exclude-keys LIST — comma-separated substrings that are never masked, even if they'd otherwise match (and even under --all).
  • -a/--all — mask every value regardless of key name.

Masking styles

Word mode (default)

Instead of a fixed <redacted>, the script picks a tag that describes the value:

  1. First it looks at the key — a key containing domain/host becomes <domain>, token becomes <token>, email/mail becomes <email>, date/time becomes <date>, and so on.
  2. If the key gives no specific hint, it looks at the value itself and detects its type: integers/floats -> <number>, true/false/yes/no -> <boolean>, an IPv4-shaped string -> <ip>, something with a scheme:// prefix or bare domain.tld shape -> <domain>, an a@b.tld shape -> <email>, an ISO/slash date -> <date>, otherwise -> <text>.
  3. For <domain>/<url> tags, the original protocol is preserved: https://secretpath becomes https://<domain>, ftps://host becomes ftps://<domain>.
./dotenv-mask-output.sh -U                  # <TOKEN> instead of <token>
./dotenv-mask-output.sh -w hidden           # generic fallback tag -> <hidden>
./dotenv-mask-output.sh -s                  # skip detection, always use the generic tag

Asterisk mode

./dotenv-mask-output.sh -m asterisk                 # KEY=***** (same length as original)
./dotenv-mask-output.sh -m asterisk -c '#'           # custom character
./dotenv-mask-output.sh -m asterisk --length 8       # fixed-length mask

Syntax it understands

The parser is written to tolerate real-world .env variation without mangling anything it doesn't recognize:

  • KEY=value, KEY = value, KEY\t=\tvalue (arbitrary whitespace around =, preserved on output)
  • export KEY=value
  • Leading indentation on a line
  • Double-quoted (KEY="value"), single-quoted (KEY='value'), and unquoted values
  • Inline comments (KEY=value # comment) — trailing whitespace before the comment and the comment itself are preserved as-is
  • Full-line comments (# ...) and blank lines — passed through untouched
  • Trailing whitespace on unquoted values — preserved
  • Windows CRLF line endings — preserved per-line (a file can even mix CRLF/LF; each line keeps its own ending)
  • Empty values (KEY=, KEY="", KEY='') are left alone — there's nothing to mask
  • Lines that aren't valid KEY=VALUE (stray text, a bare =value, etc.) are passed through unchanged rather than guessed at

Not supported (rare in practice, treated as a plain string if encountered): multi-line quoted values that span more than one physical line, and shell variable expansion/interpolation inside values — the script only rewrites text, it never evaluates the file.

Output destinations

  • Default: stdout.
  • -o/--output FILE — write to a new file.
  • -i/--in-place — overwrite the source file with the masked version. Asks for confirmation unless -y/--yes is also passed (and refuses to run non-interactively without -y, so it can't silently clobber a file in a script).

Other flags

  • -n/--dry-run — list which keys would be masked (and with what tag) without producing output; add -v to also list the keys left untouched.
  • -q/--quiet / -v/--verbose — control informational messages on stderr (masked output on stdout is never affected).
  • --no-color — disable colored stderr messages (also respects the NO_COLOR environment variable).
  • -h/--help, -V/--version.

Full help

$ ./dotenv-mask-output.sh --help

(see the script itself for the always-up-to-date option list)

License

MIT — do whatever you want with it.

#!/usr/bin/env bash
#
# dotenv-mask-output.sh — mask sensitive values from a .env-style file so it
# can be safely pasted, logged, or shared (e.g. in a bug report or CI log)
# without leaking secrets. Keys are never touched, only values — and only
# values whose key looks sensitive get masked at all.
#
# https://github.com/KenyaWestDev/dotenv-mask-output
#
# Usage:
# ./dotenv-mask-output.sh [options]
#
# Run with --help for the full option list.
set -uo pipefail
shopt -s extglob
# Capture the external NO_COLOR convention (https://no-color.org) before
# the internal NO_COLOR flag below shadows the name.
_EXTERNAL_NO_COLOR="${NO_COLOR:-}"
if ((BASH_VERSINFO[0] < 4)); then
printf 'dotenv-mask-output: requires bash 4.0+ (found %s)\n' "$BASH_VERSION" >&2
exit 3
fi
SCRIPT_NAME="dotenv-mask-output.sh"
VERSION="1.0.0"
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
CANDIDATES=(
".env"
".env.local"
".env.development.local"
".env.development"
".env.dev"
".env.test.local"
".env.test"
".env.staging.local"
".env.staging"
".env.production.local"
".env.production"
".env.prod"
)
MODE="word" # word | asterisk
MASK_CHAR="*"
MASK_LEN="match" # "match" or a positive integer
SIMPLE=false # word mode: skip type/category detection, always use TAG_WORD
TAG_WORD="redacted" # generic fallback tag
UPPER=false # <TOKEN> instead of <token>
MASK_ALL=false
EXTRA_KEYS=""
EXCLUDE_KEYS=""
WALK_UP=false
FILE=""
OUTPUT=""
IN_PLACE=false
ASSUME_YES=false
LIST_ONLY=false
DRY_RUN=false
QUIET=false
VERBOSE=false
# Disable color automatically when stderr isn't a terminal or NO_COLOR is
# set in the environment. --no-color (parsed below) always wins if passed
# explicitly.
NO_COLOR=false
[[ -t 2 ]] || NO_COLOR=true
[[ -n "$_EXTERNAL_NO_COLOR" ]] && NO_COLOR=true
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
c_reset=""; c_dim=""; c_red=""; c_yellow=""; c_cyan=""
init_colors() {
[[ "$NO_COLOR" == true ]] && return
c_reset=$'\033[0m'; c_dim=$'\033[2m'; c_red=$'\033[31m'
c_yellow=$'\033[33m'; c_cyan=$'\033[36m'
}
log_info() { [[ "$QUIET" == true ]] && return; printf '%s[info]%s %s\n' "$c_cyan" "$c_reset" "$*" >&2; }
log_warn() { [[ "$QUIET" == true ]] && return; printf '%s[warn]%s %s\n' "$c_yellow" "$c_reset" "$*" >&2; }
log_verb() { [[ "$VERBOSE" == true ]] || return; printf '%s[verbose]%s %s\n' "$c_dim" "$c_reset" "$*" >&2; }
log_err() { printf '%s[error]%s %s\n' "$c_red" "$c_reset" "$*" >&2; }
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION
Mask sensitive values in a .env-style file. Keys are left alone; only
values belonging to sensitive-looking keys are masked.
USAGE:
$SCRIPT_NAME [options]
FILE SELECTION:
-f, --file FILE Read FILE instead of auto-detecting one. Use
"-" to read from stdin.
-r, --walk-up When auto-detecting, also search parent
directories (nearest match wins).
-L, --list List candidate env files and which one would
be selected, then exit (no masking).
OUTPUT:
-o, --output FILE Write masked output to FILE instead of stdout.
-i, --in-place Overwrite the source file with masked output.
Asks for confirmation unless -y is given.
-y, --yes Assume "yes" to any confirmation prompt.
-n, --dry-run Print which keys would be masked (and with
what tag) instead of producing full output.
MASKING STYLE:
-m, --mode MODE "word" (default) or "asterisk".
-c, --char CHAR Character to repeat in asterisk mode. [*]
--length N|match Asterisk run length: a fixed number, or
"match" to mirror the original value's
length. [match]
-s, --simple In word mode, always use the generic tag
(see --tag-word) instead of detecting a
value's type/category.
-w, --tag-word WORD Generic fallback tag used in word mode when
no more specific category applies, and
always used in --simple mode. [redacted]
-U, --upper Upper-case tag words: <TOKEN> instead of
<token>.
WHAT GETS MASKED:
-a, --all Mask every value, regardless of key name.
-k, --extra-keys LIST Comma-separated substrings; keys
containing any of them are also treated
as sensitive.
-e, --exclude-keys LIST Comma-separated substrings; keys
containing any of them are never
masked (takes priority over everything
else, including --all).
MISC:
-q, --quiet Suppress informational messages.
-v, --verbose Print extra diagnostic messages.
--no-color Disable colored messages.
-h, --help Show this help and exit.
-V, --version Show version and exit.
AUTO-DETECTION ORDER (first existing file wins):
${CANDIDATES[*]}
EXAMPLES:
$SCRIPT_NAME # auto-detect, word tags, to stdout
$SCRIPT_NAME -m asterisk -c '#' # asterisk-style masking
$SCRIPT_NAME -f .env.production -o out.env
$SCRIPT_NAME -k stripe,slack -e port,name
$SCRIPT_NAME --dry-run -v
EOF
}
version() { printf '%s v%s\n' "$SCRIPT_NAME" "$VERSION"; }
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
arg="$1"
case "$arg" in
-f|--file) FILE="${2:-}"; shift 2 ;;
--file=*) FILE="${arg#*=}"; shift ;;
-o|--output) OUTPUT="${2:-}"; shift 2 ;;
--output=*) OUTPUT="${arg#*=}"; shift ;;
-i|--in-place) IN_PLACE=true; shift ;;
-y|--yes) ASSUME_YES=true; shift ;;
-m|--mode) MODE="${2:-}"; shift 2 ;;
--mode=*) MODE="${arg#*=}"; shift ;;
-c|--char) MASK_CHAR="${2:-}"; shift 2 ;;
--char=*) MASK_CHAR="${arg#*=}"; shift ;;
--length) MASK_LEN="${2:-}"; shift 2 ;;
--length=*) MASK_LEN="${arg#*=}"; shift ;;
-s|--simple) SIMPLE=true; shift ;;
-w|--tag-word) TAG_WORD="${2:-}"; shift 2 ;;
--tag-word=*) TAG_WORD="${arg#*=}"; shift ;;
-U|--upper) UPPER=true; shift ;;
-a|--all) MASK_ALL=true; shift ;;
-k|--extra-keys) EXTRA_KEYS="${2:-}"; shift 2 ;;
--extra-keys=*) EXTRA_KEYS="${arg#*=}"; shift ;;
-e|--exclude-keys) EXCLUDE_KEYS="${2:-}"; shift 2 ;;
--exclude-keys=*) EXCLUDE_KEYS="${arg#*=}"; shift ;;
-r|--walk-up) WALK_UP=true; shift ;;
-L|--list) LIST_ONLY=true; shift ;;
-n|--dry-run) DRY_RUN=true; shift ;;
-q|--quiet) QUIET=true; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
--no-color) NO_COLOR=true; shift ;;
-h|--help) usage; exit 0 ;;
-V|--version) version; exit 0 ;;
--) shift; break ;;
-*)
log_err "unknown option: $arg"
log_err "run '$SCRIPT_NAME --help' for usage"
exit 2
;;
*)
log_err "unexpected argument: $arg"
exit 2
;;
esac
done
init_colors
if [[ "$MODE" != "word" && "$MODE" != "asterisk" ]]; then
log_err "invalid --mode '$MODE' (expected 'word' or 'asterisk')"
exit 2
fi
if [[ -n "$MASK_CHAR" && ${#MASK_CHAR} -ne 1 ]]; then
log_err "--char must be exactly one character"
exit 2
fi
if [[ "$MASK_LEN" != "match" && ! "$MASK_LEN" =~ ^[1-9][0-9]*$ ]]; then
log_err "--length must be 'match' or a positive integer"
exit 2
fi
if [[ "$IN_PLACE" == true && -n "$OUTPUT" ]]; then
log_err "--in-place and --output are mutually exclusive"
exit 2
fi
# ---------------------------------------------------------------------------
# File discovery
# ---------------------------------------------------------------------------
find_env_file() {
local dir="$PWD" c parent
while true; do
for c in "${CANDIDATES[@]}"; do
if [[ -f "$dir/$c" ]]; then
printf '%s\n' "$dir/$c"
return 0
fi
done
[[ "$WALK_UP" == true ]] || break
parent=$(dirname "$dir")
[[ "$parent" == "$dir" ]] && break
dir="$parent"
done
return 1
}
if [[ "$LIST_ONLY" == true ]]; then
printf 'Search order:\n'
for c in "${CANDIDATES[@]}"; do
if [[ -f "$PWD/$c" ]]; then
printf ' %s%-32s%s FOUND\n' "$c_cyan" "$c" "$c_reset"
else
printf ' %-32s -\n' "$c"
fi
done
if selected=$(find_env_file); then
printf '\nSelected: %s\n' "$selected"
else
printf '\nSelected: (none found)\n'
fi
exit 0
fi
if [[ -z "$FILE" ]]; then
if ! FILE=$(find_env_file); then
log_err "no .env-style file found in $PWD (try -f to specify one, or -r to search parent directories)"
exit 1
fi
log_verb "auto-detected: $FILE"
fi
if [[ "$FILE" != "-" && ! -f "$FILE" ]]; then
log_err "file not found: $FILE"
exit 1
fi
# ---------------------------------------------------------------------------
# Classification: is a key sensitive, and if so what tag describes it?
# ---------------------------------------------------------------------------
key_matches_list() {
local lkey="$1" list="$2" item items
[[ -z "$list" ]] && return 1
IFS=',' read -ra items <<< "$list"
for item in "${items[@]}"; do
item="$(trim "$item")"
item="${item,,}"
[[ -z "$item" ]] && continue
[[ "$lkey" == *"$item"* ]] && return 0
done
return 1
}
# echoes "<true|false> <tag>"
classify_key() {
local key="$1" lkey tag="" sensitive=false
lkey="${key,,}"
if key_matches_list "$lkey" "$EXCLUDE_KEYS"; then
printf 'false \n'
return
fi
[[ "$MASK_ALL" == true ]] && sensitive=true
case "$lkey" in
*password*|*passwd*|*pwd*|*pass*) tag=password; sensitive=true ;;
*secret*) tag=secret; sensitive=true ;;
*token*) tag=token; sensitive=true ;;
*apikey*|*api_key*|*privatekey*|*private_key*|*publickey*|*public_key*|*key*) tag=key; sensitive=true ;;
*credential*|*cred*) tag=credential; sensitive=true ;;
*jwt*) tag=token; sensitive=true ;;
*auth*) tag=auth; sensitive=true ;;
*session*) tag=session; sensitive=true ;;
*cookie*) tag=cookie; sensitive=true ;;
*cert*) tag=certificate; sensitive=true ;;
*salt*|*hash*) tag=hash; sensitive=true ;;
*license*) tag=license; sensitive=true ;;
*webhook*|*url*|*uri*|*endpoint*) tag=url; sensitive=true ;;
*domain*|*hostname*|*host*) tag=domain; sensitive=true ;;
*email*|*mail*) tag=email; sensitive=true ;;
*phone*|*mobile*|*telnum*) tag=phone; sensitive=true ;;
*dsn*|*connection*|*database*) tag=connection; sensitive=true ;;
*smtp*) tag=secret; sensitive=true ;;
*user*) tag=username; sensitive=true ;;
*expiry*|*expires*) tag=date; sensitive=true ;;
*date*|*time*) tag=date; sensitive=true ;;
*version*) tag=version ;;
*path*|*dir*|*file*) tag=path ;;
*name*) tag=text ;;
*port*) tag=number ;;
esac
# Short/ambiguous substrings: require an underscore or start/end boundary
# so e.g. "SHIP_DATE" doesn't match "ip" and "VALID" doesn't match "id".
if [[ "$lkey" =~ (^|_)ip(_|$) ]]; then tag=ip; sensitive=true; fi
if [[ -z "$tag" && "$lkey" =~ (^|_)id(_|$) ]]; then tag=id; sensitive=true; fi
if [[ -z "$tag" && "$lkey" =~ (^|_)db(_|$) ]]; then tag=connection; sensitive=true; fi
if [[ "$lkey" =~ (^|_)pin(_|$) ]]; then tag=pin; sensitive=true; fi
if [[ "$lkey" =~ (^|_)otp(_|$) ]]; then tag=otp; sensitive=true; fi
if [[ -z "$tag" && "$lkey" =~ (^|_)access(_|$) ]]; then tag=token; sensitive=true; fi
if [[ -z "$tag" && "$lkey" =~ (^|_)private(_|$) ]]; then tag=key; sensitive=true; fi
if key_matches_list "$lkey" "$EXTRA_KEYS"; then sensitive=true; fi
printf '%s %s\n' "$sensitive" "$tag"
}
detect_type() {
local v="$1" lv
[[ -z "$v" ]] && { printf ''; return; }
if [[ "$v" =~ ^-?[0-9]+$ ]]; then printf 'number'; return; fi
if [[ "$v" =~ ^-?[0-9]+\.[0-9]+$ ]]; then printf 'number'; return; fi
lv="${v,,}"
if [[ "$lv" == "true" || "$lv" == "false" || "$lv" == "yes" || "$lv" == "no" ]]; then
printf 'boolean'; return
fi
if [[ "$v" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then printf 'ip'; return; fi
if [[ "$v" =~ ^[A-Za-z][A-Za-z0-9+.-]*://.+$ ]]; then printf 'domain'; return; fi
if [[ "$v" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then printf 'email'; return; fi
if [[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}([Tt][0-9]{2}:[0-9]{2}(:[0-9]{2})?(Z|[+-][0-9]{2}:?[0-9]{2})?)?$ ]]; then
printf 'date'; return
fi
if [[ "$v" =~ ^[0-9]{2}/[0-9]{2}/[0-9]{4}$ ]]; then printf 'date'; return; fi
if [[ "$v" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$ ]]; then
printf 'domain'; return
fi
printf 'text'
}
# ---------------------------------------------------------------------------
# Masking primitives
# ---------------------------------------------------------------------------
mask_asterisk() {
local value="$1" n out
if [[ -z "$value" ]]; then printf ''; return; fi
if [[ "$MASK_LEN" == "match" ]]; then n=${#value}; else n=$MASK_LEN; fi
((n < 1)) && n=1
printf -v out '%*s' "$n" ''
out="${out// /$MASK_CHAR}"
printf '%s' "$out"
}
mask_word() {
local key="$1" value="$2" tag="$3" out proto=""
if [[ "$SIMPLE" == true ]]; then
tag="$TAG_WORD"
elif [[ -z "$tag" ]]; then
tag=$(detect_type "$value")
[[ -z "$tag" ]] && tag="$TAG_WORD"
fi
if [[ "$tag" == "domain" || "$tag" == "url" ]]; then
if [[ "$value" =~ ^([A-Za-z][A-Za-z0-9+.-]*://) ]]; then
proto="${BASH_REMATCH[1]}"
fi
fi
[[ "$UPPER" == true ]] && tag="${tag^^}"
out="${proto}<${tag}>"
printf '%s' "$out"
}
mask_value() {
local key="$1" value="$2" tag="$3"
[[ -z "$value" ]] && { printf '%s' "$value"; return; }
if [[ "$MODE" == "asterisk" ]]; then
mask_asterisk "$value"
else
mask_word "$key" "$value" "$tag"
fi
}
# ---------------------------------------------------------------------------
# Line processing
# ---------------------------------------------------------------------------
FULL_RE='^([[:space:]]*)(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_.]*)([[:space:]]*=[[:space:]]*)(.*)$'
DQ_RE='^"((\\.|[^"\\])*)"(.*)$'
SQ_RE="^'([^']*)'(.*)\$"
declare -A DRY_SEEN=()
process_line() {
local raw="$1" cr=""
if [[ "$raw" == *$'\r' ]]; then
cr=$'\r'
raw="${raw%$'\r'}"
fi
if [[ "$raw" =~ ^[[:space:]]*$ ]]; then
[[ "$DRY_RUN" == true ]] && return
printf '%s%s\n' "$raw" "$cr"
return
fi
if [[ "$raw" =~ ^[[:space:]]*# ]]; then
[[ "$DRY_RUN" == true ]] && return
printf '%s%s\n' "$raw" "$cr"
return
fi
if [[ ! "$raw" =~ $FULL_RE ]]; then
[[ "$DRY_RUN" == true ]] && return
printf '%s%s\n' "$raw" "$cr"
return
fi
local prefix key rest
prefix="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${BASH_REMATCH[3]}${BASH_REMATCH[4]}"
key="${BASH_REMATCH[3]}"
rest="${BASH_REMATCH[5]}"
local decision sensitive tag
decision=$(classify_key "$key")
sensitive="${decision%% *}"
tag="${decision#* }"
if [[ "$sensitive" != true ]]; then
if [[ "$DRY_RUN" == true ]]; then
if [[ -z "${DRY_SEEN[$key]:-}" ]]; then
DRY_SEEN[$key]=1
[[ "$VERBOSE" == true ]] && printf '%-40s -> unmasked\n' "$key"
fi
return
fi
printf '%s%s%s\n' "$prefix" "$rest" "$cr"
return
fi
local quote="" inner="" trailing="" masked
if [[ "$rest" =~ $DQ_RE ]]; then
quote='"'
inner="${BASH_REMATCH[1]}"
trailing="${BASH_REMATCH[3]}"
elif [[ "$rest" =~ $SQ_RE ]]; then
quote="'"
inner="${BASH_REMATCH[1]}"
trailing="${BASH_REMATCH[2]}"
else
local before_hash comment_part value_part value_trimmed trailing_ws
if [[ "$rest" == *'#'* ]]; then
before_hash="${rest%%#*}"
comment_part="#${rest#*#}"
else
before_hash="$rest"
comment_part=""
fi
value_part="$before_hash"
value_trimmed="${value_part%%+([[:space:]])}"
trailing_ws="${value_part#"$value_trimmed"}"
inner="$value_trimmed"
trailing="${trailing_ws}${comment_part}"
quote=""
fi
if [[ "$DRY_RUN" == true ]]; then
if [[ -z "${DRY_SEEN[$key]:-}" ]]; then
DRY_SEEN[$key]=1
if [[ -z "$inner" ]]; then
printf '%-40s -> empty, nothing to mask\n' "$key"
else
local shown="$tag"
[[ -z "$shown" ]] && shown="(type-detected)"
printf '%-40s -> mask as <%s>\n' "$key" "$shown"
fi
fi
return
fi
if [[ -z "$inner" ]]; then
printf '%s%s%s%s%s%s\n' "$prefix" "$quote" "$inner" "$quote" "$trailing" "$cr"
return
fi
masked=$(mask_value "$key" "$inner" "$tag")
printf '%s%s%s%s%s%s\n' "$prefix" "$quote" "$masked" "$quote" "$trailing" "$cr"
}
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
if [[ "$IN_PLACE" == true && "$ASSUME_YES" != true ]]; then
if [[ -t 0 ]]; then
printf 'Overwrite %s with masked output? [y/N] ' "$FILE" >&2
read -r reply
[[ "$reply" =~ ^[Yy]$ ]] || { log_info "aborted"; exit 1; }
else
log_err "--in-place requires --yes when not running interactively"
exit 2
fi
fi
read_input() {
local src="$1" line
if [[ "$src" == "-" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
process_line "$line"
done
else
while IFS= read -r line || [[ -n "$line" ]]; do
process_line "$line"
done < "$src"
fi
}
if [[ "$DRY_RUN" == true ]]; then
read_input "$FILE"
exit 0
fi
if [[ "$IN_PLACE" == true ]]; then
tmp_out=$(mktemp "${TMPDIR:-/tmp}/dotenv-mask-output.XXXXXX")
read_input "$FILE" > "$tmp_out"
mv "$tmp_out" "$FILE"
log_info "masked output written to $FILE"
elif [[ -n "$OUTPUT" ]]; then
read_input "$FILE" > "$OUTPUT"
log_info "masked output written to $OUTPUT"
else
read_input "$FILE"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment