Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
63 changes: 63 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Complete reference for all public functions in the bash-logger module.
* [log_fatal](#log_fatal)
* [log_init](#log_init)
* [log_sensitive](#log_sensitive)
* [log_to_journal](#log_to_journal)
* [Runtime Configuration Functions](#runtime-configuration-functions)
* [set_log_level](#set_log_level)
* [set_log_format](#set_log_format)
Expand Down Expand Up @@ -572,6 +573,68 @@ output that could be captured or logged by terminal emulators or shell history.

---

### log_to_journal

Send a single message directly to the system journal, regardless of the `USE_JOURNAL` setting.

This follows the same per-call override pattern as `log_sensitive`. It is intended for callers
that must guarantee journal delivery for a specific message without enabling journal logging
globally.

**Syntax:**

```bash
log_to_journal LEVEL MESSAGE
```

**Parameters:**

* `LEVEL` - Log level name: `DEBUG`, `INFO`, `NOTICE`, `WARN`, `ERROR`, `CRITICAL`, `ALERT`,
`EMERGENCY` (aliases `WARNING`, `ERR`, `CRIT`, `EMERG`, `FATAL` are also accepted), or a
numeric syslog level `0`–7.
* `MESSAGE` - The message to send to the journal.

**Returns:**

* `0` - Success
* `1` - Unrecognised level name, wrong number of arguments, or `logger` command not available

**Behaviour:**

* Respects the current log level — if the resolved level value is above `CURRENT_LOG_LEVEL`
the message is silently suppressed, matching all other log functions.
* Applies the same sanitisation (newline and ANSI stripping) and truncation rules as all
other log functions.
* Console and file output follow the normal `CONSOLE_LOG` and `LOG_FILE` settings.
* When `logger` is not available and `USE_JOURNAL` is `false`, a warning is emitted to stderr
and the function returns `1` rather than silently discarding the message.
* `log_sensitive` behaviour is unaffected — `log_to_journal` does not change the
`skip_journal` logic and cannot cause sensitive messages to reach the journal.

**Examples:**

```bash
# Force a critical audit event to the journal without enabling journal logging globally
log_to_journal CRITICAL "User 'root' logged in from $REMOTE_ADDR"

# Use alongside regular logging
init_logger --log /var/log/myapp.log
log_info "Normal file-only message"
log_to_journal NOTICE "Significant event that must reach the journal"

# Guard against unavailable logger
if ! log_to_journal ERROR "Deployment complete"; then
log_error "Could not write to system journal"
fi
```

**See Also:**

* [Journal Logging Guide](journal-logging.md)
* [set_journal_logging](#set_journal_logging)

---

## Runtime Configuration Functions

These functions allow you to change logger settings after initialization.
Expand Down
110 changes: 71 additions & 39 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ Comprehensive guide to the bash-logger test suite, including how to run tests, w

## Overview

The bash-logger project includes a comprehensive test suite with 103 tests across 6 test
suites, validating all functionality of the logging module. The test framework is built
in pure Bash and designed to be:
The bash-logger project includes a comprehensive test suite with 23 runnable test suites
and 466 total tests (snapshot from `bash tests/run_tests.sh` on 2026-03-11).
The test framework is built in pure Bash and designed to be:

* **Self-contained**: No external test frameworks required
* **CI-friendly**: Clear exit codes and non-interactive
Expand Down Expand Up @@ -124,12 +124,29 @@ cd tests

Available test suites:

* `test_ansi_injection` - ANSI escape sanitization and related security tests
* `test_concurrent_access` - Concurrency and parallel logging behavior
* `test_config` - Configuration file parsing and behavior
* `test_config_security` - Security hardening for configuration input
* `test_edge_cases` - Boundary and unusual input handling
* `test_environment_security` - Environment-based security checks
* `test_error_conditions` - Error handling and defensive behavior
* `test_format` - Message format templates and formatting behavior
* `test_fuzzing` - Fuzz-style robustness checks
* `test_initialization` - Logger initialization behavior
* `test_install` - Installation and setup scripts
* `test_journal_logging` - Journal-specific behavior and forced journal logging
* `test_junit_output` - JUnit XML report generation
* `test_log_levels` - Log level functionality
* `test_initialization` - Logger initialization
* `test_output` - Output routing and formatting
* `test_format` - Message format templates
* `test_config` - Configuration file parsing
* `test_mixed_sanitization_modes` - Combined sanitization mode behavior
* `test_output` - Output routing and stream behavior
* `test_path_traversal` - Path traversal protections
* `test_resource_limits` - Resource and size limit behavior
* `test_runtime_config` - Runtime configuration changes
* `test_script_name_sanitization` - Script and tag name sanitization
* `test_sensitive_data` - Sensitive data handling protections
* `test_toctou_protection` - TOCTOU/race-condition protections
* `test_unsafe_newlines` - Unsafe newline mode behavior

### Understanding Test Output

Expand All @@ -151,9 +168,10 @@ Running test_log_levels...
Test Summary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Total Tests: 103
Passed: 103
Total Tests: 466
Passed: 461
Failed: 0
Skipped: 5
Comment on lines +171 to +174
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The surrounding text now emphasizes using tests/run_tests.sh for authoritative totals, but this example output hard-codes specific test counts. These numbers will go stale as suites/tests change. Consider replacing the totals with placeholders (e.g., <n>) or removing the numeric lines entirely to avoid documentation drift.

Suggested change
Total Tests: 466
Passed: 461
Failed: 0
Skipped: 5
Total Tests: <total>
Passed: <passed>
Failed: <failed>
Skipped: <skipped>

Copilot uses AI. Check for mistakes.

All tests passed!
```
Expand Down Expand Up @@ -325,28 +343,53 @@ The test suite is organized into the following files:

**Test Suites** (`tests/`):

* `test_log_levels.sh` - 12 tests for log levels
* `test_initialization.sh` - 21 tests for initialization
* `test_output.sh` - 17 tests for output routing
* `test_format.sh` - 16 tests for message formatting
* `test_config.sh` - 21 tests for config file parsing
* `test_runtime_config.sh` - 16 tests for runtime changes
`run_tests.sh` discovers test suites automatically using `test_*.sh`
(excluding `test_helpers.sh` and `test_example.sh`).

Current runnable suite files include:

* `test_ansi_injection.sh`
* `test_concurrent_access.sh`
* `test_config.sh`
* `test_config_security.sh`
* `test_edge_cases.sh`
* `test_environment_security.sh`
* `test_error_conditions.sh`
* `test_format.sh`
* `test_fuzzing.sh`
* `test_initialization.sh`
* `test_install.sh`
* `test_journal_logging.sh`
* `test_junit_output.sh`
* `test_log_levels.sh`
* `test_mixed_sanitization_modes.sh`
* `test_output.sh`
* `test_path_traversal.sh`
* `test_resource_limits.sh`
* `test_runtime_config.sh`
* `test_script_name_sanitization.sh`
* `test_sensitive_data.sh`
* `test_toctou_protection.sh`
* `test_unsafe_newlines.sh`

### Test Coverage

Current test coverage includes:

| Component | Coverage | Tests |
| --------------------- | ----------- | ------------------ |
| Log Levels | ✅ Complete | 12 |
| Initialization | ✅ Complete | 21 |
| Output Routing | ✅ Complete | 17 |
| Message Formatting | ✅ Complete | 16 |
| Configuration Files | ✅ Complete | 21 |
| Runtime Configuration | ✅ Complete | 16 |
| Journal Logging | ⚠️ Basic | Included |
| Color Detection | ⚠️ Limited | Terminal-dependent |
| **Total** | | **103** |
* Core logging behavior: ✅ Extensive
Notes: levels, output streams, formatting, initialization.
* Configuration handling: ✅ Extensive
Notes: parsing, validation, and security-focused config tests.
* Journal logging: ✅ Extensive
Notes: initialization, runtime toggles, and forced per-call journal writes.
* Security hardening: ✅ Extensive
Notes: sensitive data, path traversal, ANSI/newline handling, and TOCTOU.
* Reliability and robustness: ✅ Extensive
Notes: concurrency, fuzzing, edge cases, and resource limits.
* CI/reporting integrations: ✅ Covered
Notes: JUnit XML output and SonarQube-related flow.

Use `./tests/run_tests.sh` to get the authoritative, current totals on your machine.

## Writing Tests

Expand Down Expand Up @@ -592,19 +635,8 @@ test_feature_edge_case
chmod +x tests/test_feature.sh
```

1. **Add to test runner**: Edit `tests/run_tests.sh`, add to the test files array:

```bash
test_files=(
"$SCRIPT_DIR/test_log_levels.sh"
"$SCRIPT_DIR/test_initialization.sh"
"$SCRIPT_DIR/test_output.sh"
"$SCRIPT_DIR/test_format.sh"
"$SCRIPT_DIR/test_config.sh"
"$SCRIPT_DIR/test_runtime_config.sh"
"$SCRIPT_DIR/test_feature.sh" # Add your new suite
)
```
1. **No test runner edit required**: `tests/run_tests.sh` auto-discovers
`test_*.sh` files in `tests/` (excluding `test_helpers.sh` and `test_example.sh`).

1. **Run your new tests**:

Expand Down
91 changes: 87 additions & 4 deletions logging.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
# - log_warn, log_error, log_critical
# - log_alert, log_emergency, log_fatal
# - log_init, log_sensitive : Special purpose logging
# - log_to_journal <level> <message> : Force a single message to the journal
#
# Runtime Configuration:
# - set_log_level <level> : Change log level dynamically
Expand Down Expand Up @@ -227,6 +228,11 @@ _should_use_stderr() {
if ! readonly -p 2>/dev/null | grep -q "declare -[^ ]*r[^ ]* LOGGER_PATH="; then
LOGGER_PATH=""
fi
# Internal flag: set to "true" by _find_and_validate_logger on every exit path
# (success and failure alike). Allows log_to_journal to skip discovery when
# LOGGER_PATH is empty due to a failed/untrusted lookup rather than no lookup at all.
# Not readonly — resetting to "false" on re-source is correct (new context must re-validate).
_LOGGER_DISCOVERY_DONE="false"

# Find and validate the logger command to prevent PATH manipulation attacks
# This function finds the logger executable and validates it's in a safe system location
Expand All @@ -238,6 +244,7 @@ _find_and_validate_logger() {

if [[ -z "$logger_candidate" ]]; then
USE_JOURNAL="false"
_LOGGER_DISCOVERY_DONE="true"
return 1
fi

Expand All @@ -254,17 +261,20 @@ _find_and_validate_logger() {
# This preserves immutability while still allowing repeat availability checks.
if readonly -p 2>/dev/null | grep -q "declare -[^ ]*r[^ ]* LOGGER_PATH="; then
if [[ "$LOGGER_PATH" == "$logger_candidate" ]]; then
_LOGGER_DISCOVERY_DONE="true"
return 0
fi
echo "Warning: logger path changed after validation: $logger_candidate" >&2
echo " Locked logger path is: $LOGGER_PATH" >&2
echo " Journal logging disabled for security" >&2
USE_JOURNAL="false"
_LOGGER_DISCOVERY_DONE="true"
return 1
fi

LOGGER_PATH="$logger_candidate"
readonly LOGGER_PATH
_LOGGER_DISCOVERY_DONE="true"
return 0
;;
*)
Expand All @@ -273,6 +283,7 @@ _find_and_validate_logger() {
echo " Expected: /bin, /usr/bin, /usr/local/bin, /sbin, or /usr/sbin" >&2
echo " Journal logging disabled for security" >&2
USE_JOURNAL="false"
_LOGGER_DISCOVERY_DONE="true"
return 1
;;
esac
Expand Down Expand Up @@ -1590,6 +1601,7 @@ _log_message() {
local message="$3"
local skip_file="${4:-false}"
local skip_journal="${5:-false}"
local force_journal="${6:-false}"

# Skip logging if message level is more verbose than current log level
# With syslog-style levels, HIGHER values are LESS severe (more verbose)
Expand Down Expand Up @@ -1629,9 +1641,9 @@ _log_message() {
}
fi

# If journal logging is enabled and logger path is already validated, log to the system journal
# Skip journal logging if skip_journal is true
if [[ "$USE_JOURNAL" == "true" && "$skip_journal" != "true" ]]; then
# If journal logging is enabled or force_journal is true, log to the system journal.
# skip_journal takes precedence — it overrides force_journal when true.
if [[ ( "$USE_JOURNAL" == "true" || "$force_journal" == "true" ) && "$skip_journal" != "true" ]]; then
# Map our log level to syslog priority
local syslog_priority
syslog_priority=$(_get_syslog_priority "$level_value")
Expand All @@ -1642,7 +1654,14 @@ _log_message() {
journal_message=$(_truncate_log_message "$sanitized_message" "$LOG_MAX_JOURNAL_LENGTH")
local plain_message
plain_message=$(_strip_ansi_codes "$journal_message")
_write_to_journal "$syslog_priority" "${JOURNAL_TAG:-$SCRIPT_NAME}" "$plain_message"

# Pass force_when_disabled=true when force_journal=true and USE_JOURNAL=false so
# _write_to_journal does not short-circuit the write.
local write_forced="false"
if [[ "$force_journal" == "true" && "$USE_JOURNAL" != "true" ]]; then
write_forced="true"
fi
_write_to_journal "$syslog_priority" "${JOURNAL_TAG:-$SCRIPT_NAME}" "$plain_message" "$write_forced"
fi
}

Expand Down Expand Up @@ -1693,6 +1712,70 @@ log_sensitive() {
_log_message "SENSITIVE" $LOG_LEVEL_INFO "$1" "true" "true"
}

# Log a single message directly to the system journal, regardless of USE_JOURNAL state.
# Respects the current log level, sanitisation, and truncation rules.
# If the logger command is not available, emits a warning to stderr.
#
# Usage: log_to_journal LEVEL MESSAGE
#
# Parameters:
# LEVEL - Log level name (DEBUG, INFO, NOTICE, WARN, ERROR, CRITICAL, ALERT, EMERGENCY)
# MESSAGE - The message to log
#
# Returns:
# 0 - Success
# 1 - Invalid level or logger not available
log_to_journal() {
if [[ $# -ne 2 ]]; then
echo "Usage: log_to_journal LEVEL MESSAGE" >&2
return 1
fi

local level_name="$1"
local message="$2"

# Validate and normalise the level name to the canonical form used by _log_message
local canonical_level
case "${level_name^^}" in
DEBUG) canonical_level="DEBUG" ;;
INFO) canonical_level="INFO" ;;
NOTICE) canonical_level="NOTICE" ;;
WARN|WARNING) canonical_level="WARN" ;;
ERROR|ERR) canonical_level="ERROR" ;;
CRITICAL|CRIT) canonical_level="CRITICAL" ;;
ALERT) canonical_level="ALERT" ;;
EMERGENCY|EMERG|FATAL) canonical_level="EMERGENCY" ;;
[0-7])
# Numeric syslog level — resolve to its canonical name
canonical_level=$(_get_log_level_name "${level_name}")
;;
*)
echo "Error: log_to_journal: unrecognised level '$level_name'" >&2
echo " Valid levels: DEBUG, INFO, NOTICE, WARN, ERROR, CRITICAL, ALERT, EMERGENCY (or 0-7)" >&2
return 1
;;
esac

local level_value
level_value=$(_get_log_level_value "$canonical_level")

# Fast-path: if discovery has already run (successfully or not), skip it.
# _LOGGER_DISCOVERY_DONE is set on every exit path of _find_and_validate_logger,
# so an empty LOGGER_PATH with the flag set means logger is absent or untrusted —
# no point repeating command -v, symlink resolution, and path validation.
if [[ "$_LOGGER_DISCOVERY_DONE" != "true" ]]; then
check_logger_available
fi

# Abort before any writes if logger is still unavailable after attempted discovery.
if [[ -z "$LOGGER_PATH" || ! -x "$LOGGER_PATH" ]]; then
echo "WARNING: log_to_journal called but logger command is not available" >&2
return 1
fi
Comment on lines +1777 to +1786
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function comment and API docs describe log_to_journal as using normal routing (console/log file) and also forcing a journal write. However, the early return when LOGGER_PATH is unavailable aborts before calling _log_message, so nothing is written to console/log file in that case. Either update the docs to reflect that the message is not logged anywhere on journal failure, or restructure so console/file logging still occurs even when the journal write cannot be performed (while still returning non-zero).

Copilot uses AI. Check for mistakes.

_log_message "$canonical_level" "$level_value" "$message" "false" "false" "true"
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log_to_journal currently returns success as long as LOGGER_PATH exists and is executable, but it does not propagate a failure from _write_to_journal (e.g., if logger exits non-zero). This can lead to callers thinking a message was written to the journal when it wasn't. Consider invoking _write_to_journal in a way that allows log_to_journal to return its failure status, or refactor _log_message/_write_to_journal so the journal write result can be surfaced.

Suggested change
_log_message "$canonical_level" "$level_value" "$message" "false" "false" "true"
if ! _log_message "$canonical_level" "$level_value" "$message" "false" "false" "true"; then
return 1
fi
return 0

Copilot uses AI. Check for mistakes.
}

# Only execute initialization if this script is being run directly
# If it's being sourced, the sourcing script should call init_logger
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
Expand Down
Loading
Loading