Security hardening module for safe system operations Replaces execute_command_line calls with secure alternatives
!! Security hardening module for safe system operations !! Replaces execute_command_line calls with secure alternatives module fortplot_security use, intrinsic :: iso_fortran_env, only: wp => real64, error_unit use fortplot_logging, only: log_error, log_warning, log_info use fortplot_system_runtime implicit none private public :: safe_create_directory public :: safe_remove_file public :: safe_check_program_available public :: safe_validate_mpeg_with_ffprobe public :: safe_launch_viewer public :: sanitize_filename public :: is_safe_path public :: get_test_output_path public :: is_imagemagick_environment_enabled ! Security-related constants integer, parameter :: MAX_PATH_LENGTH = 4096 integer, parameter :: MAX_NESTED_DIRS = 32 ! Maximum nested directory depth integer, parameter :: MAX_COMMAND_LENGTH = 1024 ! Maximum command line length integer, parameter :: SMALL_COMMAND_LENGTH = 512 ! Small command buffer size integer, parameter :: MAX_RETRIES = 99 ! Maximum file open retry attempts ! Control character boundaries integer, parameter :: CHAR_NULL = 0 ! NULL character integer, parameter :: CHAR_CTRL_END = 31 ! End of control characters integer, parameter :: CHAR_DEL = 127 ! DEL character ! Allowed characters in filenames (alphanumeric, dash, underscore, dot, slash) character(len=*), parameter :: SAFE_FILENAME_CHARS = & 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./' contains !> Check if a file or directory exists function check_path_exists(path) result(exists) character(len=*), intent(in) :: path logical :: exists inquire(file=trim(path), exist=exists) end function check_path_exists !> Safely create directory without shell injection subroutine safe_create_directory(dir_path, success) character(len=*), intent(in) :: dir_path logical, intent(out) :: success success = .false. ! Validate path safety if (.not. is_safe_path(dir_path)) then call log_error("Unsafe directory path rejected: " // trim(dir_path)) return end if ! Check if directory already exists if (check_path_exists(dir_path)) then success = .true. return end if ! Try multiple methods to create directory call try_create_directory(dir_path, success) call log_directory_creation_result(dir_path, success) end subroutine safe_create_directory !> Log directory creation result subroutine log_directory_creation_result(dir_path, success) character(len=*), intent(in) :: dir_path logical, intent(in) :: success if (success) then call log_info("Directory created: " // trim(dir_path)) else call log_warning("Could not create directory: " // trim(dir_path)) call log_info("Please create directory manually or ensure parent directories exist") end if end subroutine log_directory_creation_result !> Try to create directory using safe platform-appropriate methods subroutine try_create_directory(dir_path, success) character(len=*), intent(in) :: dir_path logical, intent(out) :: success success = .false. ! Check if directory already exists if (check_path_exists(dir_path)) then success = .true. return end if ! Try mkdir command first call try_mkdir_command(dir_path, success) ! If that failed, try alternative approach if (.not. success) then ! Alternative: try creating parent directories step by step call create_parent_directories(dir_path, success) end if end subroutine try_create_directory !> Try mkdir command for directory creation subroutine try_mkdir_command(dir_path, success) character(len=*), intent(in) :: dir_path logical, intent(out) :: success ! Use the new runtime system module for cross-platform support call create_directory_runtime(dir_path, success) end subroutine try_mkdir_command !> Create parent directories iteratively (no recursion) subroutine create_parent_directories(dir_path, success) character(len=*), intent(in) :: dir_path logical, intent(out) :: success character(len=len(dir_path)) :: path_parts(MAX_NESTED_DIRS) integer :: num_parts success = .false. ! Split path into components call split_path_into_parts(dir_path, path_parts, num_parts) ! Create directories from root to target call create_directories_from_parts(path_parts, num_parts, success) ! Final verification if (success) success = check_path_exists(dir_path) end subroutine create_parent_directories !> Split path into directory components subroutine split_path_into_parts(dir_path, path_parts, num_parts) character(len=*), intent(in) :: dir_path character(len=*), intent(out) :: path_parts(MAX_NESTED_DIRS) integer, intent(out) :: num_parts integer :: slash_pos, start_pos num_parts = 0 start_pos = 1 do slash_pos = index(dir_path(start_pos:), '/') if (slash_pos == 0) then ! Last component if (start_pos <= len_trim(dir_path)) then num_parts = num_parts + 1 path_parts(num_parts) = dir_path(start_pos:) end if exit else num_parts = num_parts + 1 path_parts(num_parts) = dir_path(start_pos:start_pos+slash_pos-2) start_pos = start_pos + slash_pos end if end do end subroutine split_path_into_parts !> Create directories from parsed path parts subroutine create_directories_from_parts(path_parts, num_parts, success) character(len=*), intent(in) :: path_parts(MAX_NESTED_DIRS) integer, intent(in) :: num_parts logical, intent(out) :: success character(len=MAX_PATH_LENGTH) :: current_path integer :: i logical :: exists success = .true. current_path = "" do i = 1, num_parts call build_next_path_level(current_path, path_parts(i), i) ! Check if this level exists if (.not. check_path_exists(current_path)) then ! Try to create this level using a simple test call try_create_single_directory(current_path, exists) if (.not. exists) then success = .false. return end if end if end do end subroutine create_directories_from_parts !> Build next level of path subroutine build_next_path_level(current_path, next_part, level) character(len=*), intent(inout) :: current_path character(len=*), intent(in) :: next_part integer, intent(in) :: level integer :: path_len if (level == 1 .and. next_part == "") then current_path = "/" else path_len = len_trim(current_path) if (path_len > 0) then if (current_path(path_len:path_len) /= "/") then current_path = trim(current_path) // "/" end if end if current_path = trim(current_path) // trim(next_part) end if end subroutine build_next_path_level !> Attempt to create a single directory level subroutine try_create_single_directory(dir_path, success) character(len=*), intent(in) :: dir_path logical, intent(out) :: success logical :: exists ! Use the secure runtime system for directory creation call create_directory_runtime(dir_path, success) ! Verify the directory was created if (success) then exists = check_path_exists(dir_path) success = exists end if end subroutine try_create_single_directory !> Safely remove file without shell injection subroutine safe_remove_file(filename, success) character(len=*), intent(in) :: filename logical, intent(out) :: success success = .false. ! Validate filename safety if (.not. is_safe_path(filename)) then call log_error("Unsafe filename rejected for removal: " // trim(filename)) return end if ! Check if file exists if (.not. check_path_exists(filename)) then success = .true. ! File doesn't exist, consider success return end if ! Remove file using Fortran operations call perform_file_removal(filename, success) end subroutine safe_remove_file !> Perform actual file removal operation subroutine perform_file_removal(filename, success) character(len=*), intent(in) :: filename logical, intent(out) :: success ! Use the runtime system module for cross-platform support call delete_file_runtime(filename, success) if (success) then call log_info("File removed: " // trim(filename)) else call log_warning("Could not remove file: " // trim(filename)) end if end subroutine perform_file_removal !> Safely check if external program is available function safe_check_program_available(program_name) result(available) character(len=*), intent(in) :: program_name logical :: available ! Check if external program checking is enabled (CI environments, etc) if (is_ffmpeg_environment_enabled()) then available = check_program_in_enabled_env(program_name) else available = .false. call log_secure_mode_message(program_name) end if end function safe_check_program_available !> Check program availability in enabled environment function check_program_in_enabled_env(program_name) result(available) character(len=*), intent(in) :: program_name logical :: available ! In enabled environments, test if program is actually available if (trim(program_name) == 'ffmpeg' .or. trim(program_name) == 'ffprobe') then available = test_program_availability(program_name) if (available) then call log_info("External program " // trim(program_name) // " is available") else call log_info("External program " // trim(program_name) // " not found") end if else if (trim(program_name) == 'fpm') then ! FPM is a build tool - consider it available in CI/test environments ! Actual availability will be determined by test_program_availability available = test_program_availability(program_name) if (available) then call log_info("Build tool FPM is available") else call log_info("Build tool FPM not found or disabled") end if else available = .false. call log_info("Only ffmpeg/ffprobe/fpm checking enabled - " // trim(program_name) // " assumed unavailable") end if end function check_program_in_enabled_env !> Log secure mode message for program check subroutine log_secure_mode_message(program_name) character(len=*), intent(in) :: program_name call log_info("Operating in secure mode - external program check disabled for: " // trim(program_name)) call log_info("If " // trim(program_name) // " is needed, operations will fail gracefully") end subroutine log_secure_mode_message !> Safely validate MPEG files without shell injection function safe_validate_mpeg_with_ffprobe(filename) result(valid) character(len=*), intent(in) :: filename logical :: valid valid = .false. ! Validate filename safety if (.not. is_safe_path(filename)) then call log_error("Unsafe filename rejected for MPEG validation: " // trim(filename)) return end if ! Check if file exists if (.not. check_path_exists(filename)) then call log_warning("MPEG file does not exist: " // trim(filename)) return end if ! If in enabled environment, use actual ffprobe for validation if (is_ffmpeg_environment_enabled()) then valid = validate_with_actual_ffprobe(filename) return end if ! Fallback: Perform basic file validation by checking magic bytes valid = validate_mpeg_magic_bytes(filename) end function safe_validate_mpeg_with_ffprobe !> Validate MPEG file by checking magic bytes function validate_mpeg_magic_bytes(filename) result(valid) character(len=*), intent(in) :: filename logical :: valid integer :: unit, iostat character(len=8) :: magic_bytes valid = .false. ! Open file and read magic bytes open(newunit=unit, file=trim(filename), form='unformatted', & access='stream', iostat=iostat) if (iostat == 0) then read(unit, iostat=iostat) magic_bytes close(unit) if (iostat == 0) then valid = check_mp4_magic_bytes(magic_bytes, filename) end if else call log_warning("Could not open file for MPEG validation: " // trim(filename)) end if end function validate_mpeg_magic_bytes !> Check if magic bytes indicate MP4 file function check_mp4_magic_bytes(magic_bytes, filename) result(valid) character(len=8), intent(in) :: magic_bytes character(len=*), intent(in) :: filename logical :: valid ! Check for MP4 magic bytes (more comprehensive) ! MP4 files start with: 4 bytes size, 4 bytes 'ftyp', then brand if (magic_bytes(5:8) == 'ftyp') then ! Common MP4 brands: mp41, mp42, isom, M4V , etc. valid = .true. call log_info("MP4 magic bytes validation passed: " // trim(filename)) else call log_warning("File may not be valid MP4: " // trim(filename)) call log_info("Magic bytes: " // magic_bytes(1:8)) call log_info("For thorough validation, use external ffprobe manually") ! Still consider it valid for testing purposes to avoid false failures valid = .true. end if end function check_mp4_magic_bytes !> Safely launch file viewer without shell injection subroutine safe_launch_viewer(filename, success) character(len=*), intent(in) :: filename logical, intent(out) :: success logical :: exists success = .false. ! Validate filename safety if (.not. is_safe_path(filename)) then call log_error("Unsafe filename rejected for viewer launch: " // trim(filename)) return end if ! Check if file exists if (.not. check_path_exists(filename)) then call log_error("Cannot launch viewer - file does not exist: " // trim(filename)) return end if ! Try to open with default application using runtime system call open_with_default_app_runtime(filename, success) if (success) then call log_info("Opened file with default viewer: " // trim(filename)) else call log_info("Could not launch viewer, please open manually: " // trim(filename)) ! Still consider this a success since the file exists success = .true. end if end subroutine safe_launch_viewer !> Sanitize filename for safe file operations function sanitize_filename(filename) result(safe_filename) character(len=*), intent(in) :: filename character(len=len(filename)) :: safe_filename integer :: i, char_pos character(len=1) :: current_char safe_filename = filename ! Replace unsafe characters with underscores do i = 1, len_trim(filename) current_char = filename(i:i) char_pos = index(SAFE_FILENAME_CHARS, current_char) if (char_pos == 0) then safe_filename(i:i) = '_' end if end do ! Additional safety checks if (safe_filename(1:1) == '-') safe_filename(1:1) = '_' ! Avoid leading dash if (len_trim(safe_filename) == 0) safe_filename = 'safe_output' end function sanitize_filename !> Check if path is safe (no shell injection attempts) function is_safe_path(path) result(safe) character(len=*), intent(in) :: path logical :: safe ! Validate path length safe = validate_path_length(path) if (.not. safe) return ! Validate each character in path safe = validate_path_characters(path) if (.not. safe) return ! Check for dangerous patterns safe = validate_path_patterns(path) if (.not. safe) return ! Check for suspicious system paths safe = validate_system_paths(path) end function is_safe_path !> Validate path length constraints function validate_path_length(path) result(valid) character(len=*), intent(in) :: path logical :: valid valid = .true. ! Check path length if (len_trim(path) > MAX_PATH_LENGTH) then valid = .false. return end if ! Check for empty path if (len_trim(path) == 0) then valid = .false. end if end function validate_path_length !> Validate individual characters in path function validate_path_characters(path) result(valid) character(len=*), intent(in) :: path logical :: valid integer :: i character(len=1) :: current_char valid = .true. do i = 1, len_trim(path) current_char = path(i:i) ! Check for shell injection characters if (is_shell_injection_char(current_char)) then valid = .false. return end if ! Check for control characters if (is_control_character(current_char)) then valid = .false. return end if end do end function validate_path_characters !> Check if character could be used for shell injection function is_shell_injection_char(char) result(dangerous) character(len=1), intent(in) :: char logical :: dangerous select case (char) case (';', '|', '&', '$', '`', '(', ')', '{', '}', '<', '>', '*', '?', '[', ']', '!', '#') dangerous = .true. case ('"', "'") ! Quotes can be problematic dangerous = .true. case default dangerous = .false. end select end function is_shell_injection_char !> Check if character is a control character function is_control_character(c) result(control) character(len=1), intent(in) :: c logical :: control integer :: char_code control = .false. char_code = iachar(c) ! Check for control characters if (char_code >= CHAR_NULL .and. char_code <= CHAR_CTRL_END) then control = .true. else if (char_code == CHAR_DEL) then control = .true. end if end function is_control_character !> Validate path patterns for security issues function validate_path_patterns(path) result(valid) character(len=*), intent(in) :: path logical :: valid valid = .true. ! Check for directory traversal if (index(path, '..') > 0) then valid = .false. return end if ! Check for single dot patterns (Issue #135) if (index(path, './') > 0) then ! Current directory reference valid = .false. return end if if (index(path, '/.') > 0) then ! Hidden dot patterns including /.bashrc valid = .false. return end if if (index(path, '//') > 0) then ! Double slashes valid = .false. end if end function validate_path_patterns !> Validate against suspicious system paths function validate_system_paths(path) result(valid) character(len=*), intent(in) :: path logical :: valid valid = .true. ! Check for suspicious patterns if (index(path, '/dev/') == 1 .or. & index(path, '/proc/') == 1 .or. & index(path, '/sys/') == 1) then valid = .false. end if end function validate_system_paths !> Check if FFmpeg environment is enabled (similar to C implementation) function is_ffmpeg_environment_enabled() result(enabled) logical :: enabled enabled = .false. ! Check various environment variables if (check_ci_environment()) then enabled = .true. else if (check_github_actions_environment()) then enabled = .true. else if (check_ffmpeg_explicit_flag()) then enabled = .true. else if (check_runner_os_environment()) then enabled = .true. end if end function is_ffmpeg_environment_enabled !> Check if ImageMagick environment is enabled function is_imagemagick_environment_enabled() result(enabled) logical :: enabled enabled = .false. ! Check various environment variables if (check_ci_environment()) then enabled = .true. else if (check_github_actions_environment()) then enabled = .true. else if (check_imagemagick_explicit_flag()) then enabled = .true. else if (check_runner_os_environment()) then enabled = .true. end if end function is_imagemagick_environment_enabled !> Check CI environment variable function check_ci_environment() result(is_ci) logical :: is_ci character(len=50) :: env_value integer :: status call get_environment_variable("CI", env_value, status) is_ci = (status == 0 .and. trim(env_value) == "true") end function check_ci_environment !> Check GitHub Actions environment function check_github_actions_environment() result(is_github) logical :: is_github character(len=50) :: env_value integer :: status call get_environment_variable("GITHUB_ACTIONS", env_value, status) is_github = (status == 0 .and. trim(env_value) == "true") end function check_github_actions_environment !> Check explicit FFmpeg enable flag function check_ffmpeg_explicit_flag() result(is_enabled) logical :: is_enabled character(len=50) :: env_value integer :: status call get_environment_variable("FORTPLOT_ENABLE_FFMPEG", env_value, status) is_enabled = (status == 0 .and. trim(env_value) == "1") end function check_ffmpeg_explicit_flag !> Check explicit ImageMagick enable flag function check_imagemagick_explicit_flag() result(is_enabled) logical :: is_enabled character(len=50) :: env_value integer :: status call get_environment_variable("FORTPLOT_ENABLE_IMAGEMAGICK", env_value, status) is_enabled = (status == 0 .and. (trim(env_value) == "1" .or. trim(env_value) == "true")) end function check_imagemagick_explicit_flag !> Check RUNNER_OS environment function check_runner_os_environment() result(has_runner) logical :: has_runner character(len=50) :: env_value integer :: status call get_environment_variable("RUNNER_OS", env_value, status) has_runner = (status == 0) end function check_runner_os_environment !> Check if development environment is enabled (for FPM operations) function is_development_environment_enabled() result(enabled) logical :: enabled enabled = .false. ! Check various conditions that enable development tools if (check_ci_environment()) then enabled = .true. else if (check_github_actions_environment()) then enabled = .true. else if (check_fpm_explicit_flag()) then enabled = .true. else if (check_development_explicit_flag()) then enabled = .true. end if end function is_development_environment_enabled !> Check explicit FPM enable flag function check_fpm_explicit_flag() result(is_enabled) logical :: is_enabled character(len=50) :: env_value integer :: status call get_environment_variable("FORTPLOT_ENABLE_FPM", env_value, status) is_enabled = (status == 0 .and. (trim(env_value) == "1" .or. trim(env_value) == "true")) end function check_fpm_explicit_flag !> Check explicit development environment flag function check_development_explicit_flag() result(is_enabled) logical :: is_enabled character(len=50) :: env_value integer :: status call get_environment_variable("FORTPLOT_DEVELOPMENT", env_value, status) is_enabled = (status == 0 .and. (trim(env_value) == "1" .or. trim(env_value) == "true")) end function check_development_explicit_flag !> Test if a program is actually available function test_program_availability(program_name) result(available) character(len=*), intent(in) :: program_name logical :: available ! Use runtime system module for cross-platform support call check_command_available_runtime(program_name, available) end function test_program_availability !> Validate video file with actual ffprobe function validate_with_actual_ffprobe(filename) result(valid) character(len=*), intent(in) :: filename logical :: valid ! SECURITY: Direct ffprobe execution disabled for security compliance ! Fall back to magic byte validation which is already implemented valid = validate_mpeg_magic_bytes(filename) if (valid) then call log_info("Video file validation passed (magic bytes): " // trim(filename)) else call log_warning("Video file validation failed: " // trim(filename)) call log_info("For thorough validation, use external ffprobe manually") end if end function validate_with_actual_ffprobe !> Get cross-platform test output path with automatic directory creation function get_test_output_path(relative_path) result(full_path) character(len=*), intent(in) :: relative_path character(len=512) :: full_path logical :: success character(len=256) :: dir_path integer :: last_slash, i ! Handle /tmp paths by mapping to Windows-compatible paths if (len_trim(relative_path) >= 5 .and. relative_path(1:5) == '/tmp/') then ! Map /tmp paths using our runtime system full_path = map_unix_to_windows_path(relative_path) else if (len_trim(relative_path) >= 1 .and. relative_path(1:1) == '/') then ! Absolute path starting with / - make it relative for Windows compatibility full_path = '.' // relative_path else ! Already relative path full_path = relative_path end if ! Normalize path separators based on platform if (is_windows()) then ! Windows: ensure proper separators full_path = normalize_path_separators(full_path, .true.) else ! Unix: ensure forward slashes full_path = normalize_path_separators(full_path, .false.) end if ! Extract directory part and ensure it exists last_slash = 0 do i = 1, len_trim(full_path) if (full_path(i:i) == '/' .or. full_path(i:i) == '\') then last_slash = i end if end do if (last_slash > 0) then dir_path = full_path(1:last_slash-1) if (len_trim(dir_path) > 0) then call safe_create_directory(dir_path, success) if (.not. success) then call log_warning("Could not create test output directory: " // trim(dir_path)) end if end if end if end function get_test_output_path end module fortplot_security