diff --git a/.github/workflows/cicd-actions.yml b/.github/workflows/cicd-actions.yml index dffbe4d..36c0355 100644 --- a/.github/workflows/cicd-actions.yml +++ b/.github/workflows/cicd-actions.yml @@ -26,10 +26,13 @@ jobs: uses: actions/checkout@v4 - name: Create .env file + env: + POSTGRES_PASSWORD: ${{ secrets.TEST_DB_PASSWORD }} + SALT: ${{ secrets.TEST_SALT }} run: | echo "NVD_API_KEY=${{ secrets.TEST_NVD_API_KEY }}" >> .env echo 'DJANGO_SECRET_KEY="${{ secrets.TEST_DJANGO_SECRET_KEY }}"' >> .env - echo 'SALT="${{ secrets.TEST_SALT }}"' >> .env + echo "SALT=${SALT:-local-dev-salt}" >> .env echo "ADMIN_USERNAME=admin@acme.de" >> .env echo "ADMIN_PASSWORD=secure!" >> .env echo "USER_USERNAME=user@acme.de" >> .env @@ -41,7 +44,7 @@ jobs: echo "POSTGRES_USER=securecheckplus" >> .env echo "POSTGRES_DB=securecheckplus" >> .env echo "POSTGRES_PORT=5432" >> .env - echo 'POSTGRES_PASSWORD="${{ secrets.TEST_DB_PASSWORD }}"' >> .env + echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-scp_test_pass}" >> .env echo "EMAIL_HOST=localhost" >> .env echo "EMAIL_PORT=25" >> .env echo 'LDAP_ORGANISATION="ACME"' >> .env @@ -77,6 +80,7 @@ jobs: path: backend - name: Docker Login + if: env.DOCKER_USER != '' && env.DOCKER_KEY != '' run: echo "$DOCKER_KEY" | docker login -u "$DOCKER_USER" --password-stdin - name: Build Docker Compose @@ -182,6 +186,7 @@ jobs: path: backend/assets - name: Docker Login + if: env.DOCKER_USER != '' && env.DOCKER_KEY != '' run: echo "$DOCKER_KEY" | docker login -u "$DOCKER_USER" --password-stdin - name: Extract metadata (tags, labels) for Docker @@ -221,6 +226,7 @@ jobs: path: backend - name: Docker Login + if: env.DOCKER_USER != '' && env.DOCKER_KEY != '' run: echo "$DOCKER_KEY" | docker login -u "$DOCKER_USER" --password-stdin - name: Extract metadata (tags, labels) for Docker diff --git a/backend/Dockerfile b/backend/Dockerfile index d08bfcc..75df921 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -26,10 +26,13 @@ ARG BUILD=1 COPY . /backend -RUN adduser -D baseuser && chown -R baseuser . +RUN adduser -D -u 1000 baseuser && chown -R baseuser . USER baseuser EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/check_health', timeout=2).read()" || exit 1 + # Overwrites previous CMD from stage "dev" -#CMD gunicorn securecheckplus.wsgi:application --bind 0.0.0.0:8000 --workers=2 --threads=2 --log-level INFO +CMD ["sh", "-c", "exec gunicorn securecheckplus.wsgi:application --bind 0.0.0.0:8000 --workers=${GUNICORN_WORKERS:-2} --threads=${GUNICORN_THREADS:-2} --log-level ${LOG_LEVEL:-INFO}"] diff --git a/backend/analyzer/manager/project_manager.py b/backend/analyzer/manager/project_manager.py index 8f06d26..1f3e0a3 100644 --- a/backend/analyzer/manager/project_manager.py +++ b/backend/analyzer/manager/project_manager.py @@ -1,3 +1,4 @@ +import hmac import logging from secrets import token_urlsafe @@ -44,7 +45,7 @@ def verify_key(self, key: str) -> bool: """ try: - if self.project.api_key_hash == hash_key(key): + if hmac.compare_digest(self.project.api_key_hash, hash_key(key)): logging.info( f"Authentication with API-Key for {self.project.project_id} successful.") return True diff --git a/backend/analyzer/services/cve_fetcher.py b/backend/analyzer/services/cve_fetcher.py index 8fc1c10..61ca856 100644 --- a/backend/analyzer/services/cve_fetcher.py +++ b/backend/analyzer/services/cve_fetcher.py @@ -1,4 +1,5 @@ import logging +import re import time from urllib import parse @@ -11,6 +12,8 @@ logger = logging.getLogger(__name__) +CVE_ID_PATTERN = re.compile(r'^CVE-\d{4}-\d{4,}$') + class CVEFetcher: """ @@ -61,7 +64,12 @@ def __init__(self, cve_id: str): Args: cve_id (str): The CVE identifier to fetch data for. + + Raises: + ValueError: If the cve_id does not match the expected CVE format. """ + if not CVE_ID_PATTERN.match(cve_id): + raise ValueError(f"Invalid CVE ID format: {cve_id}") self.cve_id = cve_id self.data = {} self.successful = False @@ -80,7 +88,7 @@ def fetch_from_nist_gov(self): headers = {"apiKey": NVD_API_KEY} url = parse.urlunparse(NVD_ADDRESS) + self.cve_id logger.info(f"Fetching CVE data from NIST for CVE ID: {self.cve_id} using URL: {url}") - response = requests.get(url, headers=headers) + response = requests.get(url, headers=headers, timeout=30) if response.status_code != 200: logger.warning( @@ -138,7 +146,7 @@ def fetch_epss(self): try: url = parse.urlunparse(EPSS_ADDRESS) + self.cve_id logger.info(f"Fetching EPSS score for CVE ID: {self.cve_id} using URL: {url}") - epss_response = requests.get(url) + epss_response = requests.get(url, timeout=30) if epss_response.status_code != 200: raise RequestException( diff --git a/backend/analyzer/test/conftest.py b/backend/analyzer/test/conftest.py new file mode 100644 index 0000000..76f0f24 --- /dev/null +++ b/backend/analyzer/test/conftest.py @@ -0,0 +1,65 @@ +from unittest.mock import patch + +import pytest + +MOCK_NVD_RESPONSE = { + "vulnerabilities": [ + { + "cve": { + "id": "CVE-2021-44228", + "published": "2025-01-01T00:00:00Z", + "lastModified": "2025-01-01T00:00:00Z", + "descriptions": [{"value": "A test vulnerability description."}], + "metrics": { + "cvssMetricV31": [ + { + "cvssData": { + "baseScore": 7.5, + "baseSeverity": "HIGH", + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "confidentialityImpact": "HIGH", + "integrityImpact": "NONE", + "availabilityImpact": "NONE", + "scope": "UNCHANGED", + } + } + ] + }, + "weaknesses": [{"description": [{"value": "CWE-94"}]}], + "references": [{"url": "https://example.com/advisory", "tags": ["Vendor Advisory"]}], + } + } + ] +} + +MOCK_EPSS_RESPONSE = {"data": [{"epss": 0.5}]} + + +def _mock_requests_get(url, **kwargs): + class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + url_str = str(url) + if "nvd.nist.gov" in url_str: + return MockResponse(MOCK_NVD_RESPONSE, 200) + if "api.first.org" in url_str: + return MockResponse(MOCK_EPSS_RESPONSE, 200) + raise ConnectionError(f"Unexpected request: {url_str}") + + +@pytest.fixture(autouse=True) +def no_nvd_network(request): + if request.node.get_closest_marker("nvd_integration"): + yield + return + with patch("analyzer.services.cve_fetcher.requests.get", side_effect=_mock_requests_get), \ + patch("analyzer.services.cve_fetcher.time.sleep"): + yield diff --git a/backend/analyzer/test/test_cve_fetcher.py b/backend/analyzer/test/test_cve_fetcher.py index b1df86b..d4072f5 100644 --- a/backend/analyzer/test/test_cve_fetcher.py +++ b/backend/analyzer/test/test_cve_fetcher.py @@ -1,29 +1,42 @@ from datetime import datetime +import pytest + from analyzer.services.cve_fetcher import CVEFetcher from utilities.constants import BaseSeverity, AttackVector, AttackComplexity, UserInteraction, IntegrityImpact, \ AvailabilityImpact, ConfidentialityImpact, Scope, PrivilegesRequired cve_id = "CVE-2021-44228" -cve_fetcher = CVEFetcher(cve_id=cve_id) -cve_data = cve_fetcher.generate() -def test_description(): +@pytest.fixture +def cve_data(): + fetcher = CVEFetcher(cve_id=cve_id) + return fetcher.generate() + + +@pytest.mark.nvd_integration +def test_real_nvd_api_contract(): + fetcher = CVEFetcher(cve_id=cve_id) + data = fetcher.generate() + assert fetcher.successful + assert len(data["description"]) > 0 + assert 0 < data["cve_attributes"]["baseScore"] <= 10 + + +def test_description(cve_data): assert len(cve_data["description"]) > 0 -def test_dates(): +def test_dates(cve_data): published = cve_data["published"] assert isinstance(published, datetime) - updated = cve_data["updated"] assert isinstance(updated, datetime) -def test_cve_attributes_cvss_v3(): +def test_cve_attributes_cvss_v3(cve_data): attributes = cve_data["cve_attributes"] - assert 0 < attributes["baseScore"] <= 10 assert attributes["baseSeverity"] in BaseSeverity.names assert attributes["attackVector"] in AttackVector.names @@ -36,9 +49,9 @@ def test_cve_attributes_cvss_v3(): assert attributes["scope"] in Scope.names -def test_epss_score(): +def test_epss_score(cve_data): assert 0 <= float(cve_data["epss"]) <= 1.0 -def test_vendor_reference(): +def test_vendor_reference(cve_data): assert len(cve_data["vendor_reference"]) >= 0 diff --git a/backend/pytest.ini b/backend/pytest.ini index ac3d988..a8cc62a 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,3 +1,5 @@ [pytest] -addopts = --nomigrations --reuse-db -DJANGO_SETTINGS_MODULE = securecheckplus.settings \ No newline at end of file +addopts = --nomigrations --reuse-db -m "not nvd_integration" +DJANGO_SETTINGS_MODULE = securecheckplus.settings +markers = + nvd_integration: tests that call the real NVD API (requires internet + API key) \ No newline at end of file diff --git a/backend/securecheckplus/settings.py b/backend/securecheckplus/settings.py index 13df1d2..65a3fb5 100644 --- a/backend/securecheckplus/settings.py +++ b/backend/securecheckplus/settings.py @@ -13,6 +13,7 @@ import json import logging import os +import secrets import sys from pathlib import Path @@ -54,6 +55,21 @@ def get_env_variable_or_shutdown_gracefully(var_name): USER_USERNAME = os.environ.get("USER_USERNAME") USER_PASSWORD = os.environ.get("USER_PASSWORD") +if IS_DEV: + # Generate random credentials if not explicitly set in dev mode + if not ADMIN_USERNAME: + ADMIN_USERNAME = "admin" + if not ADMIN_PASSWORD: + ADMIN_PASSWORD = secrets.token_urlsafe(16) + if not USER_USERNAME: + USER_USERNAME = "user" + if not USER_PASSWORD: + USER_PASSWORD = secrets.token_urlsafe(16) + logging.warning( + f"DEV MODE: Using credentials - Admin: {ADMIN_USERNAME}, User: {USER_USERNAME}. " + f"DO NOT use these in production!" + ) + # If not building image (no env variables set) and # If LDAP_HOST has been set (LDAP is being used for authentication) -> all other LDAP variables need to be set # If LDAP_HOST has not been set (Use the hardcoded admin and user for authentication) -> all the hardcoded admin and @@ -135,9 +151,13 @@ def get_env_variable_or_shutdown_gracefully(var_name): ], 'DEFAULT_THROTTLE_CLASSES': [ 'rest_framework.throttling.ScopedRateThrottle', + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', ], 'DEFAULT_THROTTLE_RATES': { - 'login': '30/min', + 'login': '5/min', + 'anon': '100/day', + 'user': '1000/day', } } @@ -285,6 +305,13 @@ def format(self, record): if "https" in FULLY_QUALIFIED_DOMAIN_NAME: CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True + SECURE_SSL_REDIRECT = True + SECURE_HSTS_SECONDS = 31536000 + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + X_FRAME_OPTIONS = "DENY" else: CSRF_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False @@ -293,6 +320,14 @@ def format(self, record): CORS_ALLOWED_ORIGINS = [ FULLY_QUALIFIED_DOMAIN_NAME, ] + +if IS_DEV: + CORS_ALLOWED_ORIGINS.extend([ + "http://localhost:3000", + "http://localhost:8000", + "http://127.0.0.1:3000", + "http://127.0.0.1:8000", + ]) CSRF_TRUSTED_ORIGINS = [ FULLY_QUALIFIED_DOMAIN_NAME, ] diff --git a/backend/webserver/manager/authentication_manager.py b/backend/webserver/manager/authentication_manager.py index c5db7d7..4398b2e 100644 --- a/backend/webserver/manager/authentication_manager.py +++ b/backend/webserver/manager/authentication_manager.py @@ -1,5 +1,6 @@ import logging import os +import hmac import ldap3.core.exceptions from django.contrib import auth @@ -7,7 +8,7 @@ from django.contrib.auth.models import Group from securecheckplus.settings import ADMIN_USERNAME, ADMIN_PASSWORD, USER_PASSWORD, USER_USERNAME, LDAP_HOST, LDAP_USER_BASE_DN, LDAP_ADMIN_GROUP_DN, LDAP_BASE_GROUP_DN, \ - LDAP_ADMIN_DN, LDAP_ADMIN_PASSWORD, LDAP_USER_SEARCH_FILTER + LDAP_ADMIN_DN, LDAP_ADMIN_PASSWORD, LDAP_USER_SEARCH_FILTER, IS_DEV from utilities.exceptions import Unauthorized from webserver.manager.ldap_adapter import LdapAdapter from webserver.models import User @@ -86,12 +87,15 @@ def authenticate(self, request, username=None, password=None, **kwargs): logger.warning(f"User '{username}' is not a member of required groups!") return None - elif ADMIN_USERNAME and ADMIN_PASSWORD and ADMIN_USERNAME == username and ADMIN_PASSWORD == password: + elif IS_DEV and ADMIN_USERNAME and ADMIN_PASSWORD and ADMIN_USERNAME == username and hmac.compare_digest(ADMIN_PASSWORD, password): user = User.objects.get_or_create(username=username)[0] + user.is_staff = True + user.is_superuser = True + user.save() user.groups.add(Group.objects.get_or_create(name="admin")[0]) return user - elif USER_USERNAME and USER_PASSWORD and USER_USERNAME == username and USER_PASSWORD == password: + elif IS_DEV and USER_USERNAME and USER_PASSWORD and USER_USERNAME == username and hmac.compare_digest(USER_PASSWORD, password): user = User.objects.get_or_create(username=username)[0] return user diff --git a/backend/webserver/manager/ldap_adapter.py b/backend/webserver/manager/ldap_adapter.py index 85f33ec..b8b75ae 100644 --- a/backend/webserver/manager/ldap_adapter.py +++ b/backend/webserver/manager/ldap_adapter.py @@ -1,6 +1,7 @@ import logging from ldap3 import Server, ALL, Connection +from ldap3.utils.conv import escape_filter_chars logger = logging.getLogger(__name__) @@ -22,7 +23,8 @@ def __init__( self._user_search_filter = user_search_filter def get_ldap_user(self, username:str): - search_filter = self._user_search_filter.replace("VALUE", username) + safe_username = escape_filter_chars(username) + search_filter = self._user_search_filter.replace("VALUE", safe_username) return search_filter def admin_login(self) -> Connection: diff --git a/backend/webserver/views/misc_views.py b/backend/webserver/views/misc_views.py index bb71ea4..18185f2 100644 --- a/backend/webserver/views/misc_views.py +++ b/backend/webserver/views/misc_views.py @@ -23,9 +23,20 @@ class HtmlView(View): + ALLOWED_TEMPLATES = frozenset([ + "index", "app", "dashboard", "projects", "reports", + "project", "report", "settings", "favorites", "dependencies", + ]) def get(self, request, template_name): + if template_name not in self.ALLOWED_TEMPLATES: + logger.warning( + f"Blocked template access attempt: {template_name} " + f"from {request.META.get('HTTP_X_FORWARDED_FOR')}" + ) + return render(request, "login.html", {"IS_DEV": IS_DEV, "BASE_URL": BASE_URL or "", "PREFIX": "/"}) + PREFIX = "/" if not IS_DEV and BASE_URL: diff --git a/backend/webserver/views/project_views.py b/backend/webserver/views/project_views.py index 351199b..3e737f9 100644 --- a/backend/webserver/views/project_views.py +++ b/backend/webserver/views/project_views.py @@ -20,6 +20,8 @@ class DeleteProjectAPI(APIView): + permission_classes = [IsAuthenticated, permission_required("analyzer.delete_project")] + def post(self, request) -> Response: try: projectIds = request.data["projectIds"]