diff --git a/Dockerfile b/Dockerfile index 16a709bd..a2f245c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,8 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -ARG BASE_IMAGE_URL=registry.redhat.io/ubi9/ubi -ARG BASE_IMAGE_TAG=9.8-1782841664 +ARG BASE_IMAGE_URL=registry.redhat.io/ubi9/python-312 +ARG BASE_IMAGE_TAG=9.8-1784177776 ARG PYTHON_VERSION=3.12 # Specified on the command line with --build-arg VULN_ANALYSIS_VERSION=$(python -m setuptools_scm) @@ -31,6 +31,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ AGENT_GIT_COMMIT=${AGENT_GIT_COMMIT} \ AGENT_GIT_TAG=${AGENT_GIT_TAG} +USER root RUN dnf install -y --nodocs \ bsdtar \ ca-certificates \ @@ -116,22 +117,19 @@ WORKDIR /workspace # Copy the project into the container COPY ./ /workspace -RUN groupadd workspace-group && \ - useradd -u 1001 -m -g workspace-group user1001 && \ - chown -R :workspace-group /home/user1001 && \ - chmod -R g+wrx /home/user1001 - - +# The base image already has user 'default' with UID 1001 and GID 0 (root group) +# Set appropriate permissions for the workspace +RUN chown -R 1001:0 /workspace && \ + chmod -R g+rwx /workspace # Install the NeMo Agent toolkit package and vuln analysis package -RUN --mount=type=cache,id=uv_cache,target=/home/user1001/.cache/uv,mode=0775,sharing=locked \ +# Cache path uses /opt/app-root (the home directory of UID 1001 in UBI images) +RUN --mount=type=cache,id=uv_cache,target=/opt/app-root/.cache/uv,mode=0775,sharing=locked \ export SETUPTOOLS_SCM_PRETEND_VERSION=${VULN_ANALYSIS_VERSION} && \ - uv venv --python ${PYTHON_VERSION} /workspace/.venv && \ + UV_PYTHON_DOWNLOADS=never uv venv --python /usr/bin/python3.12 .venv && \ uv sync && \ - chown -R :workspace-group /workspace && \ - chmod -R g+wrx /workspace && \ - chown -R :workspace-group /root && \ - chmod -R g+rx /root + chown -R 1001:0 /workspace && \ + chmod -R g+rwx /workspace USER 1001 diff --git a/tests/test_nvd_client.py b/tests/test_nvd_client.py new file mode 100644 index 00000000..13751177 --- /dev/null +++ b/tests/test_nvd_client.py @@ -0,0 +1,306 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +""" +Unit tests for NVDClient._get_soup method - ensures lxml parser handles edge cases correctly. +Tests cover CVE-2026-15308 fix: switched from html.parser to lxml. +""" + +import aiohttp +import pytest +from aioresponses import aioresponses +from bs4 import BeautifulSoup + +from vuln_analysis.utils.clients.nvd_client import NVDClient + + +class TestNVDClientGetSoup: + """Tests for NVDClient._get_soup method with lxml parser.""" + + @pytest.mark.asyncio + async def test_get_soup_basic_html(self): + """Test parsing well-formed HTML from CWE page.""" + client = NVDClient() + test_url = "http://cwe.mitre.org/data/definitions/79.html" + test_html = """ + +
Content" + + with aioresponses() as mock: + mock.get(test_url, status=200, body=malformed_html) + + async with aiohttp.ClientSession() as session: + client._session = session + soup = await client._get_soup(test_url) + + assert isinstance(soup, BeautifulSoup) + assert soup.find('title') is not None + + @pytest.mark.asyncio + async def test_get_soup_repeated_unterminated_markup(self): + """Test CVE-2026-15308 scenario: repeated unterminated markup declarations. + + This was the DoS vector in html.parser. With html.parser, the malformed + markup gets parsed as comments and may corrupt the document structure. + The test verifies it completes without hanging (DoS protection). + """ + client = NVDClient() + test_url = "http://cwe.mitre.org/data/definitions/456.html" + # Simulate malicious HTML with repeated unterminated markup + dos_html = "CWE-456
Content" + + with aioresponses() as mock: + mock.get(test_url, status=200, body=dos_html) + + async with aiohttp.ClientSession() as session: + client._session = session + soup = await client._get_soup(test_url) + + # Test passes if parsing completes without hanging (DoS protection) + assert isinstance(soup, BeautifulSoup) + # With html.parser, the body should still be parseable even if title gets mangled + assert soup.find('body') is not None + + @pytest.mark.asyncio + async def test_get_soup_empty_response(self): + """Test handling of empty HTML response.""" + client = NVDClient() + test_url = "http://cwe.mitre.org/data/definitions/empty.html" + + with aioresponses() as mock: + mock.get(test_url, status=200, body="") + + async with aiohttp.ClientSession() as session: + client._session = session + soup = await client._get_soup(test_url) + + assert isinstance(soup, BeautifulSoup) + + @pytest.mark.asyncio + async def test_get_soup_special_characters(self): + """Test parsing HTML with special characters and entities.""" + client = NVDClient() + test_url = "http://cwe.mitre.org/data/definitions/special.html" + test_html = """ + +UTF-8: äöü ñ 中文
+ + """ + + with aioresponses() as mock: + mock.get(test_url, status=200, body=test_html) + + async with aiohttp.ClientSession() as session: + client._session = session + soup = await client._get_soup(test_url) + + title = soup.find('title').string + assert "Regular content
+ + + """ + + with aioresponses() as mock: + mock.get(test_url, status=200, body=test_html) + + async with aiohttp.ClientSession() as session: + client._session = session + soup = await client._get_soup(test_url) + + assert isinstance(soup, BeautifulSoup) + assert soup.find('p') is not None + + @pytest.mark.asyncio + async def test_get_soup_http_error_propagates(self): + """Test that HTTP errors are propagated (handled by request_with_retry).""" + client = NVDClient(retry_count=1, retry_on_client_errors=False) + test_url = "http://cwe.mitre.org/data/definitions/notfound.html" + + with aioresponses() as mock: + mock.get(test_url, status=404) + + async with aiohttp.ClientSession() as session: + client._session = session + # Should raise exception due to 404 and retry_on_client_errors=False + with pytest.raises(Exception): + await client._get_soup(test_url) + + @pytest.mark.asyncio + async def test_get_soup_preserves_tag_attributes(self): + """Test that lxml preserves HTML tag attributes.""" + client = NVDClient() + test_url = "http://cwe.mitre.org/data/definitions/attrs.html" + test_html = """ + +Content here.
" + result = _extract_text_from_html(html) + + assert "Title" in result + assert "Content here." in result + + def test_script_and_style_removal(self): + """Test that script and style tags are removed from output.""" + html = """ + + + +Visible content
+ +More content
+ + + """ + result = _extract_text_from_html(html) + + assert "Visible content" in result + assert "More content" in result + assert "malicious" not in result + assert "display: none" not in result + + def test_header_footer_nav_removal(self): + """Test that header, footer, and nav elements are removed.""" + html = """ + +Multiple spaces
\n\n\nMultiple\nlines\n\n
" + result = _extract_text_from_html(html) + + assert "Multiple spaces" in result + assert " " not in result # Multiple spaces collapsed + assert "\n\n\n" not in result # Multiple newlines collapsed + + def test_empty_html(self): + """Test parsing empty or whitespace-only HTML.""" + assert _extract_text_from_html("") == "" + assert _extract_text_from_html("") == "" + assert _extract_text_from_html(" \n\n ") == "" + + def test_malformed_html_unclosed_tags(self): + """Test handling of malformed HTML with unclosed tags.""" + html = "Text without closing tag
Deeply nested content
+<script> & special chars: äöü
" + result = _extract_text_from_html(html) + + assert " +More safe content
+ +