Skip to content
Closed
26 changes: 12 additions & 14 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 \
Expand Down Expand Up @@ -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

Expand Down
306 changes: 306 additions & 0 deletions tests/test_nvd_client.py
Original file line number Diff line number Diff line change
@@ -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 = """
<html>
<head><title>CWE-79 - Cross-site Scripting (XSS)</title></head>
<body>
<div id="Description">
<div class="indent">Improper neutralization of input</div>
</div>
</body>
</html>
"""

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('title') is not None
assert "CWE-79" in soup.find('title').string

@pytest.mark.asyncio
async def test_get_soup_extracts_cwe_description(self):
"""Test that CWE description can be extracted from parsed HTML."""
client = NVDClient()
test_url = "http://cwe.mitre.org/data/definitions/89.html"
test_html = """
<html>
<head><title>CWE-89 - SQL Injection</title></head>
<body>
<div id="Description">
<div class="indent">SQL commands constructed from user input</div>
</div>
</body>
</html>
"""

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)

description_div = soup.find('div', id='Description')
assert description_div is not None
indent_div = description_div.find('div', class_='indent')
assert "SQL commands" in indent_div.text

@pytest.mark.asyncio
async def test_get_soup_malformed_html(self):
"""Test parsing malformed HTML (lxml is lenient)."""
client = NVDClient()
test_url = "http://cwe.mitre.org/data/definitions/123.html"
malformed_html = "<html><title>CWE-123<div>Unclosed tags<p>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 = "<!DOCTYPE html><!" * 200 + "<title>CWE-456</title><body>Content</body>"

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 = """
<html>
<title>CWE-123 - Buffer &lt;overflow&gt; &amp; issues</title>
<body><p>UTF-8: äöü ñ 中文</p></body>
</html>
"""

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 "<overflow>" in title # HTML entities decoded
assert "&" in title
assert "äöü" in soup.find('p').text # UTF-8 preserved

@pytest.mark.asyncio
async def test_get_soup_with_retry_on_failure(self):
"""Test that request_with_retry is used (configured in client)."""
client = NVDClient(retry_count=3)
test_url = "http://cwe.mitre.org/data/definitions/retry.html"
test_html = "<html><title>CWE-Retry</title></html>"

with aioresponses() as mock:
# First two attempts fail, third succeeds
mock.get(test_url, status=500)
mock.get(test_url, status=500)
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('title') is not None

@pytest.mark.asyncio
async def test_get_soup_nested_divs(self):
"""Test extracting content from deeply nested HTML structure."""
client = NVDClient()
test_url = "http://cwe.mitre.org/data/definitions/nested.html"
test_html = """
<html>
<body>
<div id="Description">
<div class="wrapper">
<div class="indent">
<div>Deeply nested description content</div>
</div>
</div>
</div>
<div id="Extended_Description">
<div class="indent">Extended information here</div>
</div>
</body>
</html>
"""

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)

desc_div = soup.find('div', id='Description')
assert "Deeply nested" in desc_div.text

ext_desc_div = soup.find('div', id='Extended_Description')
assert "Extended information" in ext_desc_div.text

@pytest.mark.asyncio
async def test_get_soup_whitespace_handling(self):
"""Test that lxml handles whitespace correctly."""
client = NVDClient()
test_url = "http://cwe.mitre.org/data/definitions/whitespace.html"
test_html = """
<html>
<title> CWE-789 </title>
<div class="indent">
Text with multiple spaces
and
newlines
</div>
</html>
"""

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.strip()
assert title == "CWE-789"

div_text = soup.find('div', class_='indent').text
assert "multiple" in div_text
assert "spaces" in div_text

@pytest.mark.asyncio
async def test_get_soup_cdata_sections(self):
"""Test parsing HTML with CDATA sections."""
client = NVDClient()
test_url = "http://cwe.mitre.org/data/definitions/cdata.html"
test_html = """
<html>
<title>CWE-100</title>
<body>
<![CDATA[
This is CDATA content
]]>
<p>Regular content</p>
</body>
</html>
"""

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 = """
<html>
<div id="Description" class="primary" data-cwe="123">
<div class="indent">Description text</div>
</div>
</html>
"""

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)

desc_div = soup.find('div', id='Description')
assert desc_div is not None
assert desc_div.get('class') == ['primary']
assert desc_div.get('data-cwe') == '123'
Loading