diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb32b2..9ba6dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ and versions are tracked in the repo-root `VERSION` file. - Stopped emitting the caller's unredacted argument vector when wrapper diagnostics are enabled, preventing sensitive option and positional values from entering terminal or persistent logs. +- Hardened the primary diagnostic sink to reject unusable and non-regular + targets, normalize new and existing log files to mode `0600`, suppress + best-effort write errors, and report only eligible sinks through + `log_is_enabled`. ### Deprecated diff --git a/lib/bash/std/README.md b/lib/bash/std/README.md index 9500ffb..49a0b17 100644 --- a/lib/bash/std/README.md +++ b/lib/bash/std/README.md @@ -269,8 +269,16 @@ Terminal verbosity and category gates answer different questions: - `set_log_category_level` controls whether a component may emit a record at all. Categories inherit from the nearest explicitly configured dotted parent, then from `default`. -- `BASE_CLI_PRIMARY_LOG`, when set, receives accepted records through DEBUG - even when the terminal remains at INFO. +- `BASE_CLI_PRIMARY_LOG`, when it names an eligible path, receives accepted + records through DEBUG even when the terminal remains at INFO. + +The primary sink is best-effort and never changes application status. An +existing target must be an owned, writable, regular non-symlink file; a missing +target needs an existing writable and searchable parent directory. The library +does not create parent directories. `log_is_enabled` checks this eligibility +without creating or changing the target. Before appending, the library creates +or normalizes the primary log to mode `0600`; setup and write failures are +suppressed and disable that path for the remainder of the process. The global default category gate is permissive for compatibility. Applications can keep their own DEBUG output while limiting a reusable component: diff --git a/lib/bash/std/lib_std.sh b/lib/bash/std/lib_std.sh index 1d12d0f..8fd06e6 100644 --- a/lib/bash/std/lib_std.sh +++ b/lib/bash/std/lib_std.sh @@ -460,8 +460,9 @@ print_path() { __log_init__() { # Map log level strings (FATAL, ERROR, etc.) to numeric values. # Note the '-g' option passed to declare is essential for global scope. - unset _log_levels _loggers_level_map _log_category_level_map - declare -gA _log_levels _loggers_level_map _log_category_level_map + unset _log_levels _loggers_level_map _log_category_level_map _log_primary_sink_failed_paths + declare -gA _log_levels _loggers_level_map _log_category_level_map _log_primary_sink_failed_paths + _log_primary_sink_failed_paths=() # VERBOSE is deprecated compatibility surface; new callers should use DEBUG. _log_levels=([FATAL]=0 [ERROR]=1 [WARN]=2 [INFO]=3 [DEBUG]=4 [VERBOSE]=5) @@ -521,6 +522,102 @@ __log_source_location__() { printf -v "$result_name" '%s:%s' "$source_path" "$source_line" } +# +# __log_primary_sink_is_usable__ - Check the primary sink without modifying it. +# +__log_primary_sink_is_usable__() { + local primary_log="${1-}" parent_dir + + [[ -n "$primary_log" && "$primary_log" != */ ]] || return 1 + [[ -z "${_log_primary_sink_failed_paths[$primary_log]+set}" ]] || return 1 + [[ ! -L "$primary_log" ]] || return 1 + + if [[ -e "$primary_log" ]]; then + if [[ -f "$primary_log" && -O "$primary_log" && -w "$primary_log" ]]; then + return 0 + fi + return 1 + fi + + if [[ "$primary_log" == */* ]]; then + parent_dir="${primary_log%/*}" + [[ -n "$parent_dir" ]] || parent_dir=/ + else + parent_dir=. + fi + + [[ -d "$parent_dir" && -w "$parent_dir" && -x "$parent_dir" ]] +} + +# +# __log_primary_sink_prepare__ - Create or privately harden a usable sink. +# +__log_primary_sink_prepare__() { + local primary_log="$1" chmod_path + + __log_primary_sink_is_usable__ "$primary_log" || return 1 + + if [[ ! -e "$primary_log" ]]; then + # noclobber avoids truncating a target that appears after the + # non-mutating eligibility check. + if ! (umask 077; set -o noclobber; : >"$primary_log") 2>/dev/null; then + [[ -e "$primary_log" && ! -L "$primary_log" ]] || return 1 + fi + fi + + [[ -f "$primary_log" && ! -L "$primary_log" && + -O "$primary_log" && -w "$primary_log" ]] || return 1 + + # macOS chmod does not accept "--"; prefix a bare option-like path instead. + chmod_path="$primary_log" + [[ "$chmod_path" == -* ]] && chmod_path="./$chmod_path" + command chmod 600 "$chmod_path" 2>/dev/null || return 1 + + [[ -f "$primary_log" && ! -L "$primary_log" && + -O "$primary_log" && -w "$primary_log" ]] +} + +# +# __log_primary_sink_append__ - Append one record or file payload. +# +__log_primary_sink_append__() { + local payload_kind="${1-}" payload="${2-}" + local primary_log="${BASE_CLI_PRIMARY_LOG:-}" + + __log_primary_sink_is_usable__ "$primary_log" || return 1 + + ( + umask 077 + __log_primary_sink_prepare__ "$primary_log" || exit 1 + + case "$payload_kind" in + record) + printf '%s\n' "$payload" + ;; + file) + command cat -- "$payload" || exit 1 + printf '\n' + ;; + *) + exit 1 + ;; + esac >>"$primary_log" + ) 2>/dev/null +} + +# +# __log_primary_sink_write__ - Keep sink failures best-effort and disable them. +# +__log_primary_sink_write__() { + local payload_kind="$1" payload="$2" + local primary_log="${BASE_CLI_PRIMARY_LOG:-}" + + if ! __log_primary_sink_append__ "$payload_kind" "$payload"; then + _log_primary_sink_failed_paths["$primary_log"]=1 + fi + return 0 +} + # # __print_log_record__ - Compose and write a structured log record. # @@ -528,7 +625,7 @@ __print_log_record__() { local color="$1" in_level="$2" source_location="$3" local terminal_enabled="${4:-1}" persist_enabled="${5:-0}" shift 5 - local message timestamp log_line primary_log + local message timestamp log_line message="$(__join_message__ "$@")" __log_timestamp__ timestamp @@ -537,12 +634,7 @@ __print_log_record__() { printf '%b%s%b\n' "$color" "$log_line" "$COLOR_OFF" >&2 fi if ((persist_enabled)); then - primary_log="${BASE_CLI_PRIMARY_LOG:-}" - if [[ -n "$primary_log" ]]; then - # The launcher creates the primary log with mode 0600. Direct - # library users may not, so keep the same private default here. - (umask 077; printf '%s\n' "$log_line" >>"$primary_log") || : - fi + __log_primary_sink_write__ record "$log_line" fi } @@ -677,7 +769,8 @@ __log_sink_state__() { if ((category_level >= event_level)); then terminal_level="${_loggers_level_map[$category]:-${_loggers_level_map[default]}}" ((terminal_level >= event_level)) && terminal_state=1 - if [[ -n "${BASE_CLI_PRIMARY_LOG:-}" ]] && ((event_level <= _log_levels[DEBUG])); then + if ((event_level <= _log_levels[DEBUG])) && + __log_primary_sink_is_usable__ "${BASE_CLI_PRIMARY_LOG:-}"; then persist_state=1 fi fi @@ -767,7 +860,7 @@ __print_log_file__() { local in_level="${1-}" [[ -n "$in_level" ]] || return 1 shift - local logger=default file primary_log + local logger=default file local terminal_enabled persist_enabled if [[ "${1-}" == "-l" ]]; then if [[ -z "${2-}" ]]; then @@ -788,10 +881,7 @@ __print_log_file__() { printf '\n' >&2 fi if ((persist_enabled)); then - primary_log="${BASE_CLI_PRIMARY_LOG:-}" - if [[ -n "$primary_log" ]]; then - (umask 077; { cat -- "$file"; printf '\n'; } >>"$primary_log") || : - fi + __log_primary_sink_write__ file "$file" fi fi } diff --git a/lib/bash/std/tests/lib_std.bats b/lib/bash/std/tests/lib_std.bats index c6e97ed..7e0a739 100644 --- a/lib/bash/std/tests/lib_std.bats +++ b/lib/bash/std/tests/lib_std.bats @@ -10,6 +10,14 @@ create_script() { chmod +x "$script_path" } +file_mode() { + if stat -c '%a' "$1" >/dev/null 2>&1; then + stat -c '%a' "$1" + else + stat -f '%Lp' "$1" + fi +} + normalize_tty_output() { local text="$1" text="${text//$'\r'/}" @@ -112,7 +120,7 @@ PY setup() { setup_test_tmpdir PATH="$BASE_TEST_ORIG_PATH" - unset DRY_RUN dry_run LOG_DEBUG LOG_UTC NO_COLOR BASE_BASH_BOOTSTRAP_SOURCE + unset DRY_RUN dry_run LOG_DEBUG LOG_UTC NO_COLOR BASE_BASH_BOOTSTRAP_SOURCE BASE_CLI_PRIMARY_LOG source "$STDLIB_PATH" } @@ -747,6 +755,151 @@ EOF log_is_enabled -l base.library DEBUG } +@test "log_is_enabled validates primary sink paths without modifying them" { + local primary_log="$TEST_TMPDIR/eligible-primary.log" + local existing_log="$TEST_TMPDIR/existing-eligible-primary.log" + local missing_parent_log="$TEST_TMPDIR/missing/primary.log" + local directory_log="$TEST_TMPDIR/directory-primary" + local symlink_target="$TEST_TMPDIR/symlink-target.log" + local symlink_log="$TEST_TMPDIR/symlink-primary.log" + local fifo_log="$TEST_TMPDIR/fifo-primary.log" + local stderr_file="$TEST_TMPDIR/unusable-primary.err" + + BASE_CLI_PRIMARY_LOG="$primary_log" + log_is_enabled DEBUG + [ ! -e "$primary_log" ] + + printf 'existing eligible content\n' >"$existing_log" + chmod 644 "$existing_log" + BASE_CLI_PRIMARY_LOG="$existing_log" + log_is_enabled DEBUG + [ "$(file_mode "$existing_log")" = "644" ] + + mkdir "$directory_log" + BASE_CLI_PRIMARY_LOG="$directory_log" + ! log_is_enabled DEBUG + + printf 'symlink target\n' >"$symlink_target" + ln -s "$symlink_target" "$symlink_log" + BASE_CLI_PRIMARY_LOG="$symlink_log" + ! log_is_enabled DEBUG + + mkfifo "$fifo_log" + BASE_CLI_PRIMARY_LOG="$fifo_log" + ! log_is_enabled DEBUG + log_debug "fifo must not block" 2>"$stderr_file" + [ ! -s "$stderr_file" ] + + BASE_CLI_PRIMARY_LOG="$missing_parent_log" + ! log_is_enabled DEBUG + log_debug "missing parent must stay silent" 2>"$stderr_file" + [ ! -e "$missing_parent_log" ] + [ ! -s "$stderr_file" ] + [ "$(cat "$symlink_target")" = "symlink target" ] +} + +@test "primary sink creates and hardens regular files to mode 0600" { + local new_log="$TEST_TMPDIR/new-private-primary.log" + local existing_log="$TEST_TMPDIR/existing-primary.log" + local option_log="$TEST_TMPDIR/-option-primary.log" + local stderr_file="$TEST_TMPDIR/private-primary.err" + local original_dir original_umask + + original_umask="$(umask)" + umask 000 + BASE_CLI_PRIMARY_LOG="$new_log" log_debug "new private record" 2>"$stderr_file" + umask "$original_umask" + + [ ! -s "$stderr_file" ] + [ "$(file_mode "$new_log")" = "600" ] + [[ "$(cat "$new_log")" == *"new private record"* ]] + + printf 'existing sentinel\n' >"$existing_log" + chmod 666 "$existing_log" + BASE_CLI_PRIMARY_LOG="$existing_log" \ + log_debug "existing private record" 2>"$stderr_file" + + [ ! -s "$stderr_file" ] + [ "$(file_mode "$existing_log")" = "600" ] + [[ "$(cat "$existing_log")" == "existing sentinel"* ]] + [[ "$(cat "$existing_log")" == *"existing private record"* ]] + + original_dir="$PWD" + cd "$TEST_TMPDIR" || return 1 + BASE_CLI_PRIMARY_LOG="-option-primary.log" \ + log_debug "option-like private record" 2>"$stderr_file" + cd "$original_dir" || return 1 + + [ ! -s "$stderr_file" ] + [ "$(file_mode "$option_log")" = "600" ] + [[ "$(cat "$option_log")" == *"option-like private record"* ]] +} + +@test "unusable primary sinks stay silent without disabling the terminal" { + local directory_log="$TEST_TMPDIR/unusable-primary" + local stderr_file="$TEST_TMPDIR/unusable-terminal.err" + + mkdir "$directory_log" + + BASE_CLI_PRIMARY_LOG="$directory_log" \ + log_debug "hidden unusable record" 2>"$stderr_file" + [ ! -s "$stderr_file" ] + + BASE_CLI_PRIMARY_LOG="$directory_log" + ! log_is_enabled DEBUG + set_log_level DEBUG + log_is_enabled DEBUG + log_debug "terminal-only debug" 2>"$stderr_file" + + [[ "$(cat "$stderr_file")" == *"DEBUG"*"terminal-only debug"* ]] + [[ "$(cat "$stderr_file")" != *"Is a directory"* ]] + [[ "$(cat "$stderr_file")" != *"No such file or directory"* ]] +} + +@test "primary sink setup failures keep every failed path disabled" { + local first_log="$TEST_TMPDIR/first-raced-primary.log" + local second_log="$TEST_TMPDIR/second-raced-primary.log" + local stderr_file="$TEST_TMPDIR/raced-primary.err" + + __log_primary_sink_is_usable__ "$first_log" + __log_primary_sink_is_usable__ "$second_log" + __log_primary_sink_prepare__() { + printf 'synthetic primary sink setup failure\n' >&2 + return 1 + } + + BASE_CLI_PRIMARY_LOG="$first_log" \ + __log_primary_sink_write__ record "first must not persist" 2>"$stderr_file" + BASE_CLI_PRIMARY_LOG="$second_log" \ + __log_primary_sink_write__ record "second must not persist" 2>>"$stderr_file" + + [ ! -s "$stderr_file" ] + [ "${_log_primary_sink_failed_paths[$first_log]}" = "1" ] + [ "${_log_primary_sink_failed_paths[$second_log]}" = "1" ] + + [ ! -e "$first_log" ] + [ ! -e "$second_log" ] + ! __log_primary_sink_is_usable__ "$first_log" + ! __log_primary_sink_is_usable__ "$second_log" +} + +@test "read-only primary sink is ignored when the test identity cannot write it" { + local primary_log="$TEST_TMPDIR/read-only-primary.log" + local stderr_file="$TEST_TMPDIR/read-only-primary.err" + + printf 'read-only sentinel\n' >"$primary_log" + chmod 400 "$primary_log" + [[ ! -w "$primary_log" ]] || skip "The test identity can write mode-0400 files." + + BASE_CLI_PRIMARY_LOG="$primary_log" + ! log_is_enabled DEBUG + log_debug "must not persist" 2>"$stderr_file" + + [ ! -s "$stderr_file" ] + [ "$(file_mode "$primary_log")" = "400" ] + [ "$(cat "$primary_log")" = "read-only sentinel" ] +} + @test "log_is_enabled rejects malformed and invalid input without changing logging state" { local stderr_file="$TEST_TMPDIR/log-is-enabled-invalid.err" local rc @@ -979,6 +1132,26 @@ EOF [[ "$(cat "$primary_log")" == *$'unterminated contents\n'*"next structured record"* ]] } +@test "file logging hardens an existing primary sink before appending" { + local target="$TEST_TMPDIR/log-private-target.txt" + local stderr_file="$TEST_TMPDIR/log-private-file.err" + local primary_log="$TEST_TMPDIR/log-private-file.log" + + printf 'existing sink content\n' >"$primary_log" + chmod 644 "$primary_log" + printf 'private file contents\n' >"$target" + set_log_category_level -l base.files DEBUG + + BASE_CLI_PRIMARY_LOG="$primary_log" \ + log_debug_file -l base.files "$target" 2>"$stderr_file" + + [ ! -s "$stderr_file" ] + [ "$(file_mode "$primary_log")" = "600" ] + [[ "$(cat "$primary_log")" == "existing sink content"* ]] + [[ "$(cat "$primary_log")" == *"Contents of file '$target':"* ]] + [[ "$(cat "$primary_log")" == *"private file contents"* ]] +} + @test "enter and leave logging helpers include the caller name" { local stderr_file="$TEST_TMPDIR/enter-leave.err"