diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000..2e9e89e1 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,8 @@ +name: "CybICS CodeQL Configuration" + +paths-ignore: + # Third-party: OpenPLC v3 (upstream project, not our code) + - software/OpenPLC/OpenPLC_v3 + # Virtual environments + - "**/.venv" + - "**/node_modules" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6baa0c46..bcb2c96d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,6 +31,7 @@ jobs: uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} + config-file: .github/codeql/codeql-config.yml - name: Autobuild uses: github/codeql-action/autobuild@v3 diff --git a/software/cybicsagent/app.py b/software/cybicsagent/app.py index fa6d5a6d..e99538c1 100644 --- a/software/cybicsagent/app.py +++ b/software/cybicsagent/app.py @@ -73,7 +73,8 @@ def get_container_status(): except subprocess.TimeoutExpired: return {'error': 'Command timed out'} except Exception as e: - return {'error': str(e)} + logger.error(f"Error getting container status: {e}") + return {'error': 'Failed to get container status'} def restart_containers(container_names=None): @@ -125,7 +126,8 @@ def restart_containers(container_names=None): except subprocess.TimeoutExpired: return {'error': 'Restart operation timed out'} except Exception as e: - return {'error': str(e)} + logger.error(f"Error restarting containers: {e}") + return {'error': 'Failed to restart containers'} def get_container_logs(container_name, lines=50): @@ -154,7 +156,8 @@ def get_container_logs(container_name, lines=50): except subprocess.TimeoutExpired: return {'error': 'Command timed out'} except Exception as e: - return {'error': str(e)} + logger.error(f"Error getting container logs: {e}") + return {'error': 'Failed to get container logs'} def get_system_stats(): @@ -186,7 +189,8 @@ def get_system_stats(): except subprocess.TimeoutExpired: return {'error': 'Command timed out'} except Exception as e: - return {'error': str(e)} + logger.error(f"Error getting system stats: {e}") + return {'error': 'Failed to get system statistics'} def execute_network_scan(target, scan_type='basic'): @@ -227,7 +231,8 @@ def execute_network_scan(target, scan_type='basic'): except FileNotFoundError: return {'error': 'nmap not found - network scanning not available'} except Exception as e: - return {'error': str(e)} + logger.error(f"Error executing network scan: {e}") + return {'error': 'Failed to execute network scan'} def list_docker_images(): @@ -259,7 +264,8 @@ def list_docker_images(): except subprocess.TimeoutExpired: return {'error': 'Command timed out'} except Exception as e: - return {'error': str(e)} + logger.error(f"Error listing Docker images: {e}") + return {'error': 'Failed to list Docker images'} # Tool definitions for the LLM @@ -340,9 +346,11 @@ def execute_tool(tool_name, parameters=None): result = func() return result except TypeError as e: - return {'error': f'Invalid parameters for {tool_name}: {str(e)}'} + logger.error(f"Invalid parameters for {tool_name}: {e}") + return {'error': f'Invalid parameters for {tool_name}'} except Exception as e: - return {'error': f'Error executing {tool_name}: {str(e)}'} + logger.error(f"Error executing {tool_name}: {e}") + return {'error': f'Error executing {tool_name}'} def parse_tool_calls(response_text): @@ -714,7 +722,7 @@ def generate_response(question, context): except Exception as e: logger.error(f"Error generating response: {e}") - return f"⚠️ **Error**: I'm having trouble connecting to the AI model.\n\n`{str(e)}`" + return "⚠️ **Error**: I'm having trouble connecting to the AI model. Please check the model configuration." @app.route('/health', methods=['GET']) @@ -757,7 +765,7 @@ def detect_tool_intent(question): if container in question_lower: # Check for line count import re - lines_match = re.search(r'(\d+)\s*lines?', question_lower) + lines_match = re.search(r'(\d+)\s{0,5}lines?', question_lower[:200]) lines = int(lines_match.group(1)) if lines_match else 50 return (True, 'get_container_logs', {'container_name': container, 'lines': lines}) return (False, None, {}) @@ -928,7 +936,7 @@ def chat(): except Exception as e: logger.error(f"Error in chat endpoint: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred while processing your message'}), 500 @app.route('/api/info', methods=['GET']) @@ -963,7 +971,7 @@ def info(): }) except Exception as e: logger.error(f"Error in info endpoint: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred while retrieving agent information'}), 500 @app.route('/api/tools', methods=['GET']) @@ -984,7 +992,7 @@ def list_tools(): }) except Exception as e: logger.error(f"Error listing tools: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred while listing tools'}), 500 @app.route('/api/model', methods=['GET']) @@ -1039,7 +1047,7 @@ def get_model(): }) except Exception as e: logger.error(f"Error getting model info: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred while retrieving model information'}), 500 @app.route('/api/model', methods=['POST']) @@ -1112,25 +1120,25 @@ def set_model(): logger.error(f"Model downloaded but failed to activate: {test_error}") return jsonify({ 'success': False, - 'error': f'Model downloaded but failed to activate: {str(test_error)}' + 'error': 'Model downloaded but failed to activate' }), 500 except Exception as pull_error: logger.error(f"Error pulling model {new_model}: {pull_error}") return jsonify({ 'success': False, - 'error': f'Failed to download model: {str(pull_error)}' + 'error': 'Failed to download the requested model' }), 500 else: logger.error(f"Error setting model: {e}") return jsonify({ 'success': False, - 'error': str(e) + 'error': 'Failed to set the specified model' }), 500 except Exception as e: logger.error(f"Error in set_model endpoint: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred while setting the model'}), 500 @app.route('/api/model/pull', methods=['POST']) @@ -1157,12 +1165,12 @@ def pull_model(): logger.error(f"Error pulling model {model_name}: {e}") return jsonify({ 'success': False, - 'error': str(e) + 'error': 'Failed to download the specified model' }), 500 except Exception as e: logger.error(f"Error in pull_model endpoint: {e}") - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred while pulling the model'}), 500 if __name__ == '__main__': diff --git a/software/landing/app.py b/software/landing/app.py index e190e2e0..fc0a96bd 100644 --- a/software/landing/app.py +++ b/software/landing/app.py @@ -2,7 +2,8 @@ CybICS - Industrial Control Systems Training Platform Main Flask Application (Refactored) """ -from flask import Flask, render_template, jsonify, request, session, send_from_directory +from flask import Flask, render_template, jsonify, request, session, send_from_directory, abort +from werkzeug.utils import safe_join import os import sys @@ -111,7 +112,7 @@ def get_stats(): return jsonify(stats) except Exception as e: logger.error(f"Error getting stats: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/stats/history') def get_stats_history(): @@ -122,7 +123,7 @@ def get_stats_history(): return jsonify(history) except Exception as e: logger.error(f"Error getting stats history: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'Internal server error'}), 500 # ========== NETWORK ANALYZER ROUTES ========== @@ -156,9 +157,9 @@ def execute_command(): logger.info(f'Executing webshell command: {command}') try: - # Execute the command without timeout + # Intentional: webshell provides command execution for CTF training result = subprocess.run( - command, + command, # nosec - intentional command execution for CTF webshell shell=True, capture_output=True, text=True, @@ -178,7 +179,7 @@ def execute_command(): logger.error(f'Error executing command: {e}', exc_info=True) return jsonify({ 'success': False, - 'output': f'Error: {str(e)}' + 'output': 'Internal server error' }), 500 # ========== CTF ROUTES ========== @@ -310,28 +311,42 @@ def serve_training_file(filename): @app.route('/ctf/challenge//') def serve_challenge_asset(challenge_id, filename): """Serve challenge assets like images""" - training_dir = os.path.join(TRAINING_DIR, challenge_id) - return send_from_directory(training_dir, filename) + safe_challenge_dir = safe_join(TRAINING_DIR, challenge_id) + if safe_challenge_dir is None: + abort(400) + resolved = os.path.realpath(safe_challenge_dir) + if not resolved.startswith(os.path.realpath(TRAINING_DIR)): + abort(400) + return send_from_directory(resolved, filename) @app.route('/ctf/challenge/doc/') def serve_challenge_doc_asset(filename): """Serve challenge doc assets like images from doc/ folder""" + # Sanitize filename to prevent directory traversal + safe_filename = os.path.basename(filename) + # Check the referer header to determine which challenge we're in referer = request.headers.get('Referer', '') if '/ctf/challenge/' in referer: challenge_id = referer.split('/ctf/challenge/')[-1].split('/')[0].split('?')[0] - doc_path = os.path.join(TRAINING_DIR, challenge_id, 'doc') - try: - return send_from_directory(doc_path, filename) - except: - pass + safe_doc_path = safe_join(TRAINING_DIR, challenge_id, 'doc') + if safe_doc_path is not None: + resolved = os.path.realpath(safe_doc_path) + if resolved.startswith(os.path.realpath(TRAINING_DIR)): + try: + return send_from_directory(resolved, safe_filename) + except: + pass # Fallback: try to find the file in any training directory's doc folder for challenge_dir in os.listdir(TRAINING_DIR): doc_path = os.path.join(TRAINING_DIR, challenge_dir, 'doc') if os.path.isdir(doc_path): + resolved = os.path.realpath(doc_path) + if not resolved.startswith(os.path.realpath(TRAINING_DIR)): + continue try: - return send_from_directory(doc_path, filename) + return send_from_directory(resolved, safe_filename) except: continue @@ -474,8 +489,8 @@ def download_logs(): logger.error('Docker compose logs command timed out') return jsonify({'error': 'Logs generation timed out'}), 500 except Exception as e: - logger.error(f'Error generating logs: {str(e)}') - return jsonify({'error': str(e)}), 500 + logger.error(f'Error generating logs: {str(e)}', exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/settings/system/info') def system_info(): @@ -517,8 +532,8 @@ def system_info(): 'running_containers': container_count }) except Exception as e: - logger.error(f'Error getting system info: {str(e)}') - return jsonify({'error': str(e)}), 500 + logger.error(f'Error getting system info: {str(e)}', exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/settings/containers/restart', methods=['POST']) def restart_containers(): @@ -606,8 +621,8 @@ def restart_containers(): logger.error('Container restart timed out') return jsonify({'error': 'Restart operation timed out'}), 500 except Exception as e: - logger.error(f'Error restarting containers: {str(e)}') - return jsonify({'error': str(e)}), 500 + logger.error(f'Error restarting containers: {str(e)}', exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/settings/agent', methods=['GET', 'POST']) def agent_settings(): @@ -659,8 +674,8 @@ def agent_chat(): logger.error('Could not connect to agent service') return jsonify({'error': 'Agent service unavailable'}), 503 except Exception as e: - logger.error(f'Error communicating with agent: {str(e)}') - return jsonify({'error': str(e)}), 500 + logger.error(f'Error communicating with agent: {str(e)}', exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/agent/status', methods=['GET']) def agent_status(): @@ -725,8 +740,8 @@ def get_agent_model(): logger.error('Could not connect to agent service') return jsonify({'error': 'Agent service unavailable'}), 503 except Exception as e: - logger.error(f'Error getting agent model: {str(e)}') - return jsonify({'error': str(e)}), 500 + logger.error(f'Error getting agent model: {str(e)}', exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/agent/model', methods=['POST']) def set_agent_model(): @@ -760,8 +775,8 @@ def set_agent_model(): logger.error('Could not connect to agent service') return jsonify({'error': 'Agent service unavailable'}), 503 except Exception as e: - logger.error(f'Error setting agent model: {str(e)}') - return jsonify({'error': str(e)}), 500 + logger.error(f'Error setting agent model: {str(e)}', exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 @app.route('/api/agent/model/pull', methods=['POST']) def pull_agent_model(): @@ -793,8 +808,8 @@ def pull_agent_model(): logger.error('Could not connect to agent service') return jsonify({'error': 'Agent service unavailable'}), 503 except Exception as e: - logger.error(f'Error pulling agent model: {str(e)}') - return jsonify({'error': str(e)}), 500 + logger.error(f'Error pulling agent model: {str(e)}', exc_info=True) + return jsonify({'error': 'Internal server error'}), 500 # ========== APPLICATION ENTRY POINT ========== diff --git a/software/landing/modules/network_routes.py b/software/landing/modules/network_routes.py index 02b4fbc5..a9f316e4 100644 --- a/software/landing/modules/network_routes.py +++ b/software/landing/modules/network_routes.py @@ -47,7 +47,7 @@ def get_network_interfaces(): return jsonify(interfaces_list) except Exception as e: logger.error(f"Error getting network interfaces: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred'}), 500 @app.route('/api/network/start', methods=['POST']) def start_network_capture(): @@ -70,7 +70,7 @@ def start_network_capture(): except Exception as e: logger.error(f"Error starting network capture: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred'}), 500 @app.route('/api/network/stop', methods=['POST']) def stop_network_capture(): @@ -82,7 +82,7 @@ def stop_network_capture(): return jsonify({'success': True, 'message': 'Capture stopped', 'stats': stats}) except Exception as e: logger.error(f"Error stopping network capture: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred'}), 500 @app.route('/api/network/packets') def get_network_packets(): @@ -100,7 +100,7 @@ def get_network_packets(): return jsonify({'packets': packets_for_json}) except Exception as e: logger.error(f"Error getting network packets: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred'}), 500 @app.route('/api/network/clear', methods=['POST']) def clear_network_capture(): @@ -110,7 +110,7 @@ def clear_network_capture(): return jsonify({'success': True, 'message': 'Packets cleared'}) except Exception as e: logger.error(f"Error clearing network packets: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred'}), 500 @app.route('/api/network/stats') def get_network_stats(): @@ -120,7 +120,7 @@ def get_network_stats(): return jsonify(stats) except Exception as e: logger.error(f"Error getting network stats: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred'}), 500 @app.route('/api/network/export') def export_network_capture(): @@ -179,7 +179,7 @@ def export_network_capture(): ) except Exception as e: logger.error(f"Error exporting network capture: {e}", exc_info=True) - return jsonify({'error': str(e)}), 500 + return jsonify({'error': 'An internal error occurred'}), 500 def _reconstruct_packet_data(packet): """Reconstruct minimal packet data from parsed information""" diff --git a/tests/test_opcua_auth.py b/tests/test_opcua_auth.py index 8b354297..b819f39a 100644 --- a/tests/test_opcua_auth.py +++ b/tests/test_opcua_auth.py @@ -250,7 +250,8 @@ async def test_opcua_invalid_credentials_rejected(): ) print(f"✓ Correctly rejected: {description}") - print(f" Username: '{username}', Password: '{password}'") + print(f" Username: '{username}', Password: '{'*' * len(password)}'") + print(f" Error: {error_message[:100]}") print(f"\n✓ All {len(invalid_credentials)} invalid credential combinations were properly rejected") diff --git a/training/mitm/mitm.py b/training/mitm/mitm.py index b1936d2a..b39ec009 100644 --- a/training/mitm/mitm.py +++ b/training/mitm/mitm.py @@ -338,7 +338,8 @@ def start_modbus_proxy(): Start the Modbus TCP proxy that listens for incoming Modbus clients. """ proxy_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - proxy_socket.bind((PROXY_HOST, PROXY_PORT)) + # Intentional: MITM proxy must listen on all interfaces to intercept traffic + proxy_socket.bind((PROXY_HOST, PROXY_PORT)) # nosec - intentional for MITM training proxy_socket.listen(5) print(f"[*] Modbus Proxy listening on {PROXY_HOST}:{PROXY_PORT}")