Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 8 additions & 2 deletions .github/workflows/cicd-actions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}"]
3 changes: 2 additions & 1 deletion backend/analyzer/manager/project_manager.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hmac
import logging
from secrets import token_urlsafe

Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions backend/analyzer/services/cve_fetcher.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import re
import time
from urllib import parse

Expand All @@ -11,6 +12,8 @@

logger = logging.getLogger(__name__)

CVE_ID_PATTERN = re.compile(r'^CVE-\d{4}-\d{4,}$')


class CVEFetcher:
"""
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions backend/analyzer/test/conftest.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 22 additions & 9 deletions backend/analyzer/test/test_cve_fetcher.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
6 changes: 4 additions & 2 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[pytest]
addopts = --nomigrations --reuse-db
DJANGO_SETTINGS_MODULE = securecheckplus.settings
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)
37 changes: 36 additions & 1 deletion backend/securecheckplus/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import json
import logging
import os
import secrets
import sys
from pathlib import Path

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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,
]
Expand Down
10 changes: 7 additions & 3 deletions backend/webserver/manager/authentication_manager.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import logging
import os
import hmac

import ldap3.core.exceptions
from django.contrib import auth
from django.contrib.auth.backends import ModelBackend
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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion backend/webserver/manager/ldap_adapter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

from ldap3 import Server, ALL, Connection
from ldap3.utils.conv import escape_filter_chars

logger = logging.getLogger(__name__)

Expand All @@ -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:
Expand Down
Loading
Loading