From ac0613dc447175071c0894982ee2f508bdfe6476 Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Thu, 25 Jun 2026 03:19:01 +0530 Subject: [PATCH 1/5] Add Advisory History Signed-off-by: Sampurna Pyne --- .../templates/advisory_detail.html | 884 +++++++++++------- .../templates/advisory_package_details.html | 7 + .../templatetags/diff_advisory_history.py | 118 +++ vulnerabilities/utils.py | 99 ++ vulnerabilities/views.py | 130 +++ vulnerablecode/static/css/custom.css | 60 ++ vulnerablecode/urls.py | 12 + 7 files changed, 953 insertions(+), 357 deletions(-) create mode 100644 vulnerabilities/templatetags/diff_advisory_history.py diff --git a/vulnerabilities/templates/advisory_detail.html b/vulnerabilities/templates/advisory_detail.html index fea5afde4..a8478645d 100644 --- a/vulnerabilities/templates/advisory_detail.html +++ b/vulnerabilities/templates/advisory_detail.html @@ -4,6 +4,7 @@ {% load static %} {% load show_cvss %} {% load url_filters %} +{% load diff_advisory_history %} {% block title %} VulnerableCode Advisory Details - {{ advisory.advisory_id }} @@ -23,6 +24,13 @@ + {% if is_snapshot %} +
+ Historical snapshot collected on {{ advisory.date_collected|date:"Y-m-d H:i" }} UTC. + View current version +
+ {% endif %} +
  • @@ -46,13 +54,13 @@
  • {% if advisory.exploits %} -
  • - - - Exploits ({{ advisory.exploits.count }}) - - -
  • +
  • + + + Exploits ({{ advisory.exploits.count }}) + + +
  • {% endif %}
  • @@ -72,13 +80,23 @@
  • {% if ssvcs %} -
  • - - - Related SSVCS ({{ ssvcs|length }}) - - -
  • +
  • + + + Related SSVCS ({{ ssvcs|length }}) + + +
  • + {% endif %} + + {% if advisory_history and not is_snapshot %} +
  • + + + History + + +
  • {% endif %}
  • @@ -124,13 +142,13 @@ Summary - {{ advisory.summary }} + {{ advisory.summary }} {% if severity_score_range %} Severity score range - {{ severity_score_range }} + {{ severity_score_range }} {% endif %} @@ -147,9 +165,9 @@ Exploitability {% if advisory.exploitability is not None %} - {{ advisory.exploitability }} + {{ advisory.exploitability }} {% else %} - {{ "" }} + {{ "" }} {% endif %} @@ -157,27 +175,27 @@ Weighted Severity + >Weighted Severity {% if advisory.weighted_severity is not None %} - {{ advisory.weighted_severity }} + {{ advisory.weighted_severity }} {% else %} - {{ "" }} + {{ "" }} {% endif %} Risk + >Risk {% if advisory.risk_score is not None %} - {{ advisory.risk_score }} + {{ advisory.risk_score }} {% else %} - {{ "" }} + {{ "" }} {% endif %} @@ -188,7 +206,7 @@ " >Affected and Fixed Packages - + Package Details @@ -222,7 +240,7 @@ {{ weakness.name }} - + {% empty %} @@ -311,215 +329,215 @@
    {% for exploit in advisory.exploits.all %} - - - - - +
    Data source {{ exploit.data_source }}
    + + + + - - {% if exploit.date_added %} - - - - - {% endif %} - {% if exploit.description %} - - - - - {% endif %} - {% if exploit.required_action %} - - + + + {% endif %} + {% if exploit.description %} + + + + + {% endif %} + {% if exploit.required_action %} + + - - - {% endif %} - {% if exploit.due_date %} - - + + {% endif %} + {% if exploit.due_date %} + + - - - {% endif %} - {% if exploit.notes %} - - + + {% endif %} + {% if exploit.notes %} + + - - - {% endif %} - {% if exploit.known_ransomware_campaign_use is not None %} - - - - - {% endif %} - {% if exploit.source_date_published %} - - - - - {% endif %} - {% if exploit.exploit_type %} - - - - - {% endif %} - {% if exploit.platform %} - - - - - {% endif %} - {% if exploit.source_date_updated %} - - - - - {% endif %} + Note + + + + + {% endif %} + {% if exploit.known_ransomware_campaign_use is not None %} + + + + + {% endif %} + {% if exploit.source_date_published %} + + + + + {% endif %} + {% if exploit.exploit_type %} + + + + + {% endif %} + {% if exploit.platform %} + + + + + {% endif %} + {% if exploit.source_date_updated %} + + + + + {% endif %} - {% if exploit.source_url %} - - - - - {% endif %} - -
    Data source {{ exploit.data_source }}
    - - Date added - - {{ exploit.date_added }}
    - - Description - - {{ exploit.description }}
    - + + Date added + + {{ exploit.date_added }}
    + + Description + + {{ exploit.description }}
    + - Required action - - {{ exploit.required_action }}
    - {{ exploit.required_action }}
    + - Due date - - {{ exploit.due_date }}
    - {{ exploit.due_date }}
    + - Note - -
    {{ exploit.notes }}
    - - Ransomware campaign use - - {{ exploit.known_ransomware_campaign_use|yesno:"Known,Unknown" }}
    - - Source publication date - - {{ exploit.source_date_published }}
    - - Exploit type - - {{ exploit.exploit_type }}
    - - Platform - - {{ exploit.platform }}
    - - Source update date - - {{ exploit.source_date_updated }}
    {{ exploit.notes }}
    + + Ransomware campaign use + + {{ exploit.known_ransomware_campaign_use|yesno:"Known,Unknown" }}
    + + Source publication date + + {{ exploit.source_date_published }}
    + + Exploit type + + {{ exploit.exploit_type }}
    + + Platform + + {{ exploit.platform }}
    + + Source update date + + {{ exploit.source_date_updated }}
    - - Source URL - - {{ exploit.source_url }}
    + {% if exploit.source_url %} + + + + Source URL + + + {{ exploit.source_url }} + + {% endif %} + + {% empty %} - - - No exploits are available. - - + + + No exploits are available. + + {% endfor %}
    - +
    {% if epss_data %} -
    - Exploit Prediction Scoring System (EPSS) -
    - - - - - - - - - - - {% if epss_data.published_at %} - - - - - {% endif %} - {% if epss_data.source %} - - - - - {% endif %} - {% if epss_data.advisory %} - - - - - {% endif %} - -
    - - Percentile - - {{ epss_data.percentile }}
    - - EPSS Score - - {{ epss_data.score }}
    - - Published At - - {{ epss_data.published_at }}
    - - Source - - {{ epss_data.source }}
    - - Advisory - - {{ epss_data.advisory.avid }}
    +
    + Exploit Prediction Scoring System (EPSS) +
    + + + + + + + + + + + {% if epss_data.published_at %} + + + + + {% endif %} + {% if epss_data.source %} + + + + + {% endif %} + {% if epss_data.advisory %} + + + + + {% endif %} + +
    + + Percentile + + {{ epss_data.percentile }}
    + + EPSS Score + + {{ epss_data.score }}
    + + Published At + + {{ epss_data.published_at }}
    + + Source + + {{ epss_data.source }}
    + + Advisory + + {{ epss_data.advisory.avid }}
    {% else %} -

    No EPSS data available for this advisory.

    +

    No EPSS data available for this advisory.

    {% endif %}
    @@ -547,169 +565,169 @@
    {% for severity_vector in severity_vectors %} - {% if severity_vector.vector.version == '2.0' %} - Vector: {{ severity_vector.vector.vectorString }} - {% if severity_vector.origin %} - Found at - {{ severity_vector.origin }} - {% endif %} - - - - - - - - - - - - - - - - - - - -
    Exploitability (E)Access Vector (AV)Access Complexity (AC)Authentication (Au)Confidentiality Impact (C)Integrity Impact (I)Availability Impact (A)
    {{ severity_vector.vector.exploitability|cvss_printer:"high,functional,unproven,proof_of_concept,not_defined" }}{{ severity_vector.vector.accessVector|cvss_printer:"local,adjacent_network,network" }}{{ severity_vector.vector.accessComplexity|cvss_printer:"high,medium,low" }}{{ severity_vector.vector.authentication|cvss_printer:"multiple,single,none" }}{{ severity_vector.vector.confidentialityImpact|cvss_printer:"none,partial,complete" }}{{ severity_vector.vector.integrityImpact|cvss_printer:"none,partial,complete" }}{{ severity_vector.vector.availabilityImpact|cvss_printer:"none,partial,complete" }}
    - {% elif severity_vector.vector.version == '3.1' or severity_vector.vector.version == '3.0'%} - Vector: {{ severity_vector.vector.vectorString }} Found at {{ severity_vector.origin }} - - - - - - - - - - - - - - - - - - - - - -
    Attack Vector (AV)Attack Complexity (AC)Privileges Required (PR)User Interaction (UI)Scope (S)Confidentiality Impact (C)Integrity Impact (I)Availability Impact (A)
    {{ severity_vector.vector.attackVector|cvss_printer:"network,adjacent_network,local,physical"}}{{ severity_vector.vector.attackComplexity|cvss_printer:"low,high" }}{{ severity_vector.vector.privilegesRequired|cvss_printer:"none,low,high" }}{{ severity_vector.vector.userInteraction|cvss_printer:"none,required"}}{{ severity_vector.vector.scope|cvss_printer:"unchanged,changed" }}{{ severity_vector.vector.confidentialityImpact|cvss_printer:"high,low,none" }}{{ severity_vector.vector.integrityImpact|cvss_printer:"high,low,none" }}{{ severity_vector.vector.availabilityImpact|cvss_printer:"high,low,none" }}
    - {% elif severity_vector.vector.version == '4' %} - Vector: {{ severity_vector.vector.vectorString }} Found at {{ severity_vector.origin }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Attack Vector (AV)Attack Complexity (AC)Attack Requirements (AT)Privileges Required (PR)User Interaction (UI)Vulnerable System Impact Confidentiality (VC)Vulnerable System Impact Integrity (VI)Vulnerable System Impact Availability (VA)Subsequent System Impact Confidentiality (SC)Subsequent System Impact Integrity (SI)Subsequent System Impact Availability (SA)
    {{ severity_vector.vector.attackVector|cvss_printer:"network,adjacent,local,physical"}}{{ severity_vector.vector.attackComplexity|cvss_printer:"low,high" }}{{ severity_vector.vector.attackRequirement|cvss_printer:"none,present" }}{{ severity_vector.vector.privilegesRequired|cvss_printer:"none,low,high" }}{{ severity_vector.vector.userInteraction|cvss_printer:"none,passive,active"}}{{ severity_vector.vector.vulnerableSystemImpactConfidentiality|cvss_printer:"high,low,none" }}{{ severity_vector.vector.vulnerableSystemImpactIntegrity|cvss_printer:"high,low,none" }}{{ severity_vector.vector.vulnerableSystemImpactAvailability|cvss_printer:"high,low,none" }}{{ severity_vector.vector.subsequentSystemImpactConfidentiality|cvss_printer:"high,low,none" }}{{ severity_vector.vector.subsequentSystemImpactIntegrity|cvss_printer:"high,low,none" }}{{ severity_vector.vector.subsequentSystemImpactAvailability|cvss_printer:"high,low,none" }}
    - {% elif severity_vector.vector.version == 'ssvc' %} -
    - Vector: {{ severity_vector.vector.vectorString }} Found at {{ severity_vector.origin }} -
    - {% endif %} - {% empty %} - - - There are no known vectors. - - - {% endfor %} + {% if severity_vector.vector.version == '2.0' %} + Vector: {{ severity_vector.vector.vectorString }} + {% if severity_vector.origin %} + Found at + {{ severity_vector.origin }} + {% endif %} + + + + + + + + + + + + + + + + + + + +
    Exploitability (E)Access Vector (AV)Access Complexity (AC)Authentication (Au)Confidentiality Impact (C)Integrity Impact (I)Availability Impact (A)
    {{ severity_vector.vector.exploitability|cvss_printer:"high,functional,unproven,proof_of_concept,not_defined" }}{{ severity_vector.vector.accessVector|cvss_printer:"local,adjacent_network,network" }}{{ severity_vector.vector.accessComplexity|cvss_printer:"high,medium,low" }}{{ severity_vector.vector.authentication|cvss_printer:"multiple,single,none" }}{{ severity_vector.vector.confidentialityImpact|cvss_printer:"none,partial,complete" }}{{ severity_vector.vector.integrityImpact|cvss_printer:"none,partial,complete" }}{{ severity_vector.vector.availabilityImpact|cvss_printer:"none,partial,complete" }}
    + {% elif severity_vector.vector.version == '3.1' or severity_vector.vector.version == '3.0'%} + Vector: {{ severity_vector.vector.vectorString }} Found at {{ severity_vector.origin }} + + + + + + + + + + + + + + + + + + + + + +
    Attack Vector (AV)Attack Complexity (AC)Privileges Required (PR)User Interaction (UI)Scope (S)Confidentiality Impact (C)Integrity Impact (I)Availability Impact (A)
    {{ severity_vector.vector.attackVector|cvss_printer:"network,adjacent_network,local,physical"}}{{ severity_vector.vector.attackComplexity|cvss_printer:"low,high" }}{{ severity_vector.vector.privilegesRequired|cvss_printer:"none,low,high" }}{{ severity_vector.vector.userInteraction|cvss_printer:"none,required"}}{{ severity_vector.vector.scope|cvss_printer:"unchanged,changed" }}{{ severity_vector.vector.confidentialityImpact|cvss_printer:"high,low,none" }}{{ severity_vector.vector.integrityImpact|cvss_printer:"high,low,none" }}{{ severity_vector.vector.availabilityImpact|cvss_printer:"high,low,none" }}
    + {% elif severity_vector.vector.version == '4' %} + Vector: {{ severity_vector.vector.vectorString }} Found at {{ severity_vector.origin }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Attack Vector (AV)Attack Complexity (AC)Attack Requirements (AT)Privileges Required (PR)User Interaction (UI)Vulnerable System Impact Confidentiality (VC)Vulnerable System Impact Integrity (VI)Vulnerable System Impact Availability (VA)Subsequent System Impact Confidentiality (SC)Subsequent System Impact Integrity (SI)Subsequent System Impact Availability (SA)
    {{ severity_vector.vector.attackVector|cvss_printer:"network,adjacent,local,physical"}}{{ severity_vector.vector.attackComplexity|cvss_printer:"low,high" }}{{ severity_vector.vector.attackRequirement|cvss_printer:"none,present" }}{{ severity_vector.vector.privilegesRequired|cvss_printer:"none,low,high" }}{{ severity_vector.vector.userInteraction|cvss_printer:"none,passive,active"}}{{ severity_vector.vector.vulnerableSystemImpactConfidentiality|cvss_printer:"high,low,none" }}{{ severity_vector.vector.vulnerableSystemImpactIntegrity|cvss_printer:"high,low,none" }}{{ severity_vector.vector.vulnerableSystemImpactAvailability|cvss_printer:"high,low,none" }}{{ severity_vector.vector.subsequentSystemImpactConfidentiality|cvss_printer:"high,low,none" }}{{ severity_vector.vector.subsequentSystemImpactIntegrity|cvss_printer:"high,low,none" }}{{ severity_vector.vector.subsequentSystemImpactAvailability|cvss_printer:"high,low,none" }}
    + {% elif severity_vector.vector.version == 'ssvc' %} +
    + Vector: {{ severity_vector.vector.vectorString }} Found at {{ severity_vector.origin }} +
    + {% endif %} + {% empty %} + + + There are no known vectors. + + + {% endfor %}
    {% if ssvcs %} - {% for ssvc in ssvcs %} -
    -
    -

    - Vector: {{ ssvc.vector }} -

    -

    - Decision: {{ ssvc.decision }} -

    + {% for ssvc in ssvcs %} +
    +
    +

    + Vector: {{ ssvc.vector }} +

    +

    + Decision: {{ ssvc.decision }} +

    - Source URL: - - {{ ssvc.advisory_url }} - - + Source URL: + + {{ ssvc.advisory_url }} + +

    - Source Advisory: - - {{ ssvc.advisory.avid }} - - + Source Advisory: + + {{ ssvc.advisory.avid }} + +

    -
    +
    - View SSVC decision tree + View SSVC decision tree
    {{ ssvc.options|pprint }}
    -
    -
    +
    - {% endfor %} +
    + {% endfor %} {% else %} -

    There are no SSVC decisions available.

    +

    There are no SSVC decisions available.

    {% endif %} -
    +
    - + + {% if advisory_history and not is_snapshot %} +
    +
    + To track EPSS score history go to EPSS Tab +
    + + + + + + + + + + {% for entry in advisory_history %} + {% if entry.is_initial or entry.diff %} + + + + + + {% endif %} + {% empty %} + + + + {% endfor %} + +
    Date CollectedChanges in this versionSnapshot
    + {{ entry.date_collected|date:"Y-m-d H:i" }} UTC + {% if entry.is_latest %} + Latest + {% endif %} + + {% if entry.is_initial %} + Oldest version collected + {% elif entry.diff %} + {% with formatted_diff=entry.diff|format_diff_for_ui %} +
    + {% for field, change in formatted_diff.items %} +
    + {{ field }}: + {% if field == 'Summary' %} + Summary was changed + {% elif 'old' in change or 'new' in change %} +
    + {{ + change.old }} + → + {{ + change.new }} +
    + {% else %} +
    + {% for item in change.added %} +
    + + Added {% if item.attributes %}{{ item.header }} with +{% for key, value in item.attributes %} {{ key }} {{ value }}{% if not forloop.last %} +{% endif %}{% endfor %}{% else %}{{ item }}{% endif %} +
    + {% endfor %} + {% for item in change.removed %} +
    + − Removed {% if item.attributes %}{{ item.header }} with +{% for key, value in item.attributes %} {{ key }} {{ value }}{% if not forloop.last %} +{% endif %}{% endfor %}{% else %}{{ item }}{% endif %} +
    + {% endfor %} +
    + {% endif %} +
    + {% endfor %} +
    + Show more ▾ + {% endwith %} + {% endif %} +
    + {% if not entry.is_latest %} + + View + + {% else %} + - + {% endif %} +
    No historical versions available.
    + {% if page_obj.has_other_pages %} + + {% endif %} +
    + {% endif %}
@@ -737,6 +870,36 @@ {% endblock %} \ No newline at end of file diff --git a/vulnerabilities/templates/advisory_package_details.html b/vulnerabilities/templates/advisory_package_details.html index 85c1dcaba..66853a77e 100644 --- a/vulnerabilities/templates/advisory_package_details.html +++ b/vulnerabilities/templates/advisory_package_details.html @@ -13,6 +13,13 @@ {% if advisoryv2 %}
+ {% if is_snapshot %} +
+ Historical snapshot collected on {{ advisoryv2.date_collected|date:"Y-m-d H:i" }} UTC. + View current version +
+ {% endif %} +
diff --git a/vulnerabilities/templatetags/diff_advisory_history.py b/vulnerabilities/templatetags/diff_advisory_history.py new file mode 100644 index 000000000..7d7933a05 --- /dev/null +++ b/vulnerabilities/templatetags/diff_advisory_history.py @@ -0,0 +1,118 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from django import template +from packageurl import PackageURL + +register = template.Library() + + +@register.filter +def format_diff_for_ui(changes) -> dict: + """ + Example: + { + 'affected_packages': { + 'added': [{'package': {'type': 'pypi', 'name': 'requests', 'version': '2.25.0'}, 'affected_version_range': '==2.25.0', 'fixed_version_range': '==2.25.1'}], + 'removed': [{'package': {'type': 'pypi', 'name': 'requests', 'version': '2.24.0'}, 'affected_version_range': '==2.24.0', 'fixed_version_range': '==2.24.1'}] + } + } + should result in: + { + 'Affected Packages': { + 'added': [{'header': 'Affected package', 'attributes': [('PURL', 'pkg:pypi/requests@2.25.0'), ('Affected version', '==2.25.0'), ('Fixed Version', '==2.25.1')] }], + 'removed': [{'header': 'Affected package', 'attributes': [('PURL', 'pkg:pypi/requests@2.24.0'), ('Affected version', '==2.24.0'), ('Fixed Version', '==2.24.1')] }] + } + } + """ + formatted = {} + + for field, change in changes.items(): + label = field.replace("_", " ").title() + + if "old" in change or "new" in change: + formatted[label] = change + continue + + formatted[label] = {"added": [], "removed": []} + + for change_type in ["added", "removed"]: + for item in change.get(change_type, []): + if field == "affected_packages": + attributes = [] + + if package := item.get("package"): + package_string = ( + str(PackageURL(**package)) + if isinstance(package, dict) + else str(package) + ) + attributes.append(("PURL", package_string)) + + if affected_version_range := item.get("affected_version_range"): + attributes.append(("Affected version", affected_version_range)) + + if fixed_version_range := item.get("fixed_version_range"): + attributes.append(("Fixed Version", fixed_version_range)) + + formatted[label][change_type].append( + {"header": "Affected package", "attributes": attributes} + ) + + elif field == "references": + attributes = [] + + if reference_url := item.get("url"): + attributes.append(("URL", reference_url)) + + if reference_id := item.get("reference_id"): + attributes.append(("ID", reference_id)) + + formatted[label][change_type].append( + {"header": "Reference", "attributes": attributes} + ) + + elif field == "severities": + attributes = [] + + if scoring_system := item.get("system") or item.get("scoring_system"): + scoring_system_string = str(scoring_system) + if "cvss" in scoring_system_string.lower(): + scoring_system_string = scoring_system_string.upper() + else: + scoring_system_string = scoring_system_string.replace("_", " ").title() + attributes.append(("System", scoring_system_string)) + + if severity_value := item.get("value"): + attributes.append(("Value", severity_value)) + + if scoring_elements := item.get("scoring_elements"): + attributes.append(("Elements", scoring_elements)) + + formatted[label][change_type].append( + {"header": "Severity", "attributes": attributes} + ) + + elif field == "patches": + attributes = [] + + if patch_url := (item.get("url") or item.get("repository")): + attributes.append(("URL", patch_url)) + + if patch_commit := item.get("commit"): + attributes.append(("Commit", patch_commit)) + + formatted[label][change_type].append( + {"header": "Patch", "attributes": attributes} + ) + + else: + formatted[label][change_type].append(item) + + return formatted diff --git a/vulnerabilities/utils.py b/vulnerabilities/utils.py index a4fe0274f..fd3476ece 100644 --- a/vulnerabilities/utils.py +++ b/vulnerabilities/utils.py @@ -691,6 +691,36 @@ def compute_content_id(advisory_data): return content_id +def get_normalized_advisory_v2(advisory_data): + """ + Return a normalized dictionary of advisory data for diffing history. + + :param advisory_data: An AdvisoryV2 object + :return: A normalized dictionary of advisory data + """ + from vulnerabilities.models import AdvisoryV2 + + if not isinstance(advisory_data, AdvisoryV2): + raise ValueError("Unsupported advisory data type for history diffing") + + advisory_data = advisory_data.to_advisory_data() + + normalized_data = { + "advisory_id": advisory_data.advisory_id, + "aliases": advisory_data.aliases, + "summary": advisory_data.summary, + "affected_packages": [pkg.to_dict() for pkg in advisory_data.affected_packages if pkg], + "references": [ref.to_dict() for ref in advisory_data.references if ref], + "severities": [sev.to_dict() for sev in advisory_data.severities if sev], + "weaknesses": advisory_data.weaknesses, + "patches": [patch.to_dict() for patch in advisory_data.patches], + "url": advisory_data.url, + # "date_published": str(advisory_data.date_published) if advisory_data.date_published else None, + } + + return normalized_data + + def compute_content_id_v2(advisory_data): """ Compute a unique content_id for an advisory by normalizing its data and hashing it. @@ -1129,3 +1159,72 @@ def safe_altcha_redirect(next_url: str) -> redirect: return redirect(next_url) return redirect("/") + + +def exclude_epss_severities(severities: list) -> list: + """ + Return a copy of the severities list with all EPSS entries removed. + EPSS scores are updated daily and can be tracked separately in EPSS tab. + """ + return [ + severity + for severity in (severities or []) + if str(severity.get("system", "")).lower() != "epss" + ] + + +def diff_advisories_v2(old_data: dict, new_data: dict) -> dict: + """ + Diff two normalised advisories and return the changes. + """ + changes = {} + all_keys = set(old_data.keys()).union(new_data.keys()) + + for field in all_keys: + old_field_value = old_data.get(field) + new_field_value = new_data.get(field) + + if isinstance(old_field_value, list) or isinstance(new_field_value, list): + old_items_list = old_field_value or [] + new_items_list = new_field_value or [] + + if field == "severities": + old_items_list = exclude_epss_severities(old_items_list) + new_items_list = exclude_epss_severities(new_items_list) + + if all(isinstance(item, str) for item in old_items_list + new_items_list): + # "aliases", "weaknesses" + old_items_set = set(old_items_list) + new_items_set = set(new_items_list) + if old_items_set != new_items_set: + changes[field] = { + "added": sorted(new_items_set - old_items_set), + "removed": sorted(old_items_set - new_items_set), + } + elif all(isinstance(item, dict) for item in old_items_list + new_items_list): + # "references", "affected_packages", "patches", "severities" + old_items_set = { + json.dumps(item, sort_keys=True, default=str) for item in old_items_list + } + new_items_set = { + json.dumps(item, sort_keys=True, default=str) for item in new_items_list + } + if old_items_set != new_items_set: + changes[field] = { + "added": [ + json.loads(item) for item in sorted(new_items_set - old_items_set) + ], + "removed": [ + json.loads(item) for item in sorted(old_items_set - new_items_set) + ], + } + elif ( + isinstance(old_field_value, str) + or isinstance(new_field_value, str) + or (old_field_value is None and new_field_value is None) + ): + # "url", "date_published", "summary" + if old_field_value != new_field_value: + changes[field] = {"old": old_field_value, "new": new_field_value} + + return changes diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 73eb229b3..8c74c9c15 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -21,6 +21,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.mail import send_mail +from django.core.paginator import Paginator from django.db.models import Exists from django.db.models import OuterRef from django.db.models import Prefetch @@ -62,7 +63,9 @@ from vulnerabilities.tasks import compute_queue_load_factor from vulnerabilities.throttling import AnonUserUIThrottle from vulnerabilities.utils import TYPES_WITH_MULTIPLE_IMPORTERS +from vulnerabilities.utils import diff_advisories_v2 from vulnerabilities.utils import get_advisories_from_groups +from vulnerabilities.utils import get_normalized_advisory_v2 from vulnerabilities.utils import safe_altcha_redirect from vulnerablecode import __version__ as VULNERABLECODE_VERSION from vulnerablecode.settings import ALTCHA_SESSION_TIMEOUT @@ -580,6 +583,65 @@ def get_context_data(self, **kwargs): return context +def build_advisory_history(advisory, page_number): + """ + Build advisory history for a given advisory. + """ + advisory_history_qs = ( + models.AdvisoryV2.objects.filter(avid=advisory.avid) + .order_by("-date_collected", "-unique_content_id") + .prefetch_related( + "aliases", "references", "weaknesses", "severities", "impacted_packages", "patches" + ) + ) + + paginator = Paginator(advisory_history_qs, 10) + pagination_obj = paginator.get_page(page_number) + advisories_on_page = list(pagination_obj.object_list) + + last_advisory_fallback = None + if pagination_obj.has_next(): + # Fetch the very first record from the next page to calculate the diff for the last item on the current page + last_advisory_fallback = advisory_history_qs.filter( + date_collected__lt=advisories_on_page[-1].date_collected + ).first() + + normalized_advisories = { + adv.unique_content_id: get_normalized_advisory_v2(adv) for adv in advisories_on_page + } + + # Normalize the last advisory fallback + if last_advisory_fallback: + normalized_advisories[last_advisory_fallback.unique_content_id] = ( + get_normalized_advisory_v2(last_advisory_fallback) + ) + + advisory_history = [] + for current_index, current_advisory in enumerate(advisories_on_page): + # Diff w.r.t immediately older advisory + if current_index + 1 < len(advisories_on_page): + older_advisory = advisories_on_page[current_index + 1] + else: + older_advisory = last_advisory_fallback + + current_data = normalized_advisories[current_advisory.unique_content_id] + older_data = ( + normalized_advisories[older_advisory.unique_content_id] if older_advisory else None + ) + + advisory_history.append( + { + "date_collected": current_advisory.date_collected, + "unique_content_id": current_advisory.unique_content_id, + "is_latest": current_advisory.is_latest, + "is_initial": older_advisory is None, + "diff": diff_advisories_v2(older_data, current_data) if older_data else {}, + } + ) + + return advisory_history, pagination_obj + + class AdvisoryDetails(VulnerableCodeDetailView): model = models.AdvisoryV2 template_name = "advisory_detail.html" @@ -647,6 +709,12 @@ def get_queryset(self): "source_advisory__url", ), ), + Prefetch( + "impacted_packages", + queryset=models.ImpactedPackage.objects.only( + "base_purl", "affecting_vers", "fixed_vers" + ), + ), ) ) @@ -738,6 +806,10 @@ def add_ssvc(ssvc): for ssvc in advisory.related_ssvcs.all(): add_ssvc(ssvc) + advisory_history, pagination_obj = build_advisory_history( + advisory, self.request.GET.get("page", 1) + ) + context["ssvcs"] = ssvc_entries context.update( { @@ -750,11 +822,29 @@ def add_ssvc(ssvc): "weaknesses": weaknesses_present_in_db, "status": advisory.get_status_label, "epss_data": epss_data, + "advisory_history": advisory_history, + "page_obj": pagination_obj, + "is_snapshot": False, } ) return context +class AdvisorySnapshotView(AdvisoryDetails): + def get_object(self, queryset=None): + avid = self.kwargs["avid"] + uid = self.kwargs["unique_content_id"] + try: + return self.get_queryset().get(avid=avid, unique_content_id=uid) + except models.AdvisoryV2.DoesNotExist: + raise Http404 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({"advisory_history": [], "is_snapshot": True}) + return context + + class HomePage(VulnerableCodeView): template_name = "index.html" @@ -993,6 +1083,46 @@ def get_queryset(self): ) +class AdvisoryPackagesSnapshotView(AdvisoryPackagesDetails): + """ + View to display package details for an advisory snapshot. + """ + + def get_object(self, queryset=None): + avid = self.kwargs["avid"] + uid = self.kwargs["unique_content_id"] + try: + return self.get_queryset().get(avid=avid, unique_content_id=uid) + except models.AdvisoryV2.DoesNotExist: + raise Http404 + + def get_queryset(self): + from vulnerabilities.models import ImpactedPackage + from vulnerabilities.models import PackageV2 + + + return AdvisoryV2.objects.all().prefetch_related( + Prefetch( + "impacted_packages", + queryset=ImpactedPackage.objects.all().prefetch_related( + Prefetch( + "affecting_packages", + queryset=PackageV2.objects.only("type", "namespace", "name", "version"), + ), + Prefetch( + "fixed_by_packages", + queryset=PackageV2.objects.only("type", "namespace", "name", "version"), + ), + ), + ) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({"is_snapshot": True}) + return context + + class PipelineScheduleListView(VulnerableCodeListView, FormMixin): model = PipelineSchedule context_object_name = "schedule_list" diff --git a/vulnerablecode/static/css/custom.css b/vulnerablecode/static/css/custom.css index 6d8918a8f..b914ccf55 100644 --- a/vulnerablecode/static/css/custom.css +++ b/vulnerablecode/static/css/custom.css @@ -655,3 +655,63 @@ ul.fixed_by_bullet li li li { box-shadow: none; } } + + +/* Diff formatting for advisory history */ + +.diff-badge { + padding: 2px 6px; + border-radius: 4px; + display: inline-block; +} + +.diff-badge-yellow { + color: #856404; + background-color: #fff3cd; +} + +.diff-badge-red { + color: #721c24; + background-color: #f8d7da; +} + +.diff-badge-green { + color: #155724; + background-color: #d4edda; +} + +.diff-wrap { + word-break: break-word; + white-space: pre-wrap; +} + +.diff-max-width { + max-width: 600px; +} + +#tab-content table div { + display: block !important; + margin-bottom: 0 !important; + padding-bottom: 0 !important; +} + +.history-diff-collapsible { + max-height: 120px; + overflow: hidden; + position: relative; +} + +.history-diff-collapsible:not(.is-expanded):not(.no-truncate)::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + background: linear-gradient(transparent, white); + pointer-events: none; +} + +.history-diff-collapsible.is-expanded { + max-height: none; +} \ No newline at end of file diff --git a/vulnerablecode/urls.py b/vulnerablecode/urls.py index b693bd725..6acce4119 100644 --- a/vulnerablecode/urls.py +++ b/vulnerablecode/urls.py @@ -27,6 +27,8 @@ from vulnerabilities.views import AdvisoryPackageCommitPatchDetails from vulnerabilities.views import AdvisoryPackageCurationView from vulnerabilities.views import AdvisoryPackagesDetails +from vulnerabilities.views import AdvisoryPackagesSnapshotView +from vulnerabilities.views import AdvisorySnapshotView from vulnerabilities.views import AdvisorySeverityCurationView from vulnerabilities.views import AdvisoryToDoListView from vulnerabilities.views import AdvisoryWeaknessCurationView @@ -114,6 +116,11 @@ def __init__(self, *args, **kwargs): PipelineRunDetailView.as_view(), name="run-details", ), + path( + "v2", + HomePageV2.as_view(), + name="home", + ), path( "advisories/packages/", AdvisoryPackagesDetails.as_view(), @@ -124,6 +131,11 @@ def __init__(self, *args, **kwargs): AdvisoryPackageCommitPatchDetails.as_view(), name="advisory_package_commit_details", ), + path( + "advisories/snapshot//", + AdvisorySnapshotView.as_view(), + name="advisory_snapshot", + ), path( "advisories/", AdvisoryDetails.as_view(), From e133b7f21834641ecb22a64ad239ed278f7abe2c Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Thu, 25 Jun 2026 03:19:48 +0530 Subject: [PATCH 2/5] Add Tests for Advisory History Signed-off-by: Sampurna Pyne --- .../tests/test_advisory_history.py | 59 +++ .../expected_diff_GHSA-6rw7-vpxm-498p.json | 46 ++ ...ed_formatted_diff_GHSA-6rw7-vpxm-498p.json | 110 ++++ ...ormalised_history_GHSA-6rw7-vpxm-498p.json | 206 ++++++++ .../expected_diff_GHSA-72hv-8253-57qq.json | 154 ++++++ ...ormalised_history_GHSA-72hv-8253-57qq.json | 484 ++++++++++++++++++ 6 files changed, 1059 insertions(+) create mode 100644 vulnerabilities/tests/test_advisory_history.py create mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json create mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json create mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json create mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json create mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json diff --git a/vulnerabilities/tests/test_advisory_history.py b/vulnerabilities/tests/test_advisory_history.py new file mode 100644 index 000000000..212db4b41 --- /dev/null +++ b/vulnerabilities/tests/test_advisory_history.py @@ -0,0 +1,59 @@ +import json +import os + +from vulnerabilities.templatetags.diff_advisory_history import format_diff_for_ui +from vulnerabilities.tests.util_tests import check_results_against_json + + +def test_advisory_history_diffing(): + """ + Test the diffing logic for historical advisory snapshots. + """ + from vulnerabilities.utils import diff_advisories_v2 + + base_dir = os.path.join(os.path.dirname(__file__), "test_data", "advisory_history") + + # Test GHSA-72hv-8253-57qq (Sample 1) + # See: https://github.com/github/advisory-database/commits/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json + sample1_dir = os.path.join(base_dir, "GHSA-72hv-8253-57qq") + with open(os.path.join(sample1_dir, "normalised_history_GHSA-72hv-8253-57qq.json"), "r") as f: + sample1_data = json.load(f) + + sample1_diffs = [ + diff_advisories_v2(sample1_data[i], sample1_data[i + 1]) + for i in range(len(sample1_data) - 1) + ] + + sample1_expected_file = os.path.join(sample1_dir, "expected_diff_GHSA-72hv-8253-57qq.json") + check_results_against_json(sample1_diffs, sample1_expected_file) + + # Test GHSA-6rw7-vpxm-498p (Sample 2) + # See: https://github.com/github/advisory-database/commits/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json + sample2_dir = os.path.join(base_dir, "GHSA-6rw7-vpxm-498p") + with open(os.path.join(sample2_dir, "normalised_history_GHSA-6rw7-vpxm-498p.json"), "r") as f: + sample2_data = json.load(f) + + sample2_diffs = [ + diff_advisories_v2(sample2_data[i], sample2_data[i + 1]) + for i in range(len(sample2_data) - 1) + ] + + sample2_expected_file = os.path.join(sample2_dir, "expected_diff_GHSA-6rw7-vpxm-498p.json") + check_results_against_json(sample2_diffs, sample2_expected_file) + + +def test_format_diff_for_ui(): + """ + Test the template tag logic that formats diffs for the UI. + """ + base_dir = os.path.join(os.path.dirname(__file__), "test_data", "advisory_history") + sample2_dir = os.path.join(base_dir, "GHSA-6rw7-vpxm-498p") + input_file = os.path.join(sample2_dir, "expected_diff_GHSA-6rw7-vpxm-498p.json") + + with open(input_file, "r") as f: + input_diffs = json.load(f) + + formatted_diffs = [format_diff_for_ui(diff) for diff in input_diffs] + + expected_file = os.path.join(sample2_dir, "expected_formatted_diff_GHSA-6rw7-vpxm-498p.json") + check_results_against_json(formatted_diffs, expected_file) diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json new file mode 100644 index 000000000..a7265d0b5 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json @@ -0,0 +1,46 @@ +[ + { + "summary": { + "old": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", + "new": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value." + } + }, + { + "severities": { + "added": [ + { + "scoring_elements": "", + "system": "generic_textual", + "value": "MODERATE" + }, + { + "scoring_elements": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L", + "system": "cvssv3.1", + "value": "3.7" + }, + { + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L", + "system": "cvssv4", + "value": "6.3" + } + ], + "removed": [ + { + "scoring_elements": "", + "system": "generic_textual", + "value": "HIGH" + }, + { + "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "system": "cvssv3.1", + "value": "7.5" + }, + { + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N", + "system": "cvssv4", + "value": "8.7" + } + ] + } + } +] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json new file mode 100644 index 000000000..645b9d308 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json @@ -0,0 +1,110 @@ +[ + { + "Summary": { + "old": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", + "new": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value." + } + }, + { + "Severities": { + "added": [ + { + "header": "Severity", + "attributes": [ + [ + "System", + "Generic Textual" + ], + [ + "Value", + "MODERATE" + ] + ] + }, + { + "header": "Severity", + "attributes": [ + [ + "System", + "CVSSV3.1" + ], + [ + "Value", + "3.7" + ], + [ + "Elements", + "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L" + ] + ] + }, + { + "header": "Severity", + "attributes": [ + [ + "System", + "CVSSV4" + ], + [ + "Value", + "6.3" + ], + [ + "Elements", + "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L" + ] + ] + } + ], + "removed": [ + { + "header": "Severity", + "attributes": [ + [ + "System", + "Generic Textual" + ], + [ + "Value", + "HIGH" + ] + ] + }, + { + "header": "Severity", + "attributes": [ + [ + "System", + "CVSSV3.1" + ], + [ + "Value", + "7.5" + ], + [ + "Elements", + "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + ] + ] + }, + { + "header": "Severity", + "attributes": [ + [ + "System", + "CVSSV4" + ], + [ + "Value", + "8.7" + ], + [ + "Elements", + "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + ] + ] + } + ] + } + } +] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json new file mode 100644 index 000000000..71c0281a3 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json @@ -0,0 +1,206 @@ +[ + { + "advisory_id": "GHSA-6rw7-vpxm-498p", + "aliases": [ + "CVE-2025-15284" + ], + "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", + "affected_packages": [ + { + "package": { + "type": "npm", + "namespace": "", + "name": "qs", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:npm/<6.14.1", + "fixed_version_range": "vers:npm/6.14.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" + } + ], + "severities": [ + { + "system": "cvssv3.1", + "value": "7.5", + "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + { + "system": "cvssv4", + "value": "8.7", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "HIGH", + "scoring_elements": "" + } + ], + "weaknesses": [ + 20 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", + "date_published": "2025-12-30 21:02:54+00:00" + }, + { + "advisory_id": "GHSA-6rw7-vpxm-498p", + "aliases": [ + "CVE-2025-15284" + ], + "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value.", + "affected_packages": [ + { + "package": { + "type": "npm", + "namespace": "", + "name": "qs", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:npm/<6.14.1", + "fixed_version_range": "vers:npm/6.14.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" + } + ], + "severities": [ + { + "system": "cvssv3.1", + "value": "7.5", + "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + { + "system": "cvssv4", + "value": "8.7", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "HIGH", + "scoring_elements": "" + } + ], + "weaknesses": [ + 20 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", + "date_published": "2025-12-30 21:02:54+00:00" + }, + { + "advisory_id": "GHSA-6rw7-vpxm-498p", + "aliases": [ + "CVE-2025-15284" + ], + "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value.", + "affected_packages": [ + { + "package": { + "type": "npm", + "namespace": "", + "name": "qs", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:npm/<6.14.1", + "fixed_version_range": "vers:npm/6.14.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" + } + ], + "severities": [ + { + "system": "cvssv3.1", + "value": "3.7", + "scoring_elements": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L" + }, + { + "system": "cvssv4", + "value": "6.3", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L" + }, + { + "system": "generic_textual", + "value": "MODERATE", + "scoring_elements": "" + } + ], + "weaknesses": [ + 20 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", + "date_published": "2025-12-30 21:02:54+00:00" + } +] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json new file mode 100644 index 000000000..2e4141659 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json @@ -0,0 +1,154 @@ +[ + { + "references": { + "added": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/pull/1555" + } + ], + "removed": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/a004e9789c2cc6b41b379d02d229d58474d9a738" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/issues/1538" + } + ] + } + }, + { + "affected_packages": { + "added": [], + "removed": [ + { + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "tools.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + }, + { + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "tools.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + }, + { + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "com.fasterxml.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + } + ] + }, + "severities": { + "added": [ + { + "scoring_elements": "", + "system": "generic_textual", + "value": "MODERATE" + }, + { + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N", + "system": "cvssv4", + "value": "6.9" + } + ], + "removed": [ + { + "scoring_elements": "", + "system": "generic_textual", + "value": "HIGH" + }, + { + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N", + "system": "cvssv4", + "value": "8.7" + } + ] + } + }, + { + "affected_packages": { + "added": [ + { + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "tools.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + }, + { + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "tools.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + }, + { + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_by_commit_patches": [], + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "package": { + "name": "jackson-core", + "namespace": "com.fasterxml.jackson.core", + "qualifiers": "", + "subpath": "", + "type": "maven", + "version": "" + } + } + ], + "removed": [] + } + } +] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json new file mode 100644 index 000000000..7cbd111e3 --- /dev/null +++ b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json @@ -0,0 +1,484 @@ +[ + { + "advisory_id": "GHSA-72hv-8253-57qq", + "aliases": [], + "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", + "affected_packages": [ + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/a004e9789c2cc6b41b379d02d229d58474d9a738" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/issues/1538" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" + } + ], + "severities": [ + { + "system": "cvssv4", + "value": "8.7", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "HIGH", + "scoring_elements": "" + } + ], + "weaknesses": [ + 770 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", + "date_published": "2026-02-28 02:01:05+00:00" + }, + { + "advisory_id": "GHSA-72hv-8253-57qq", + "aliases": [], + "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", + "affected_packages": [ + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/pull/1555" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" + } + ], + "severities": [ + { + "system": "cvssv4", + "value": "8.7", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "HIGH", + "scoring_elements": "" + } + ], + "weaknesses": [ + 770 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", + "date_published": "2026-02-28 02:01:05+00:00" + }, + { + "advisory_id": "GHSA-72hv-8253-57qq", + "aliases": [], + "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", + "affected_packages": [ + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/pull/1555" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" + } + ], + "severities": [ + { + "system": "cvssv4", + "value": "6.9", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "MODERATE", + "scoring_elements": "" + } + ], + "weaknesses": [ + 770 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", + "date_published": "2026-02-28 02:01:05+00:00" + }, + { + "advisory_id": "GHSA-72hv-8253-57qq", + "aliases": [], + "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", + "affected_packages": [ + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "com.fasterxml.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/<=2.18.5", + "fixed_version_range": "vers:maven/2.18.6", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", + "fixed_version_range": "vers:maven/2.21.1", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + }, + { + "package": { + "type": "maven", + "namespace": "tools.jackson.core", + "name": "jackson-core", + "version": "", + "qualifiers": "", + "subpath": "" + }, + "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", + "fixed_version_range": "vers:maven/3.1.0", + "introduced_by_commit_patches": [], + "fixed_by_commit_patches": [] + } + ], + "references": [ + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/pull/1555" + }, + { + "reference_id": "", + "reference_type": "", + "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" + } + ], + "severities": [ + { + "system": "cvssv4", + "value": "6.9", + "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" + }, + { + "system": "generic_textual", + "value": "MODERATE", + "scoring_elements": "" + } + ], + "weaknesses": [ + 770 + ], + "patches": [], + "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", + "date_published": "2026-02-28 02:01:05+00:00" + } +] \ No newline at end of file From fe56518a238f20317db1c0810908a09b56f055da Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Sun, 26 Jul 2026 04:48:05 +0530 Subject: [PATCH 3/5] Refactor with AdvisoryHistoryDiff model and pipeline Signed-off-by: Sampurna Pyne --- vulnerabilities/improvers/__init__.py | 2 + .../migrations/0138_advisoryhistorydiff.py | 38 ++++ vulnerabilities/models.py | 67 +++++++ vulnerabilities/pipelines/__init__.py | 5 + .../v2_improvers/history_diff_backfill.py | 81 ++++++++ vulnerabilities/pipes/advisory.py | 38 +++- .../templates/advisory_detail.html | 18 +- .../templatetags/diff_advisory_history.py | 181 ++++++++---------- vulnerabilities/utils.py | 161 +++++++--------- vulnerabilities/views.py | 61 +++--- vulnerablecode/urls.py | 2 +- 11 files changed, 418 insertions(+), 236 deletions(-) create mode 100644 vulnerabilities/migrations/0138_advisoryhistorydiff.py create mode 100644 vulnerabilities/pipelines/v2_improvers/history_diff_backfill.py diff --git a/vulnerabilities/improvers/__init__.py b/vulnerabilities/improvers/__init__.py index 51694bde9..0a1b5e7b5 100644 --- a/vulnerabilities/improvers/__init__.py +++ b/vulnerabilities/improvers/__init__.py @@ -21,6 +21,7 @@ from vulnerabilities.pipelines.v2_improvers import ( group_advisories_for_packages as group_advisories_for_packages_v2, ) +from vulnerabilities.pipelines.v2_improvers import history_diff_backfill from vulnerabilities.pipelines.v2_improvers import mark_unfurl_version_range from vulnerabilities.pipelines.v2_improvers import reference_collect_commits from vulnerabilities.pipelines.v2_improvers import relate_severities @@ -42,6 +43,7 @@ reference_collect_commits.CollectReferencesFixCommitsPipeline, enhance_with_github_poc.GithubPocsImproverPipeline, mark_unfurl_version_range.MarkUnfurlVersionRangePipeline, + history_diff_backfill.HistoryDiffImproverPipeline, group_advisories_for_packages_v2.GroupAdvisoriesForPackages, ] ) diff --git a/vulnerabilities/migrations/0138_advisoryhistorydiff.py b/vulnerabilities/migrations/0138_advisoryhistorydiff.py new file mode 100644 index 000000000..7e7efc21e --- /dev/null +++ b/vulnerabilities/migrations/0138_advisoryhistorydiff.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.11 on 2026-07-25 16:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities', '0137_alter_pipelineschedule_run_interval'), + ] + + operations = [ + migrations.CreateModel( + name='AdvisoryHistoryDiff', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('summary_added', models.TextField(blank=True, null=True)), + ('summary_removed', models.TextField(blank=True, null=True)), + ('url_added', models.URLField(blank=True, null=True)), + ('url_removed', models.URLField(blank=True, null=True)), + ('added_aliases', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryalias')), + ('added_impacted_packages', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.impactedpackage')), + ('added_patches', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.patch')), + ('added_references', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryreference')), + ('added_severities', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryseverity')), + ('added_weaknesses', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryweakness')), + ('advisory_after', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='history_diff', to='vulnerabilities.advisoryv2')), + ('advisory_before', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='history_diff_before', to='vulnerabilities.advisoryv2')), + ('removed_aliases', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryalias')), + ('removed_impacted_packages', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.impactedpackage')), + ('removed_patches', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.patch')), + ('removed_references', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryreference')), + ('removed_severities', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryseverity')), + ('removed_weaknesses', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryweakness')), + ], + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 826d35a54..ded3a749f 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -3367,6 +3367,73 @@ def get_aliases(self): alias = get_aliases +class AdvisoryHistoryDiff(models.Model): + """ + Stores the pre-computed diff between an AdvisoryV2 snapshot (advisory_after) + and its immediate predecessor (advisory_before). + Created for every snapshot. + """ + + # Points to the older snapshot in the comparison + advisory_before = models.OneToOneField( + AdvisoryV2, + on_delete=models.SET_NULL, + related_name="history_diff_before", + null=True, # The very first snapshot won't have a predecessor + blank=True, + ) + + # Points to the newer snapshot in the comparison + advisory_after = models.OneToOneField( + AdvisoryV2, on_delete=models.SET_NULL, related_name="history_diff", null=True, blank=True + ) + + summary_added = models.TextField(blank=True, null=True) + summary_removed = models.TextField(blank=True, null=True) + + url_added = models.URLField(blank=True, null=True) + url_removed = models.URLField(blank=True, null=True) + + added_severities = models.ManyToManyField( + "AdvisorySeverity", related_name="added_in_history", blank=True + ) + removed_severities = models.ManyToManyField( + "AdvisorySeverity", related_name="removed_in_history", blank=True + ) + + added_impacted_packages = models.ManyToManyField( + "ImpactedPackage", related_name="added_in_history", blank=True + ) + removed_impacted_packages = models.ManyToManyField( + "ImpactedPackage", related_name="removed_in_history", blank=True + ) + + added_references = models.ManyToManyField( + "AdvisoryReference", related_name="added_in_history", blank=True + ) + removed_references = models.ManyToManyField( + "AdvisoryReference", related_name="removed_in_history", blank=True + ) + + added_aliases = models.ManyToManyField( + "AdvisoryAlias", related_name="added_in_history", blank=True + ) + removed_aliases = models.ManyToManyField( + "AdvisoryAlias", related_name="removed_in_history", blank=True + ) + + added_weaknesses = models.ManyToManyField( + "AdvisoryWeakness", related_name="added_in_history", blank=True + ) + removed_weaknesses = models.ManyToManyField( + "AdvisoryWeakness", related_name="removed_in_history", blank=True + ) + + added_patches = models.ManyToManyField("Patch", related_name="added_in_history", blank=True) + removed_patches = models.ManyToManyField("Patch", related_name="removed_in_history", blank=True) + + + class ImpactedPackage(models.Model): """ Represents a single impact for an advisory, including affected range and fixed version and diff --git a/vulnerabilities/pipelines/__init__.py b/vulnerabilities/pipelines/__init__.py index 5940363b0..508bec9d1 100644 --- a/vulnerabilities/pipelines/__init__.py +++ b/vulnerabilities/pipelines/__init__.py @@ -284,6 +284,10 @@ class VulnerableCodeBaseImporterPipelineV2(VulnerableCodePipeline): # pull requests, commit messages, EPSS, exploits, etc. exclude_from_package_todo = False + # Set this to True if computing history diff is not fruitful for this source. + # An example of such advisory would be pipeline dedicated to EPSS + exclude_from_history_diff = False + # Control how often progress log is shown (range: 1–100, higher value = less frequent log) progress_step = 10 @@ -344,6 +348,7 @@ def collect_and_store_advisories(self): datasource_id=self.datasource_id, logger=self.log, precedence=self.precedence, + calculate_history_diff=not self.exclude_from_history_diff, ): collected_advisory_count += 1 except Exception as e: diff --git a/vulnerabilities/pipelines/v2_improvers/history_diff_backfill.py b/vulnerabilities/pipelines/v2_improvers/history_diff_backfill.py new file mode 100644 index 000000000..f53a2e324 --- /dev/null +++ b/vulnerabilities/pipelines/v2_improvers/history_diff_backfill.py @@ -0,0 +1,81 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from aboutcode.pipeline import LoopProgress + +from vulnerabilities.importers import IMPORTERS_REGISTRY +from vulnerabilities.models import AdvisoryHistoryDiff +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.pipelines import VulnerableCodePipeline +from vulnerabilities.utils import compute_advisory_v2_diff + + +class HistoryDiffImproverPipeline(VulnerableCodePipeline): + """ + Pipeline to compute and store relational history snapshot diffs. + """ + + pipeline_id = "history_diff_improver_v2" + + @classmethod + def steps(cls): + return (cls.calculate_history_diffs,) + + def calculate_history_diffs(self): + # Skip excluded importers + excluded_ids = [ + cls.pipeline_id + for cls in IMPORTERS_REGISTRY.values() + if getattr(cls, "exclude_from_history_diff", False) + and getattr(cls, "pipeline_id", None) + ] + qs = ( + AdvisoryV2.objects.exclude(pipeline_id__in=excluded_ids) + if excluded_ids + else AdvisoryV2.objects.all() + ) + + avids_qs = qs.filter(history_diff__isnull=True).values_list("avid", flat=True).distinct() + avids_count = avids_qs.count() + self.log(f"Computing history diffs for {avids_count} advisories") + + for avid in LoopProgress( + total_iterations=avids_count, logger=self.log, progress_step=10 + ).iter(avids_qs.iterator()): + snapshots = list( + qs.filter(avid=avid) + .order_by("date_collected", "unique_content_id") + .prefetch_related( + "aliases", + "references", + "weaknesses", + "severities", + "patches", + "impacted_packages", + ) + ) + + previous_snapshot = None + + # TODO: + # Bulk diff creation + + for snapshot in snapshots: + if not hasattr(snapshot, "history_diff"): # Skip already processed advisories + if previous_snapshot is not None: + compute_advisory_v2_diff(previous_snapshot, snapshot) + else: + # For the very first snapshot, we create an empty diff to mark it as processed + AdvisoryHistoryDiff.objects.create( + advisory_after=snapshot, advisory_before=None + ) + + previous_snapshot = snapshot + + self.log("Successfully finished history diff backfill.") diff --git a/vulnerabilities/pipes/advisory.py b/vulnerabilities/pipes/advisory.py index fec72e66f..a91046605 100644 --- a/vulnerabilities/pipes/advisory.py +++ b/vulnerabilities/pipes/advisory.py @@ -33,6 +33,7 @@ from vulnerabilities.improver import MAX_CONFIDENCE from vulnerabilities.models import Advisory from vulnerabilities.models import AdvisoryAlias +from vulnerabilities.models import AdvisoryHistoryDiff from vulnerabilities.models import AdvisoryReference from vulnerabilities.models import AdvisorySeverity from vulnerabilities.models import AdvisoryV2 @@ -49,6 +50,7 @@ from vulnerabilities.models import Weakness from vulnerabilities.pipes.risk_score import compute_advisory_risk_score from vulnerabilities.pipes.univers_utils import get_exact_purls_v2 +from vulnerabilities.utils import compute_advisory_v2_diff def get_or_create_aliases(aliases: List) -> QuerySet: @@ -295,6 +297,7 @@ def insert_advisory_v2( logger: Callable, datasource_id: str, precedence: int = 0, + calculate_history_diff: bool = True, ): from vulnerabilities.models import ImpactedPackage from vulnerabilities.models import PackageV2 @@ -338,12 +341,16 @@ def insert_advisory_v2( if not created: return advisory_obj - AdvisoryV2.objects.filter( + latest_qs = AdvisoryV2.objects.filter( avid=f"{datasource_id}/{advisory.advisory_id}", is_latest=True, - ).update(is_latest=False) + ) + + previous_latest_advisory = latest_qs.first() + latest_qs.update(is_latest=False) + advisory_obj.is_latest = True - advisory_obj.save() + advisory_obj.save(update_fields=["is_latest"]) aliases = get_or_create_advisory_aliases(aliases=advisory.aliases) references = get_or_create_advisory_references(references=advisory.references) @@ -416,6 +423,31 @@ def insert_advisory_v2( ) impact.introduced_by_package_commit_patches.add(*introduced_commit_v2) impact.fixed_by_package_commit_patches.add(*fixed_commit_v2) + + if calculate_history_diff: + if previous_latest_advisory: + previous = AdvisoryV2.objects.prefetch_related( + "aliases", + "references", + "weaknesses", + "severities", + "patches", + "impacted_packages", + ).get(id=previous_latest_advisory.id) + + current = AdvisoryV2.objects.prefetch_related( + "aliases", + "references", + "weaknesses", + "severities", + "patches", + "impacted_packages", + ).get(id=advisory_obj.id) + + compute_advisory_v2_diff(previous, current) + else: + AdvisoryHistoryDiff.objects.create(advisory_after=advisory_obj, advisory_before=None) + return advisory_obj diff --git a/vulnerabilities/templates/advisory_detail.html b/vulnerabilities/templates/advisory_detail.html index a8478645d..16cb4caf2 100644 --- a/vulnerabilities/templates/advisory_detail.html +++ b/vulnerabilities/templates/advisory_detail.html @@ -776,6 +776,9 @@ Oldest version collected {% elif entry.diff %} {% with formatted_diff=entry.diff|format_diff_for_ui %} + {% if formatted_diff %} + Show more ▾
{% for field, change in formatted_diff.items %}
@@ -794,15 +797,13 @@
{% for item in change.added %}
- + Added {% if item.attributes %}{{ item.header }} with -{% for key, value in item.attributes %} {{ key }} {{ value }}{% if not forloop.last %} + {% if item.attributes %}{% for key, value in item.attributes %}{{ key }} {{ value }}{% if not forloop.last %} {% endif %}{% endfor %}{% else %}{{ item }}{% endif %}
{% endfor %} {% for item in change.removed %}
- − Removed {% if item.attributes %}{{ item.header }} with -{% for key, value in item.attributes %} {{ key }} {{ value }}{% if not forloop.last %} + {% if item.attributes %}{% for key, value in item.attributes %}{{ key }} {{ value }}{% if not forloop.last %} {% endif %}{% endfor %}{% else %}{{ item }}{% endif %}
{% endfor %} @@ -811,8 +812,9 @@
{% endfor %}
- Show more ▾ + {% else %} + No changes detected in this snapshot. + {% endif %} {% endwith %} {% endif %} @@ -873,7 +875,7 @@ function checkDiffHeights() { document.querySelectorAll('.history-diff-collapsible').forEach(function (diffContainer) { - const toggleButton = diffContainer.nextElementSibling; + const toggleButton = diffContainer.previousElementSibling; if (!toggleButton || !toggleButton.classList.contains('diff-toggle-btn')) return; // Measure true height @@ -893,7 +895,7 @@ } function toggleDiff(toggleButton) { - const diffContainer = toggleButton.previousElementSibling; + const diffContainer = toggleButton.nextElementSibling; diffContainer.classList.toggle('is-expanded'); toggleButton.textContent = diffContainer.classList.contains('is-expanded') ? 'Show less \u25b4' diff --git a/vulnerabilities/templatetags/diff_advisory_history.py b/vulnerabilities/templatetags/diff_advisory_history.py index 7d7933a05..f2be18c1f 100644 --- a/vulnerabilities/templatetags/diff_advisory_history.py +++ b/vulnerabilities/templatetags/diff_advisory_history.py @@ -8,111 +8,94 @@ # from django import template -from packageurl import PackageURL + +from vulnerabilities.models import AdvisoryHistoryDiff register = template.Library() @register.filter -def format_diff_for_ui(changes) -> dict: - """ - Example: - { - 'affected_packages': { - 'added': [{'package': {'type': 'pypi', 'name': 'requests', 'version': '2.25.0'}, 'affected_version_range': '==2.25.0', 'fixed_version_range': '==2.25.1'}], - 'removed': [{'package': {'type': 'pypi', 'name': 'requests', 'version': '2.24.0'}, 'affected_version_range': '==2.24.0', 'fixed_version_range': '==2.24.1'}] - } - } - should result in: - { - 'Affected Packages': { - 'added': [{'header': 'Affected package', 'attributes': [('PURL', 'pkg:pypi/requests@2.25.0'), ('Affected version', '==2.25.0'), ('Fixed Version', '==2.25.1')] }], - 'removed': [{'header': 'Affected package', 'attributes': [('PURL', 'pkg:pypi/requests@2.24.0'), ('Affected version', '==2.24.0'), ('Fixed Version', '==2.24.1')] }] - } - } - """ +def format_diff_for_ui(diff: AdvisoryHistoryDiff) -> dict: formatted = {} - for field, change in changes.items(): - label = field.replace("_", " ").title() - - if "old" in change or "new" in change: - formatted[label] = change - continue - - formatted[label] = {"added": [], "removed": []} - - for change_type in ["added", "removed"]: - for item in change.get(change_type, []): - if field == "affected_packages": - attributes = [] - - if package := item.get("package"): - package_string = ( - str(PackageURL(**package)) - if isinstance(package, dict) - else str(package) - ) - attributes.append(("PURL", package_string)) - - if affected_version_range := item.get("affected_version_range"): - attributes.append(("Affected version", affected_version_range)) - - if fixed_version_range := item.get("fixed_version_range"): - attributes.append(("Fixed Version", fixed_version_range)) - - formatted[label][change_type].append( - {"header": "Affected package", "attributes": attributes} - ) - - elif field == "references": - attributes = [] - - if reference_url := item.get("url"): - attributes.append(("URL", reference_url)) - - if reference_id := item.get("reference_id"): - attributes.append(("ID", reference_id)) - - formatted[label][change_type].append( - {"header": "Reference", "attributes": attributes} - ) - - elif field == "severities": - attributes = [] - - if scoring_system := item.get("system") or item.get("scoring_system"): - scoring_system_string = str(scoring_system) - if "cvss" in scoring_system_string.lower(): - scoring_system_string = scoring_system_string.upper() - else: - scoring_system_string = scoring_system_string.replace("_", " ").title() - attributes.append(("System", scoring_system_string)) - - if severity_value := item.get("value"): - attributes.append(("Value", severity_value)) - - if scoring_elements := item.get("scoring_elements"): - attributes.append(("Elements", scoring_elements)) - - formatted[label][change_type].append( - {"header": "Severity", "attributes": attributes} - ) - - elif field == "patches": - attributes = [] - - if patch_url := (item.get("url") or item.get("repository")): - attributes.append(("URL", patch_url)) - - if patch_commit := item.get("commit"): - attributes.append(("Commit", patch_commit)) - - formatted[label][change_type].append( - {"header": "Patch", "attributes": attributes} - ) - - else: - formatted[label][change_type].append(item) + if diff.summary_added or diff.summary_removed: + formatted["Summary"] = {"old": diff.summary_removed, "new": diff.summary_added} + + if diff.url_added or diff.url_removed: + formatted["Url"] = {"old": diff.url_removed, "new": diff.url_added} + + def _process_added_removed(label, added_qs, removed_qs, formatter): + added = [formatter(item) for item in added_qs] + removed = [formatter(item) for item in removed_qs] + if added or removed: + formatted[label] = {"added": added, "removed": removed} + + def format_severity(severity): + scoring_system_string = str(severity.scoring_system) + if "cvss" in scoring_system_string.lower(): + scoring_system_string = scoring_system_string.upper() + else: + scoring_system_string = scoring_system_string.replace("_", " ").title() + + attrs = [("System", scoring_system_string)] + if severity.value: + attrs.append(("Value", severity.value)) + if severity.scoring_elements: + attrs.append(("Elements", severity.scoring_elements)) + return {"header": "Severity", "attributes": attrs} + + _process_added_removed( + "Severities", diff.added_severities.all(), diff.removed_severities.all(), format_severity + ) + + def format_package(package): + attrs = [("PURL", package.base_purl)] + if package.affecting_vers: + attrs.append(("Affected version", package.affecting_vers)) + if package.fixed_vers: + attrs.append(("Fixed Version", package.fixed_vers)) + return {"header": "Affected package", "attributes": attrs} + + _process_added_removed( + "Affected Packages", + diff.added_impacted_packages.all(), + diff.removed_impacted_packages.all(), + format_package, + ) + + def format_reference(reference): + attrs = [] + if reference.url: + attrs.append(("URL", reference.url)) + if reference.reference_id: + attrs.append(("ID", reference.reference_id)) + return {"header": "Reference", "attributes": attrs} + + _process_added_removed( + "References", diff.added_references.all(), diff.removed_references.all(), format_reference + ) + + def format_patch(patch): + attrs = [] + if patch.url or patch.repository: + attrs.append(("URL", patch.url or patch.repository)) + if patch.commit: + attrs.append(("Commit", patch.commit)) + return {"header": "Patch", "attributes": attrs} + + _process_added_removed( + "Patches", diff.added_patches.all(), diff.removed_patches.all(), format_patch + ) + + _process_added_removed( + "Weaknesses", + diff.added_weaknesses.all(), + diff.removed_weaknesses.all(), + lambda weakness: weakness.cwe_id, + ) + + _process_added_removed( + "Aliases", diff.added_aliases.all(), diff.removed_aliases.all(), lambda alias: alias.alias + ) return formatted diff --git a/vulnerabilities/utils.py b/vulnerabilities/utils.py index fd3476ece..7db54460e 100644 --- a/vulnerabilities/utils.py +++ b/vulnerabilities/utils.py @@ -691,36 +691,6 @@ def compute_content_id(advisory_data): return content_id -def get_normalized_advisory_v2(advisory_data): - """ - Return a normalized dictionary of advisory data for diffing history. - - :param advisory_data: An AdvisoryV2 object - :return: A normalized dictionary of advisory data - """ - from vulnerabilities.models import AdvisoryV2 - - if not isinstance(advisory_data, AdvisoryV2): - raise ValueError("Unsupported advisory data type for history diffing") - - advisory_data = advisory_data.to_advisory_data() - - normalized_data = { - "advisory_id": advisory_data.advisory_id, - "aliases": advisory_data.aliases, - "summary": advisory_data.summary, - "affected_packages": [pkg.to_dict() for pkg in advisory_data.affected_packages if pkg], - "references": [ref.to_dict() for ref in advisory_data.references if ref], - "severities": [sev.to_dict() for sev in advisory_data.severities if sev], - "weaknesses": advisory_data.weaknesses, - "patches": [patch.to_dict() for patch in advisory_data.patches], - "url": advisory_data.url, - # "date_published": str(advisory_data.date_published) if advisory_data.date_published else None, - } - - return normalized_data - - def compute_content_id_v2(advisory_data): """ Compute a unique content_id for an advisory by normalizing its data and hashing it. @@ -1161,70 +1131,83 @@ def safe_altcha_redirect(next_url: str) -> redirect: return redirect("/") -def exclude_epss_severities(severities: list) -> list: +def compute_advisory_v2_diff(old_snapshot, new_snapshot): """ - Return a copy of the severities list with all EPSS entries removed. - EPSS scores are updated daily and can be tracked separately in EPSS tab. + Compare two advisory snapshots and compute the diff. + Args: + old_snapshot: The old advisory snapshot. + new_snapshot: The new advisory snapshot. """ - return [ - severity - for severity in (severities or []) - if str(severity.get("system", "")).lower() != "epss" - ] + from vulnerabilities.models import AdvisoryHistoryDiff + diff = AdvisoryHistoryDiff(advisory_after=new_snapshot, advisory_before=old_snapshot) -def diff_advisories_v2(old_data: dict, new_data: dict) -> dict: - """ - Diff two normalised advisories and return the changes. - """ - changes = {} - all_keys = set(old_data.keys()).union(new_data.keys()) + if old_snapshot.summary != new_snapshot.summary: + diff.summary_removed = old_snapshot.summary + diff.summary_added = new_snapshot.summary - for field in all_keys: - old_field_value = old_data.get(field) - new_field_value = new_data.get(field) + if old_snapshot.url != new_snapshot.url: + diff.url_removed = old_snapshot.url + diff.url_added = new_snapshot.url - if isinstance(old_field_value, list) or isinstance(new_field_value, list): - old_items_list = old_field_value or [] - new_items_list = new_field_value or [] + diff.save() - if field == "severities": - old_items_list = exclude_epss_severities(old_items_list) - new_items_list = exclude_epss_severities(new_items_list) + def _compute_diff_helper(old_queryset, new_queryset, added_manager, removed_manager): + old_ids = set(old_queryset.values_list("pk", flat=True)) + new_ids = set(new_queryset.values_list("pk", flat=True)) + if added_ids := new_ids - old_ids: + added_manager.set(added_ids) + if removed_ids := old_ids - new_ids: + removed_manager.set(removed_ids) - if all(isinstance(item, str) for item in old_items_list + new_items_list): - # "aliases", "weaknesses" - old_items_set = set(old_items_list) - new_items_set = set(new_items_list) - if old_items_set != new_items_set: - changes[field] = { - "added": sorted(new_items_set - old_items_set), - "removed": sorted(old_items_set - new_items_set), - } - elif all(isinstance(item, dict) for item in old_items_list + new_items_list): - # "references", "affected_packages", "patches", "severities" - old_items_set = { - json.dumps(item, sort_keys=True, default=str) for item in old_items_list - } - new_items_set = { - json.dumps(item, sort_keys=True, default=str) for item in new_items_list - } - if old_items_set != new_items_set: - changes[field] = { - "added": [ - json.loads(item) for item in sorted(new_items_set - old_items_set) - ], - "removed": [ - json.loads(item) for item in sorted(old_items_set - new_items_set) - ], - } - elif ( - isinstance(old_field_value, str) - or isinstance(new_field_value, str) - or (old_field_value is None and new_field_value is None) - ): - # "url", "date_published", "summary" - if old_field_value != new_field_value: - changes[field] = {"old": old_field_value, "new": new_field_value} - - return changes + _compute_diff_helper( + old_snapshot.severities, + new_snapshot.severities, + diff.added_severities, + diff.removed_severities, + ) + _compute_diff_helper( + old_snapshot.references, + new_snapshot.references, + diff.added_references, + diff.removed_references, + ) + _compute_diff_helper( + old_snapshot.aliases, new_snapshot.aliases, diff.added_aliases, diff.removed_aliases + ) + _compute_diff_helper( + old_snapshot.weaknesses, + new_snapshot.weaknesses, + diff.added_weaknesses, + diff.removed_weaknesses, + ) + _compute_diff_helper( + old_snapshot.patches, new_snapshot.patches, diff.added_patches, diff.removed_patches + ) + + def _get_impacted_package(impacted_package) -> tuple: + """ + Return the tuple representation of an impacted package. + For eg: ("pkg:pypi/django", "<1.0", "5.0.1") + """ + return ( + impacted_package.base_purl, + impacted_package.affecting_vers, + impacted_package.fixed_vers, + ) + + old_packages_map = { + _get_impacted_package(pkg): pkg.pk for pkg in old_snapshot.impacted_packages.all() + } + new_packages_map = { + _get_impacted_package(pkg): pkg.pk for pkg in new_snapshot.impacted_packages.all() + } + + old_tuples = set(old_packages_map.keys()) + new_tuples = set(new_packages_map.keys()) + + if added_tuples := new_tuples - old_tuples: + diff.added_impacted_packages.set([new_packages_map[tup] for tup in added_tuples]) + + if removed_tuples := old_tuples - new_tuples: + diff.removed_impacted_packages.set([old_packages_map[tup] for tup in removed_tuples]) diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 8c74c9c15..256347c5c 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -63,9 +63,7 @@ from vulnerabilities.tasks import compute_queue_load_factor from vulnerabilities.throttling import AnonUserUIThrottle from vulnerabilities.utils import TYPES_WITH_MULTIPLE_IMPORTERS -from vulnerabilities.utils import diff_advisories_v2 from vulnerabilities.utils import get_advisories_from_groups -from vulnerabilities.utils import get_normalized_advisory_v2 from vulnerabilities.utils import safe_altcha_redirect from vulnerablecode import __version__ as VULNERABLECODE_VERSION from vulnerablecode.settings import ALTCHA_SESSION_TIMEOUT @@ -587,55 +585,47 @@ def build_advisory_history(advisory, page_number): """ Build advisory history for a given advisory. """ - advisory_history_qs = ( - models.AdvisoryV2.objects.filter(avid=advisory.avid) - .order_by("-date_collected", "-unique_content_id") - .prefetch_related( - "aliases", "references", "weaknesses", "severities", "impacted_packages", "patches" - ) + advisory_history_qs = models.AdvisoryV2.objects.filter(avid=advisory.avid).order_by( + "-date_collected", "-unique_content_id" ) paginator = Paginator(advisory_history_qs, 10) pagination_obj = paginator.get_page(page_number) advisories_on_page = list(pagination_obj.object_list) - last_advisory_fallback = None - if pagination_obj.has_next(): - # Fetch the very first record from the next page to calculate the diff for the last item on the current page - last_advisory_fallback = advisory_history_qs.filter( - date_collected__lt=advisories_on_page[-1].date_collected - ).first() - - normalized_advisories = { - adv.unique_content_id: get_normalized_advisory_v2(adv) for adv in advisories_on_page - } - - # Normalize the last advisory fallback - if last_advisory_fallback: - normalized_advisories[last_advisory_fallback.unique_content_id] = ( - get_normalized_advisory_v2(last_advisory_fallback) + advisory_ids = [advisory.pk for advisory in advisories_on_page] + advisory_to_diff = { + diff.advisory_after_id: diff + for diff in models.AdvisoryHistoryDiff.objects.filter( + advisory_after_id__in=advisory_ids + ).prefetch_related( + "added_severities", + "removed_severities", + "added_impacted_packages", + "removed_impacted_packages", + "added_references", + "removed_references", + "added_patches", + "removed_patches", + "added_weaknesses", + "removed_weaknesses", + "added_aliases", + "removed_aliases", ) + } advisory_history = [] for current_index, current_advisory in enumerate(advisories_on_page): - # Diff w.r.t immediately older advisory - if current_index + 1 < len(advisories_on_page): - older_advisory = advisories_on_page[current_index + 1] - else: - older_advisory = last_advisory_fallback - - current_data = normalized_advisories[current_advisory.unique_content_id] - older_data = ( - normalized_advisories[older_advisory.unique_content_id] if older_advisory else None - ) + is_initial = not pagination_obj.has_next() and current_index == len(advisories_on_page) - 1 + history_diff = advisory_to_diff.get(current_advisory.pk) advisory_history.append( { "date_collected": current_advisory.date_collected, "unique_content_id": current_advisory.unique_content_id, "is_latest": current_advisory.is_latest, - "is_initial": older_advisory is None, - "diff": diff_advisories_v2(older_data, current_data) if older_data else {}, + "is_initial": is_initial, + "diff": history_diff, } ) @@ -1100,7 +1090,6 @@ def get_queryset(self): from vulnerabilities.models import ImpactedPackage from vulnerabilities.models import PackageV2 - return AdvisoryV2.objects.all().prefetch_related( Prefetch( "impacted_packages", diff --git a/vulnerablecode/urls.py b/vulnerablecode/urls.py index 6acce4119..a4b1eb306 100644 --- a/vulnerablecode/urls.py +++ b/vulnerablecode/urls.py @@ -28,8 +28,8 @@ from vulnerabilities.views import AdvisoryPackageCurationView from vulnerabilities.views import AdvisoryPackagesDetails from vulnerabilities.views import AdvisoryPackagesSnapshotView -from vulnerabilities.views import AdvisorySnapshotView from vulnerabilities.views import AdvisorySeverityCurationView +from vulnerabilities.views import AdvisorySnapshotView from vulnerabilities.views import AdvisoryToDoListView from vulnerabilities.views import AdvisoryWeaknessCurationView from vulnerabilities.views import AffectedByAdvisoriesListView From 5b189f9f6a5377e3762ea5476f19a797f8c2fcb4 Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Sun, 26 Jul 2026 05:11:28 +0530 Subject: [PATCH 4/5] Update tests Signed-off-by: Sampurna Pyne --- .../tests/test_advisory_history.py | 116 +++-- .../expected_diff_GHSA-6rw7-vpxm-498p.json | 46 -- ...ed_formatted_diff_GHSA-6rw7-vpxm-498p.json | 110 ---- ...ormalised_history_GHSA-6rw7-vpxm-498p.json | 206 -------- .../expected_diff_GHSA-72hv-8253-57qq.json | 154 ------ ...ormalised_history_GHSA-72hv-8253-57qq.json | 484 ------------------ 6 files changed, 74 insertions(+), 1042 deletions(-) delete mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json delete mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json delete mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json delete mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json delete mode 100644 vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json diff --git a/vulnerabilities/tests/test_advisory_history.py b/vulnerabilities/tests/test_advisory_history.py index 212db4b41..85d2ebcec 100644 --- a/vulnerabilities/tests/test_advisory_history.py +++ b/vulnerabilities/tests/test_advisory_history.py @@ -1,59 +1,91 @@ -import json -import os +from datetime import timedelta -from vulnerabilities.templatetags.diff_advisory_history import format_diff_for_ui -from vulnerabilities.tests.util_tests import check_results_against_json +import pytest +from django.utils import timezone +from vulnerabilities.models import AdvisoryHistoryDiff +from vulnerabilities.models import AdvisorySeverity +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.models import ImpactedPackage +from vulnerabilities.pipelines.v2_improvers.history_diff_backfill import HistoryDiffImproverPipeline + +@pytest.mark.django_db def test_advisory_history_diffing(): """ Test the diffing logic for historical advisory snapshots. """ - from vulnerabilities.utils import diff_advisories_v2 - - base_dir = os.path.join(os.path.dirname(__file__), "test_data", "advisory_history") - - # Test GHSA-72hv-8253-57qq (Sample 1) - # See: https://github.com/github/advisory-database/commits/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json - sample1_dir = os.path.join(base_dir, "GHSA-72hv-8253-57qq") - with open(os.path.join(sample1_dir, "normalised_history_GHSA-72hv-8253-57qq.json"), "r") as f: - sample1_data = json.load(f) + avid1 = "github_osv/GHSA-72hv-8253-57qq" - sample1_diffs = [ - diff_advisories_v2(sample1_data[i], sample1_data[i + 1]) - for i in range(len(sample1_data) - 1) - ] + severity_high = AdvisorySeverity.objects.create(scoring_system="generic_textual", value="HIGH") + severity_moderate = AdvisorySeverity.objects.create( + scoring_system="generic_textual", value="MODERATE" + ) - sample1_expected_file = os.path.join(sample1_dir, "expected_diff_GHSA-72hv-8253-57qq.json") - check_results_against_json(sample1_diffs, sample1_expected_file) + old_advisory_snapshot = AdvisoryV2.objects.create( + advisory_id="GHSA-72hv-8253-57qq", + unique_content_id="snap1hash", + date_collected=timezone.now(), + pipeline_id="test", + datasource_id="github_osv", + url="http://test.com", + avid=avid1, + summary="Initial summary", + ) + old_advisory_snapshot.severities.add(severity_high) - # Test GHSA-6rw7-vpxm-498p (Sample 2) - # See: https://github.com/github/advisory-database/commits/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json - sample2_dir = os.path.join(base_dir, "GHSA-6rw7-vpxm-498p") - with open(os.path.join(sample2_dir, "normalised_history_GHSA-6rw7-vpxm-498p.json"), "r") as f: - sample2_data = json.load(f) + new_advisory_snapshot = AdvisoryV2.objects.create( + advisory_id="GHSA-72hv-8253-57qq", + unique_content_id="snap2hash", + date_collected=timezone.now() + timedelta(days=1), + pipeline_id="test", + datasource_id="github_osv", + url="http://test.com", + avid=avid1, + summary="Updated summary", + ) + new_advisory_snapshot.severities.add(severity_moderate) - sample2_diffs = [ - diff_advisories_v2(sample2_data[i], sample2_data[i + 1]) - for i in range(len(sample2_data) - 1) - ] + pkg1 = ImpactedPackage.objects.create( + advisory=old_advisory_snapshot, + base_purl="pkg:maven/tools.jackson.core/jackson-core", + affecting_vers="vers:maven/<=2.18.5", + fixed_vers="vers:maven/2.18.6", + ) + pkg2 = ImpactedPackage.objects.create( + advisory=old_advisory_snapshot, + base_purl="pkg:maven/tools.jackson.core/jackson-core", + affecting_vers="vers:maven/>=2.19.0|<2.21.1", + fixed_vers="vers:maven/2.21.1", + ) + pkg3 = ImpactedPackage.objects.create( + advisory=old_advisory_snapshot, + base_purl="pkg:maven/com.fasterxml.jackson.core/jackson-core", + affecting_vers="vers:maven/>=3.0.0|<3.1.0", + fixed_vers="vers:maven/3.1.0", + ) - sample2_expected_file = os.path.join(sample2_dir, "expected_diff_GHSA-6rw7-vpxm-498p.json") - check_results_against_json(sample2_diffs, sample2_expected_file) + pipeline = HistoryDiffImproverPipeline() + pipeline.execute() + # Run a second time to ensure idempotency + pipeline.execute() -def test_format_diff_for_ui(): - """ - Test the template tag logic that formats diffs for the UI. - """ - base_dir = os.path.join(os.path.dirname(__file__), "test_data", "advisory_history") - sample2_dir = os.path.join(base_dir, "GHSA-6rw7-vpxm-498p") - input_file = os.path.join(sample2_dir, "expected_diff_GHSA-6rw7-vpxm-498p.json") + old_advisory_snapshot.refresh_from_db() + new_advisory_snapshot.refresh_from_db() - with open(input_file, "r") as f: - input_diffs = json.load(f) + assert hasattr(old_advisory_snapshot, "history_diff") + assert old_advisory_snapshot.history_diff.advisory_before is None - formatted_diffs = [format_diff_for_ui(diff) for diff in input_diffs] + diff = AdvisoryHistoryDiff.objects.get(advisory_after=new_advisory_snapshot) + assert diff.summary_removed == "Initial summary" + assert diff.summary_added == "Updated summary" + assert severity_moderate in diff.added_severities.all() + assert severity_high in diff.removed_severities.all() - expected_file = os.path.join(sample2_dir, "expected_formatted_diff_GHSA-6rw7-vpxm-498p.json") - check_results_against_json(formatted_diffs, expected_file) + assert diff.added_impacted_packages.count() == 0 + assert diff.removed_impacted_packages.count() == 3 + assert pkg1 in diff.removed_impacted_packages.all() + assert pkg2 in diff.removed_impacted_packages.all() + assert pkg3 in diff.removed_impacted_packages.all() + assert AdvisoryHistoryDiff.objects.count() == 2 diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json deleted file mode 100644 index a7265d0b5..000000000 --- a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_diff_GHSA-6rw7-vpxm-498p.json +++ /dev/null @@ -1,46 +0,0 @@ -[ - { - "summary": { - "old": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", - "new": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value." - } - }, - { - "severities": { - "added": [ - { - "scoring_elements": "", - "system": "generic_textual", - "value": "MODERATE" - }, - { - "scoring_elements": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L", - "system": "cvssv3.1", - "value": "3.7" - }, - { - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L", - "system": "cvssv4", - "value": "6.3" - } - ], - "removed": [ - { - "scoring_elements": "", - "system": "generic_textual", - "value": "HIGH" - }, - { - "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", - "system": "cvssv3.1", - "value": "7.5" - }, - { - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N", - "system": "cvssv4", - "value": "8.7" - } - ] - } - } -] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json deleted file mode 100644 index 645b9d308..000000000 --- a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/expected_formatted_diff_GHSA-6rw7-vpxm-498p.json +++ /dev/null @@ -1,110 +0,0 @@ -[ - { - "Summary": { - "old": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", - "new": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value." - } - }, - { - "Severities": { - "added": [ - { - "header": "Severity", - "attributes": [ - [ - "System", - "Generic Textual" - ], - [ - "Value", - "MODERATE" - ] - ] - }, - { - "header": "Severity", - "attributes": [ - [ - "System", - "CVSSV3.1" - ], - [ - "Value", - "3.7" - ], - [ - "Elements", - "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L" - ] - ] - }, - { - "header": "Severity", - "attributes": [ - [ - "System", - "CVSSV4" - ], - [ - "Value", - "6.3" - ], - [ - "Elements", - "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L" - ] - ] - } - ], - "removed": [ - { - "header": "Severity", - "attributes": [ - [ - "System", - "Generic Textual" - ], - [ - "Value", - "HIGH" - ] - ] - }, - { - "header": "Severity", - "attributes": [ - [ - "System", - "CVSSV3.1" - ], - [ - "Value", - "7.5" - ], - [ - "Elements", - "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" - ] - ] - }, - { - "header": "Severity", - "attributes": [ - [ - "System", - "CVSSV4" - ], - [ - "Value", - "8.7" - ], - [ - "Elements", - "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" - ] - ] - } - ] - } - } -] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json deleted file mode 100644 index 71c0281a3..000000000 --- a/vulnerabilities/tests/test_data/advisory_history/GHSA-6rw7-vpxm-498p/normalised_history_GHSA-6rw7-vpxm-498p.json +++ /dev/null @@ -1,206 +0,0 @@ -[ - { - "advisory_id": "GHSA-6rw7-vpxm-498p", - "aliases": [ - "CVE-2025-15284" - ], - "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs does not enforce limits for bracket notation (`a[]=1&a[]=2`), allowing attackers to cause denial-of-service via memory exhaustion. Applications using `arrayLimit` for DoS protection are vulnerable.\n\n### Details\n\nThe `arrayLimit` option only checks limits for indexed notation (`a[0]=1&a[1]=2`) but completely bypasses it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n**Test 1 - Basic bypass:**\n```bash\nnpm install qs\n```\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Test 2 - DoS demonstration:**\n```javascript\nconst qs = require('qs');\nconst attack = 'a[]=' + Array(10000).fill('x').join('&a[]=');\nconst result = qs.parse(attack, { arrayLimit: 100 });\nconsole.log(result.a.length); // Output: 10000 (should be max 100)\n```\n\n**Configuration:**\n- `arrayLimit: 5` (test 1) or `arrayLimit: 100` (test 2)\n- Use bracket notation: `a[]=value` (not indexed `a[0]=value`)\n\n### Impact\n\nDenial of Service via memory exhaustion. Affects applications using `qs.parse()` with user-controlled input and `arrayLimit` for protection.\n\n**Attack scenario:**\n1. Attacker sends HTTP request: `GET /api/search?filters[]=x&filters[]=x&...&filters[]=x` (100,000+ times)\n2. Application parses with `qs.parse(query, { arrayLimit: 100 })`\n3. qs ignores limit, parses all 100,000 elements into array\n4. Server memory exhausted \u2192 application crashes or becomes unresponsive\n5. Service unavailable for all users\n\n**Real-world impact:**\n- Single malicious request can crash server\n- No authentication required\n- Easy to automate and scale\n- Affects any endpoint parsing query strings with bracket notation\n\n### Suggested Fix\n\nAdd `arrayLimit` validation to the bracket notation handler. The code already calculates `currentArrayLength` at line 147-151, but it's not used in the bracket notation handler at line 159.\n\n**Current code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Fixed code**:\n```javascript\nif (root === '[]' && options.parseArrays) {\n // Use currentArrayLength already calculated at line 147-151\n if (options.throwOnLimitExceeded && currentArrayLength >= options.arrayLimit) {\n throw new RangeError('Array limit exceeded. Only ' + options.arrayLimit + ' element' + (options.arrayLimit === 1 ? '' : 's') + ' allowed in an array.');\n }\n \n // If limit exceeded and not throwing, convert to object (consistent with indexed notation behavior)\n if (currentArrayLength >= options.arrayLimit) {\n obj = options.plainObjects ? { __proto__: null } : {};\n obj[currentArrayLength] = leaf;\n } else {\n obj = options.allowEmptyArrays && (leaf === '' || (options.strictNullHandling && leaf === null))\n ? []\n : utils.combine([], leaf);\n }\n}\n```\n\nThis makes bracket notation behaviour consistent with indexed notation, enforcing `arrayLimit` and converting to object when limit is exceeded (per README documentation).", - "affected_packages": [ - { - "package": { - "type": "npm", - "namespace": "", - "name": "qs", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:npm/<6.14.1", - "fixed_version_range": "vers:npm/6.14.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - } - ], - "references": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" - } - ], - "severities": [ - { - "system": "cvssv3.1", - "value": "7.5", - "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" - }, - { - "system": "cvssv4", - "value": "8.7", - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" - }, - { - "system": "generic_textual", - "value": "HIGH", - "scoring_elements": "" - } - ], - "weaknesses": [ - 20 - ], - "patches": [], - "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", - "date_published": "2025-12-30 21:02:54+00:00" - }, - { - "advisory_id": "GHSA-6rw7-vpxm-498p", - "aliases": [ - "CVE-2025-15284" - ], - "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value.", - "affected_packages": [ - { - "package": { - "type": "npm", - "namespace": "", - "name": "qs", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:npm/<6.14.1", - "fixed_version_range": "vers:npm/6.14.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - } - ], - "references": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" - } - ], - "severities": [ - { - "system": "cvssv3.1", - "value": "7.5", - "scoring_elements": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" - }, - { - "system": "cvssv4", - "value": "8.7", - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" - }, - { - "system": "generic_textual", - "value": "HIGH", - "scoring_elements": "" - } - ], - "weaknesses": [ - 20 - ], - "patches": [], - "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", - "date_published": "2025-12-30 21:02:54+00:00" - }, - { - "advisory_id": "GHSA-6rw7-vpxm-498p", - "aliases": [ - "CVE-2025-15284" - ], - "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion\n### Summary\n\nThe `arrayLimit` option in qs did not enforce limits for bracket notation (`a[]=1&a[]=2`), only for indexed notation (`a[0]=1`). This is a consistency bug; `arrayLimit` should apply uniformly across all array notations.\n\n**Note:** The default `parameterLimit` of 1000 effectively mitigates the DoS scenario originally described. With default options, bracket notation cannot produce arrays larger than `parameterLimit` regardless of `arrayLimit`, because each `a[]=value` consumes one parameter slot. The severity has been reduced accordingly.\n\n### Details\n\nThe `arrayLimit` option only checked limits for indexed notation (`a[0]=1&a[1]=2`) but did not enforce it for bracket notation (`a[]=1&a[]=2`).\n\n**Vulnerable code** (`lib/parse.js:159-162`):\n```javascript\nif (root === '[]' && options.parseArrays) {\n obj = utils.combine([], leaf); // No arrayLimit check\n}\n```\n\n**Working code** (`lib/parse.js:175`):\n```javascript\nelse if (index <= options.arrayLimit) { // Limit checked here\n obj = [];\n obj[index] = leaf;\n}\n```\n\nThe bracket notation handler at line 159 uses `utils.combine([], leaf)` without validating against `options.arrayLimit`, while indexed notation at line 175 checks `index <= options.arrayLimit` before creating arrays.\n\n### PoC\n\n```javascript\nconst qs = require('qs');\nconst result = qs.parse('a[]=1&a[]=2&a[]=3&a[]=4&a[]=5&a[]=6', { arrayLimit: 5 });\nconsole.log(result.a.length); // Output: 6 (should be max 5)\n```\n\n**Note on parameterLimit interaction:** The original advisory's \"DoS demonstration\" claimed a length of 10,000, but `parameterLimit` (default: 1000) caps parsing to 1,000 parameters. With default options, the actual output is 1,000, not 10,000.\n\n### Impact\n\nConsistency bug in `arrayLimit` enforcement. With default `parameterLimit`, the practical DoS risk is negligible since `parameterLimit` already caps the total number of parsed parameters (and thus array elements from bracket notation). The risk increases only when `parameterLimit` is explicitly set to a very high value.", - "affected_packages": [ - { - "package": { - "type": "npm", - "namespace": "", - "name": "qs", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:npm/<6.14.1", - "fixed_version_range": "vers:npm/6.14.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - } - ], - "references": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs/commit/3086902ecf7f088d0d1803887643ac6c03d415b9" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/ljharb/qs/security/advisories/GHSA-6rw7-vpxm-498p" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-15284" - } - ], - "severities": [ - { - "system": "cvssv3.1", - "value": "3.7", - "scoring_elements": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L" - }, - { - "system": "cvssv4", - "value": "6.3", - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L" - }, - { - "system": "generic_textual", - "value": "MODERATE", - "scoring_elements": "" - } - ], - "weaknesses": [ - 20 - ], - "patches": [], - "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2025/12/GHSA-6rw7-vpxm-498p/GHSA-6rw7-vpxm-498p.json", - "date_published": "2025-12-30 21:02:54+00:00" - } -] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json deleted file mode 100644 index 2e4141659..000000000 --- a/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/expected_diff_GHSA-72hv-8253-57qq.json +++ /dev/null @@ -1,154 +0,0 @@ -[ - { - "references": { - "added": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/pull/1555" - } - ], - "removed": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/commit/a004e9789c2cc6b41b379d02d229d58474d9a738" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/issues/1538" - } - ] - } - }, - { - "affected_packages": { - "added": [], - "removed": [ - { - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_by_commit_patches": [], - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "package": { - "name": "jackson-core", - "namespace": "tools.jackson.core", - "qualifiers": "", - "subpath": "", - "type": "maven", - "version": "" - } - }, - { - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_by_commit_patches": [], - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "package": { - "name": "jackson-core", - "namespace": "tools.jackson.core", - "qualifiers": "", - "subpath": "", - "type": "maven", - "version": "" - } - }, - { - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_by_commit_patches": [], - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "package": { - "name": "jackson-core", - "namespace": "com.fasterxml.jackson.core", - "qualifiers": "", - "subpath": "", - "type": "maven", - "version": "" - } - } - ] - }, - "severities": { - "added": [ - { - "scoring_elements": "", - "system": "generic_textual", - "value": "MODERATE" - }, - { - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N", - "system": "cvssv4", - "value": "6.9" - } - ], - "removed": [ - { - "scoring_elements": "", - "system": "generic_textual", - "value": "HIGH" - }, - { - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N", - "system": "cvssv4", - "value": "8.7" - } - ] - } - }, - { - "affected_packages": { - "added": [ - { - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_by_commit_patches": [], - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "package": { - "name": "jackson-core", - "namespace": "tools.jackson.core", - "qualifiers": "", - "subpath": "", - "type": "maven", - "version": "" - } - }, - { - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_by_commit_patches": [], - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "package": { - "name": "jackson-core", - "namespace": "tools.jackson.core", - "qualifiers": "", - "subpath": "", - "type": "maven", - "version": "" - } - }, - { - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_by_commit_patches": [], - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "package": { - "name": "jackson-core", - "namespace": "com.fasterxml.jackson.core", - "qualifiers": "", - "subpath": "", - "type": "maven", - "version": "" - } - } - ], - "removed": [] - } - } -] \ No newline at end of file diff --git a/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json b/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json deleted file mode 100644 index 7cbd111e3..000000000 --- a/vulnerabilities/tests/test_data/advisory_history/GHSA-72hv-8253-57qq/normalised_history_GHSA-72hv-8253-57qq.json +++ /dev/null @@ -1,484 +0,0 @@ -[ - { - "advisory_id": "GHSA-72hv-8253-57qq", - "aliases": [], - "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", - "affected_packages": [ - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - } - ], - "references": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/commit/a004e9789c2cc6b41b379d02d229d58474d9a738" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/issues/1538" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" - } - ], - "severities": [ - { - "system": "cvssv4", - "value": "8.7", - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" - }, - { - "system": "generic_textual", - "value": "HIGH", - "scoring_elements": "" - } - ], - "weaknesses": [ - 770 - ], - "patches": [], - "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", - "date_published": "2026-02-28 02:01:05+00:00" - }, - { - "advisory_id": "GHSA-72hv-8253-57qq", - "aliases": [], - "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", - "affected_packages": [ - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - } - ], - "references": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/pull/1555" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" - } - ], - "severities": [ - { - "system": "cvssv4", - "value": "8.7", - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N" - }, - { - "system": "generic_textual", - "value": "HIGH", - "scoring_elements": "" - } - ], - "weaknesses": [ - 770 - ], - "patches": [], - "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", - "date_published": "2026-02-28 02:01:05+00:00" - }, - { - "advisory_id": "GHSA-72hv-8253-57qq", - "aliases": [], - "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", - "affected_packages": [ - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - } - ], - "references": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/pull/1555" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" - } - ], - "severities": [ - { - "system": "cvssv4", - "value": "6.9", - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" - }, - { - "system": "generic_textual", - "value": "MODERATE", - "scoring_elements": "" - } - ], - "weaknesses": [ - 770 - ], - "patches": [], - "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", - "date_published": "2026-02-28 02:01:05+00:00" - }, - { - "advisory_id": "GHSA-72hv-8253-57qq", - "aliases": [], - "summary": "jackson-core: Number Length Constraint Bypass in Async Parser Leads to Potential DoS Condition\n### Summary\nThe non-blocking (async) JSON parser in `jackson-core` bypasses the `maxNumberLength` constraint (default: 1000 characters) defined in `StreamReadConstraints`. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).\n\nThe standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.\n\n### Details\nThe root cause is that the async parsing path in `NonBlockingUtf8JsonParserBase` (and related classes) does not call the methods responsible for number length validation.\n\n- The number parsing methods (e.g., `_finishNumberIntegralPart`) accumulate digits into the `TextBuffer` without any length checks.\n- After parsing, they call `_valueComplete()`, which finalizes the token but does **not** call `resetInt()` or `resetFloat()`.\n- The `resetInt()`/`resetFloat()` methods in `ParserBase` are where the `validateIntegerLength()` and `validateFPLength()` checks are performed.\n- Because this validation step is skipped, the `maxNumberLength` constraint is never enforced in the async code path.\n\n### PoC\nThe following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.\n\n```java\npackage tools.jackson.core.unittest.dos;\n\nimport java.nio.charset.StandardCharsets;\n\nimport org.junit.jupiter.api.Test;\n\nimport tools.jackson.core.*;\nimport tools.jackson.core.exc.StreamConstraintsException;\nimport tools.jackson.core.json.JsonFactory;\nimport tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\n/**\n * POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers\n *\n * Authors: sprabhav7, rohan-repos\n * \n * maxNumberLength default = 1000 characters (digits).\n * A number with more than 1000 digits should be rejected by any parser.\n *\n * BUG: The async parser never calls resetInt()/resetFloat() which is where\n * validateIntegerLength()/validateFPLength() lives. Instead it calls\n * _valueComplete() which skips all number length validation.\n *\n * CWE-770: Allocation of Resources Without Limits or Throttling\n */\nclass AsyncParserNumberLengthBypassTest {\n\n private static final int MAX_NUMBER_LENGTH = 1000;\n private static final int TEST_NUMBER_LENGTH = 5000;\n\n private final JsonFactory factory = new JsonFactory();\n\n // CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength\n @Test\n void syncParserRejectsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\t\t\n\t\t// Output to console\n System.out.println(\"[SYNC] Parsing \" + TEST_NUMBER_LENGTH + \"-digit number (limit: \" + MAX_NUMBER_LENGTH + \")\");\n try {\n try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n System.out.println(\"[SYNC] Accepted number with \" + p.getText().length() + \" digits \u2014 UNEXPECTED\");\n }\n }\n }\n fail(\"Sync parser must reject a \" + TEST_NUMBER_LENGTH + \"-digit number\");\n } catch (StreamConstraintsException e) {\n System.out.println(\"[SYNC] Rejected with StreamConstraintsException: \" + e.getMessage());\n }\n }\n\n // VULNERABILITY: Async parser accepts the SAME number that sync rejects\n @Test\n void asyncParserAcceptsLongNumber() throws Exception {\n byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);\n\n NonBlockingByteArrayJsonParser p =\n (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty());\n p.feedInput(payload, 0, payload.length);\n p.endOfInput();\n\n boolean foundNumber = false;\n try {\n while (p.nextToken() != null) {\n if (p.currentToken() == JsonToken.VALUE_NUMBER_INT) {\n foundNumber = true;\n String numberText = p.getText();\n assertEquals(TEST_NUMBER_LENGTH, numberText.length(),\n \"Async parser silently accepted all \" + TEST_NUMBER_LENGTH + \" digits\");\n }\n }\n // Output to console\n System.out.println(\"[ASYNC INT] Accepted number with \" + TEST_NUMBER_LENGTH + \" digits \u2014 BUG CONFIRMED\");\n assertTrue(foundNumber, \"Parser should have produced a VALUE_NUMBER_INT token\");\n } catch (StreamConstraintsException e) {\n fail(\"Bug is fixed \u2014 async parser now correctly rejects long numbers: \" + e.getMessage());\n }\n p.close();\n }\n\n private byte[] buildPayloadWithLongInteger(int numDigits) {\n StringBuilder sb = new StringBuilder(numDigits + 10);\n sb.append(\"{\\\"v\\\":\");\n for (int i = 0; i < numDigits; i++) {\n sb.append((char) ('1' + (i % 9)));\n }\n sb.append('}');\n return sb.toString().getBytes(StandardCharsets.UTF_8);\n }\n}\n\n```\n\n\n### Impact\nA malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause:\n1. **Memory Exhaustion:** Unbounded allocation of memory in the `TextBuffer` to store the number's digits, leading to an `OutOfMemoryError`.\n2. **CPU Exhaustion:** If the application subsequently calls `getBigIntegerValue()` or `getDecimalValue()`, the JVM can be tied up in O(n^2) `BigInteger` parsing operations, leading to a CPU-based DoS.\n\n### Suggested Remediation\n\nThe async parsing path should be updated to respect the `maxNumberLength` constraint. The simplest fix appears to ensure that `_valueComplete()` or a similar method in the async path calls the appropriate validation methods (`resetInt()` or `resetFloat()`) already present in `ParserBase`, mirroring the behavior of the synchronous parsers.\n\n**NOTE:** This research was performed in collaboration with [rohan-repos](https://github.com/rohan-repos)", - "affected_packages": [ - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "com.fasterxml.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/<=2.18.5", - "fixed_version_range": "vers:maven/2.18.6", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=2.19.0|<2.21.1", - "fixed_version_range": "vers:maven/2.21.1", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - }, - { - "package": { - "type": "maven", - "namespace": "tools.jackson.core", - "name": "jackson-core", - "version": "", - "qualifiers": "", - "subpath": "" - }, - "affected_version_range": "vers:maven/>=3.0.0|<3.1.0", - "fixed_version_range": "vers:maven/3.1.0", - "introduced_by_commit_patches": [], - "fixed_by_commit_patches": [] - } - ], - "references": [ - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/commit/b0c428e6f993e1b5ece5c1c3cb2523e887cd52cf" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/pull/1555" - }, - { - "reference_id": "", - "reference_type": "", - "url": "https://github.com/FasterXML/jackson-core/security/advisories/GHSA-72hv-8253-57qq" - } - ], - "severities": [ - { - "system": "cvssv4", - "value": "6.9", - "scoring_elements": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N" - }, - { - "system": "generic_textual", - "value": "MODERATE", - "scoring_elements": "" - } - ], - "weaknesses": [ - 770 - ], - "patches": [], - "url": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-72hv-8253-57qq/GHSA-72hv-8253-57qq.json", - "date_published": "2026-02-28 02:01:05+00:00" - } -] \ No newline at end of file From 0c56a02e52a76d6435f84a8b7720564ca0fed7db Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Sun, 26 Jul 2026 06:07:28 +0530 Subject: [PATCH 5/5] Fix migration Signed-off-by: Sampurna Pyne --- .../migrations/0142_merge_20260726_0000.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 vulnerabilities/migrations/0142_merge_20260726_0000.py diff --git a/vulnerabilities/migrations/0142_merge_20260726_0000.py b/vulnerabilities/migrations/0142_merge_20260726_0000.py new file mode 100644 index 000000000..34e4525ca --- /dev/null +++ b/vulnerabilities/migrations/0142_merge_20260726_0000.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.11 on 2026-07-26 00:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities', '0138_advisoryhistorydiff'), + ('vulnerabilities', '0141_advisorymitigations'), + ] + + operations = [ + ]