diff --git a/docs/content/releases/os_upgrading/3.2.md b/docs/content/releases/os_upgrading/3.2.md new file mode 100644 index 00000000000..75a05f1aa19 --- /dev/null +++ b/docs/content/releases/os_upgrading/3.2.md @@ -0,0 +1,58 @@ +--- +title: 'Upgrading to DefectDojo Version 3.2.x' +toc_hide: true +weight: -20260701 +description: Vulnerability ids gain an autodetected type and a uniqueness constraint; findings can now carry multiple CWEs via a new Finding_CWE relationship. Migrations add the type column, de-duplicate vulnerability-id rows, add the uniqueness constraint, create the CWE table, and backfill it. Existing hash codes are unaffected. +--- + +## Vulnerability id type + +Each `Vulnerability_Id` gains an autodetected `vulnerability_id_type` — the identifier's leading +prefix (`CVE-2024-1234` → `CVE`, `GHSA-…` → `GHSA`, `RUSTSEC-…` → `RUSTSEC`). It is derived +structurally (no registry) and stored (indexed) so identifiers can be filtered and grouped by type +efficiently. It is `NULL` when there is no non-numeric prefix. It is populated automatically on +import and on `save()`; existing rows are backfilled by migration. It does not participate in +`hash_code`, so **existing hash codes and deduplication are unaffected**. + +A unique constraint is also added on `(finding, vulnerability_id)`; pre-existing duplicate rows +(unintended) are consolidated first. + +## Multiple CWEs per finding + +A finding could previously store only one CWE (the integer `cwe` field). This release adds a +dedicated `Finding_CWE` relationship so a finding can carry **multiple CWEs**, using the same +approach as vulnerability ids: the primary CWE stays on `Finding.cwe` (unchanged — legacy +deduplication and hash codes still use it), and additional CWEs live in the relationship. + +CWE is modeled separately from vulnerability identifiers on purpose: a CWE is a weakness *class*, +not a vulnerability *instance* identifier, so it must not participate in `hash_code`, +vulnerability-id deduplication, or the `cve` field. Because of this separation, **existing hash +codes and deduplication are unaffected**. + +CWEs are populated automatically on import and when a finding is created or edited (from the +finding's CWE field, plus any additional CWEs a parser supplies). The finding exposes them via +`finding.cwes` (primary first, deduplicated). + +## Database migration + +Five migrations run automatically on upgrade: + +- `0276_vulnerability_id_type` — adds the indexed `vulnerability_id_type` column and a leading + index on `vulnerability_id`. +- `0277_backfill_vulnerability_id_type` — backfills `vulnerability_id_type` and removes duplicate + `(finding, vulnerability_id)` rows (keeping the earliest). +- `0278_unique_finding_vulnerability_id` — adds the unique constraint on `(finding, vulnerability_id)`. +- `0279_finding_cwe` — creates the `Finding_CWE` table (unique per `(finding, cwe)`). +- `0280_backfill_finding_cwe` — seeds `Finding_CWE` rows from the legacy `Finding.cwe` values. + +### What you need to do + +The migrations are applied automatically. New and edited findings populate their CWE relationship +automatically. To backfill `Finding_CWE` rows for **existing** findings, run the idempotent command +after upgrading: + +``` +manage.py migrate_cwe +``` + +For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.2.0). diff --git a/dojo/db_migrations/0276_vulnerability_id_type.py b/dojo/db_migrations/0276_vulnerability_id_type.py new file mode 100644 index 00000000000..013bd29be6c --- /dev/null +++ b/dojo/db_migrations/0276_vulnerability_id_type.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + """Schema only: add the autodetected vulnerability_id_type column and a leading index on + vulnerability_id. The data backfill (0277) and the unique constraint (0278, which requires + the dedupe data step first) are kept in separate migrations so data migrations are never + mixed with schema migrations.""" + + dependencies = [ + ("dojo", "0275_usercontactinfo_user_state_details"), + ] + + operations = [ + migrations.AddField( + model_name="vulnerability_id", + name="vulnerability_id_type", + field=models.CharField(blank=True, db_index=True, editable=False, max_length=20, null=True), + ), + migrations.AddIndex( + model_name="vulnerability_id", + index=models.Index(fields=["vulnerability_id"], name="dojo_vuln_id_lookup_idx"), + ), + ] diff --git a/dojo/db_migrations/0277_backfill_vulnerability_id_type.py b/dojo/db_migrations/0277_backfill_vulnerability_id_type.py new file mode 100644 index 00000000000..d9239905145 --- /dev/null +++ b/dojo/db_migrations/0277_backfill_vulnerability_id_type.py @@ -0,0 +1,49 @@ +from django.db import migrations + +from dojo.finding.vulnerability_id import resolve_vulnerability_id_type + +BATCH_SIZE = 1000 + + +def dedupe_vulnerability_ids(apps, schema_editor): + """Remove duplicate (finding, vulnerability_id) rows, keeping the lowest id, so the unique + constraint added in the following schema migration (0278) can be created. Postgres.""" + schema_editor.execute( + """ + DELETE FROM dojo_vulnerability_id a + USING dojo_vulnerability_id b + WHERE a.finding_id = b.finding_id + AND a.vulnerability_id = b.vulnerability_id + AND a.id > b.id + """, + ) + + +def backfill_vulnerability_id_type(apps, schema_editor): + """Populate vulnerability_id_type for existing rows by autodetecting the id prefix.""" + Vulnerability_Id = apps.get_model("dojo", "Vulnerability_Id") + batch = [] + qs = Vulnerability_Id.objects.filter(vulnerability_id_type__isnull=True).only("id", "vulnerability_id") + for row in qs.iterator(chunk_size=BATCH_SIZE): + row.vulnerability_id_type = resolve_vulnerability_id_type(row.vulnerability_id) + batch.append(row) + if len(batch) >= BATCH_SIZE: + Vulnerability_Id.objects.bulk_update(batch, ["vulnerability_id_type"], batch_size=BATCH_SIZE) + batch = [] + if batch: + Vulnerability_Id.objects.bulk_update(batch, ["vulnerability_id_type"], batch_size=BATCH_SIZE) + + +class Migration(migrations.Migration): + + """Data only (no schema changes): dedupe (finding, vulnerability_id) rows and backfill + vulnerability_id_type for existing rows.""" + + dependencies = [ + ("dojo", "0276_vulnerability_id_type"), + ] + + operations = [ + migrations.RunPython(dedupe_vulnerability_ids, migrations.RunPython.noop), + migrations.RunPython(backfill_vulnerability_id_type, migrations.RunPython.noop), + ] diff --git a/dojo/db_migrations/0278_unique_finding_vulnerability_id.py b/dojo/db_migrations/0278_unique_finding_vulnerability_id.py new file mode 100644 index 00000000000..fe2d287bc54 --- /dev/null +++ b/dojo/db_migrations/0278_unique_finding_vulnerability_id.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + """Schema only: add the (finding, vulnerability_id) unique constraint. Runs after the 0277 + dedupe data step so the constraint can be created without violating existing duplicate rows.""" + + dependencies = [ + ("dojo", "0277_backfill_vulnerability_id_type"), + ] + + operations = [ + migrations.AddConstraint( + model_name="vulnerability_id", + constraint=models.UniqueConstraint(fields=("finding", "vulnerability_id"), name="unique_finding_vulnerability_id"), + ), + ] diff --git a/dojo/db_migrations/0279_finding_cwe.py b/dojo/db_migrations/0279_finding_cwe.py new file mode 100644 index 00000000000..d2cbe171f6a --- /dev/null +++ b/dojo/db_migrations/0279_finding_cwe.py @@ -0,0 +1,28 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + """Schema only: create the Finding_CWE relationship (multiple CWEs per finding). The data + backfill from the legacy Finding.cwe field is kept in a separate migration (0280) so data + migrations are never mixed with schema migrations.""" + + dependencies = [ + ("dojo", "0278_unique_finding_vulnerability_id"), + ] + + operations = [ + migrations.CreateModel( + name="Finding_CWE", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("cwe", models.CharField(db_index=True, max_length=11)), + ("finding", models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to="dojo.finding")), + ], + ), + migrations.AddConstraint( + model_name="finding_cwe", + constraint=models.UniqueConstraint(fields=("finding", "cwe"), name="unique_finding_cwe"), + ), + ] diff --git a/dojo/db_migrations/0280_backfill_finding_cwe.py b/dojo/db_migrations/0280_backfill_finding_cwe.py new file mode 100644 index 00000000000..fce5d143cb0 --- /dev/null +++ b/dojo/db_migrations/0280_backfill_finding_cwe.py @@ -0,0 +1,36 @@ +from django.db import migrations + +from dojo.finding.cwe import cwe_label + +BATCH_SIZE = 1000 + + +def create_finding_cwe_records(apps, schema_editor): + """Backfill Finding_CWE rows (canonical CWE-) from the legacy int Finding.cwe field.""" + Finding = apps.get_model("dojo", "Finding") + Finding_CWE = apps.get_model("dojo", "Finding_CWE") + batch = [] + for finding in Finding.objects.filter(cwe__gt=0).only("id", "cwe").iterator(chunk_size=BATCH_SIZE): + label = cwe_label(finding.cwe) + if label is None: + continue + batch.append(Finding_CWE(finding_id=finding.id, cwe=label)) + if len(batch) >= BATCH_SIZE: + Finding_CWE.objects.bulk_create(batch, batch_size=BATCH_SIZE, ignore_conflicts=True) + batch = [] + if batch: + Finding_CWE.objects.bulk_create(batch, batch_size=BATCH_SIZE, ignore_conflicts=True) + + +class Migration(migrations.Migration): + + """Data only (no schema changes): create the initial Finding_CWE rows from the legacy + Finding.cwe field.""" + + dependencies = [ + ("dojo", "0279_finding_cwe"), + ] + + operations = [ + migrations.RunPython(create_finding_cwe_records, migrations.RunPython.noop), + ] diff --git a/dojo/finding/api/serializer.py b/dojo/finding/api/serializer.py index 360f08b7926..74202269a53 100644 --- a/dojo/finding/api/serializer.py +++ b/dojo/finding/api/serializer.py @@ -15,7 +15,9 @@ import dojo.finding.helper as finding_helper from dojo.authorization.authorization import user_has_permission from dojo.celery_dispatch import dojo_dispatch_task +from dojo.finding.cwe import cwe_label, cwe_number from dojo.finding.helper import ( + save_cwes, save_endpoints_template, save_vulnerability_ids, save_vulnerability_ids_template, @@ -32,6 +34,7 @@ Endpoint, Engagement, Finding, + Finding_CWE, Finding_Group, Finding_Template, Note_Type, @@ -298,6 +301,30 @@ class Meta: fields = ["vulnerability_id"] +@extend_schema_field(serializers.CharField()) +class CweField(serializers.Field): + + """Serialize a CWE as the canonical ``CWE-`` string; accept ``"CWE-79"`` or ``"79"`` on write.""" + + def to_representation(self, value): + return cwe_label(value) or value + + def to_internal_value(self, data): + label = cwe_label(data) + if label is None: + msg = "Enter a CWE number, e.g. 89 or CWE-89." + raise serializers.ValidationError(msg) + return label + + +class FindingCweSerializer(serializers.ModelSerializer): + cwe = CweField() + + class Meta: + model = Finding_CWE + fields = ["cwe"] + + class FindingSerializer(serializers.ModelSerializer): mitigated = serializers.DateTimeField(required=False, allow_null=True) mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all()) @@ -321,6 +348,9 @@ class FindingSerializer(serializers.ModelSerializer): vulnerability_ids = VulnerabilityIdSerializer( source="vulnerability_id_set", many=True, required=False, ) + cwes = FindingCweSerializer( + source="finding_cwe_set", many=True, required=False, + ) reporter = serializers.PrimaryKeyRelatedField( required=False, queryset=User.objects.all(), ) @@ -417,6 +447,13 @@ def update(self, instance, validated_data): logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0]) validated_data["cve"] = parsed_vulnerability_ids[0] + # CWEs (mirror vulnerability_ids): the first entry is the primary Finding.cwe; the rest + # become Finding_CWE rows via save_cwes() below. + parsed_cwes = None + if (cwes := validated_data.pop("finding_cwe_set", None)) is not None: + parsed_cwes = [entry["cwe"] for entry in cwes] + validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0 + # Save the reporter on the finding if reporter_id := validated_data.get("reporter"): instance.reporter = reporter_id @@ -445,6 +482,11 @@ def update(self, instance, validated_data): instance, validated_data, ) + # Sync the CWE relation (separate from vulnerability ids) after the new cwe is applied. + if parsed_cwes is not None: + instance.unsaved_cwes = parsed_cwes[1:] + save_cwes(instance) + if settings.V3_FEATURE_LOCATIONS and locations is not None: for location_ref in instance.locations.all(): location_ref.location.disassociate_from_finding(instance) @@ -561,6 +603,9 @@ class FindingCreateSerializer(serializers.ModelSerializer): vulnerability_ids = VulnerabilityIdSerializer( source="vulnerability_id_set", many=True, required=False, ) + cwes = FindingCweSerializer( + source="finding_cwe_set", many=True, required=False, + ) reporter = serializers.PrimaryKeyRelatedField( required=False, queryset=User.objects.all(), ) @@ -601,6 +646,12 @@ def create(self, validated_data): validated_data["cve"] = parsed_vulnerability_ids[0] # validated_data["unsaved_vulnerability_ids"] = parsed_vulnerability_ids + # CWEs (mirror vulnerability_ids): first entry is the primary cwe, the rest are extras. + parsed_cwes = None + if (cwes := validated_data.pop("finding_cwe_set", None)) is not None: + parsed_cwes = [entry["cwe"] for entry in cwes] + validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0 + # super.create() doesn't accept unsaved_vulnerability_ids or dedupe_option=False, so call save directly. new_finding = Finding(**validated_data) new_finding.unsaved_vulnerability_ids = parsed_vulnerability_ids or [] @@ -617,6 +668,9 @@ def create(self, validated_data): new_finding.reviewers.set(reviewers) if parsed_vulnerability_ids: save_vulnerability_ids(new_finding, parsed_vulnerability_ids) + if parsed_cwes is not None: + new_finding.unsaved_cwes = parsed_cwes[1:] + save_cwes(new_finding) if push_to_jira: jira_services.push(new_finding) diff --git a/dojo/finding/cwe.py b/dojo/finding/cwe.py new file mode 100644 index 00000000000..4b31b5c5f21 --- /dev/null +++ b/dojo/finding/cwe.py @@ -0,0 +1,49 @@ +"""Pure helpers for CWE identifiers (no model imports, safe to import anywhere).""" + + +def cwe_number(value) -> int | None: + """ + ``"CWE-79"`` / ``"79"`` / ``79`` -> ``79`` (positive int, case-insensitive); anything else -> ``None``. + + CWE numbers are positive, so ``0`` (Finding.cwe's "unset" sentinel) and non-numeric input -> ``None``. + """ + if value is None: + return None + token = str(value).strip().upper().removeprefix("CWE-") + if not token.isdigit(): + return None + number = int(token) + return number if number > 0 else None + + +def cwe_label(value) -> str | None: + """``79`` / ``"79"`` / ``"CWE-79"`` -> ``"CWE-79"`` (canonical, case-insensitive); invalid -> ``None``.""" + number = cwe_number(value) + return f"CWE-{number}" if number is not None else None + + +def parse_cwes(text: str | None) -> list[str]: + """ + Parse CWEs from user text (one per line or comma-separated) into canonical ``CWE-`` labels. + + Accepts ``89`` or ``CWE-89`` (case-insensitive); ignores anything non-numeric; deduplicates. + """ + result: list[str] = [] + for token in (text or "").replace(",", "\n").split(): + label = cwe_label(token) + if label is not None and label not in result: + result.append(label) + return result + + +def finding_cwe_labels(cwe, unsaved_cwes=None) -> list[str]: + """Canonical ``CWE-`` labels for a finding: the primary ``cwe`` first, then extras, deduplicated.""" + labels: list[str] = [] + primary = cwe_label(cwe) + if primary is not None: + labels.append(primary) + for extra in unsaved_cwes or []: + label = cwe_label(extra) + if label is not None: + labels.append(label) + return list(dict.fromkeys(labels)) diff --git a/dojo/finding/deduplication.py b/dojo/finding/deduplication.py index bf91b610b6d..36d2a4a402e 100644 --- a/dojo/finding/deduplication.py +++ b/dojo/finding/deduplication.py @@ -340,11 +340,11 @@ def build_candidate_scope_queryset(test, mode="deduplication", service=None): queryset = Finding.objects.filter(scope_q) if settings.V3_FEATURE_LOCATIONS: - prefetch_list = ["locations__location__url", "vulnerability_id_set", "found_by"] + prefetch_list = ["locations__location__url", "vulnerability_id_set", "finding_cwe_set", "found_by"] else: # TODO: Delete this after the move to Locations # Base prefetches for both modes - prefetch_list = ["endpoints", "vulnerability_id_set", "found_by"] + prefetch_list = ["endpoints", "vulnerability_id_set", "finding_cwe_set", "found_by"] # Prefetch all endpoint statuses with their endpoint for reimport mode. # The non-special filtering (excluding false_positive, out_of_scope, risk_accepted) @@ -1098,6 +1098,13 @@ def do_false_positive_history_batch(findings): # Fetch all candidate existing findings with one DB query candidates = _fetch_fp_candidates_for_batch(findings, product, dedup_alg) + # Optional plugin hook: refine the per-finding candidate list after it is resolved by + # deduplication_algorithm. Lets a plugin (e.g. Pro) narrow candidates by fields that are + # excluded from the hash string but compared per pair (set-match tokens on + # vulnerability_ids / CWEs). Resolved once; a no-op when unset. See get_custom_method. + from dojo.utils import get_custom_method # noqa: PLC0415 -- circular import + fp_candidate_filter = get_custom_method("FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD") + to_mark_as_fp_ids: set = set() for finding in findings: @@ -1121,6 +1128,9 @@ def do_false_positive_history_batch(findings): else: existing = [] + if fp_candidate_filter: + existing = fp_candidate_filter(finding, existing) + existing_fps = [ef for ef in existing if ef.false_p] if existing_fps: diff --git a/dojo/finding/helper.py b/dojo/finding/helper.py index d83d032b176..0527d04fa86 100644 --- a/dojo/finding/helper.py +++ b/dojo/finding/helper.py @@ -19,6 +19,7 @@ from dojo.celery import app from dojo.endpoint.utils import endpoint_get_or_create, save_endpoints_to_add from dojo.file_uploads.helper import delete_related_files +from dojo.finding.cwe import finding_cwe_labels from dojo.finding.deduplication import ( dedupe_batch_of_findings, do_dedupe_finding_task_internal, @@ -26,6 +27,7 @@ do_false_positive_history_batch, get_finding_models_for_deduplication, ) +from dojo.finding.vulnerability_id import resolve_vulnerability_id_type from dojo.jira import services as jira_services from dojo.location.models import Location from dojo.location.status import FindingLocationStatus @@ -36,6 +38,7 @@ Engagement, FileUpload, Finding, + Finding_CWE, Finding_Group, JIRA_Instance, Notes, @@ -1004,7 +1007,7 @@ def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool Vulnerability_Id.objects.filter(finding=finding).delete() Vulnerability_Id.objects.bulk_create([ - Vulnerability_Id(finding=finding, vulnerability_id=vid) + Vulnerability_Id(finding=finding, vulnerability_id=vid, vulnerability_id_type=resolve_vulnerability_id_type(vid)) for vid in vulnerability_ids ]) @@ -1015,6 +1018,24 @@ def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool finding.cve = None +def save_cwes(finding, *, delete_existing: bool = True): + """ + Persist the finding's CWEs as Finding_CWE rows. + + The primary Finding.cwe plus any parser-supplied unsaved_cwes, stored as canonical CWE- + strings. CWE is a weakness class, kept separate from vulnerability ids. + """ + cwe_values = finding_cwe_labels(finding.cwe, getattr(finding, "unsaved_cwes", None)) + + if delete_existing: + Finding_CWE.objects.filter(finding=finding).delete() + + Finding_CWE.objects.bulk_create( + [Finding_CWE(finding=finding, cwe=cwe) for cwe in cwe_values], + ignore_conflicts=True, + ) + + def save_vulnerability_ids_template(finding_template, vulnerability_ids): """Save vulnerability IDs as newline-separated string in TextField.""" # Remove duplicates and empty strings diff --git a/dojo/finding/models.py b/dojo/finding/models.py index 8a55d3df9e6..40bda9e2c86 100644 --- a/dojo/finding/models.py +++ b/dojo/finding/models.py @@ -22,6 +22,8 @@ from titlecase import titlecase from dojo.base_models.base import BaseModel +from dojo.finding.cwe import cwe_label, finding_cwe_labels +from dojo.finding.vulnerability_id import resolve_vulnerability_id_type # get_current_date/tomorrow/copy_model_util are defined early in dojo.models, before the # re-export that loads this module — so this resolves despite the partial circular load, and @@ -534,6 +536,9 @@ def __init__(self, *args, **kwargs): self.unsaved_tags = None self.unsaved_files = None self.unsaved_vulnerability_ids = None + # Extra CWE numbers a parser wants to attach in addition to the primary self.cwe. + # Persisted as Finding_CWE rows (multiple CWEs per finding). None = none supplied. + self.unsaved_cwes = None def __str__(self): return self.title @@ -689,6 +694,11 @@ def copy(self, test=None): copy.found_by.set(old_found_by) # Assign any tags copy.tags.set(old_tags) + # Copy the vulnerability ids and CWEs (relation rows aren't copied by copy_model_util) + for vulnerability_id in self.vulnerability_id_set.all(): + Vulnerability_Id.objects.create(finding=copy, vulnerability_id=vulnerability_id.vulnerability_id) + for finding_cwe in self.finding_cwe_set.all(): + Finding_CWE.objects.create(finding=copy, cwe=finding_cwe.cwe) return copy @@ -778,6 +788,11 @@ def compute_hash_code(self): my_vulnerability_ids = self.get_vulnerability_ids() fields_to_hash += my_vulnerability_ids deduplicationLogger.debug(hashcodeField + " : " + my_vulnerability_ids) + elif hashcodeField == "cwes": + # For the CWE set (primary cwe + additional CWEs), need to compute the field + my_cwes = self.get_cwes() + fields_to_hash += my_cwes + deduplicationLogger.debug(hashcodeField + " : " + my_cwes) else: # Generically use the finding attribute having the same name, converts to str in case it's integer fields_to_hash += str(getattr(self, hashcodeField)) @@ -823,6 +838,28 @@ def _get_saved_vulnerability_ids(finding) -> str: return _get_saved_vulnerability_ids(self) or _get_unsaved_vulnerability_ids(self) + # Get CWEs (canonical CWE- labels) to use for hash_code computation. + # Mirrors get_vulnerability_ids: the saved path reads the reverse relation directly (never the + # cached `cwes` property, which could be stale if accessed before the rows were written), and + # falls back to the unsaved values before the finding is saved. save_cwes persists the primary + # self.cwe as a Finding_CWE row too, so finding_cwe_set already carries the full set. + def get_cwes(self): + + def _get_unsaved_cwes(finding) -> str: + # primary self.cwe + any unsaved extra CWEs, canonical CWE-, deduplicated + labels = finding_cwe_labels(finding.cwe, finding.unsaved_cwes) + return "".join(sorted(labels)) + + def _get_saved_cwes(finding) -> str: + if finding.id is not None: + # Use the reverse relation (finding_cwe_set) rather than the cached `cwes` property + # so prefetch is honored and a stale cached value can never corrupt the hash. + labels = [row.cwe for row in finding.finding_cwe_set.all()] + return "".join(sorted(labels)) + return "" + + return _get_saved_cwes(self) or _get_unsaved_cwes(self) + # Get locations/endpoints to use for hash_code computation def get_locations(self): # TODO: Delete this after the move to Locations @@ -1343,6 +1380,19 @@ def vulnerability_ids(self): # Remove duplicates return list(dict.fromkeys(vulnerability_ids)) + @cached_property + def cwes(self): + # All CWEs for this finding in canonical CWE- form: the additional Finding_CWE rows + # (already stored as CWE- strings) with the primary self.cwe first, deduplicated. + # The single primary `cwe` field is merged in for backward compatibility, exactly as the + # `cve` field is merged into `vulnerability_ids`. We keep both fields to stay flexible + # until the single `cwe` field is not needed anymore and can be removed. + labels = [row.cwe for row in self.finding_cwe_set.all()] + primary = cwe_label(self.cwe) + if primary is not None: + labels.insert(0, primary) + return list(dict.fromkeys(label for label in labels if label)) + @property def violates_sla(self): return (self.sla_expiration_date and self.sla_expiration_date < timezone.now().date()) @@ -1366,10 +1416,51 @@ def set_hash_code(self, dedupe_option): class Vulnerability_Id(models.Model): finding = models.ForeignKey("dojo.Finding", editable=False, on_delete=models.CASCADE) vulnerability_id = models.TextField(max_length=50, blank=False, null=False) + # Autodetected from the id prefix (CVE, GHSA, ...); NULL when there is no non-numeric + # prefix. Denormalized/indexed so type-scoped queries (e.g. GROUP BY type) stay cheap. + vulnerability_id_type = models.CharField(max_length=20, null=True, blank=True, editable=False, db_index=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["finding", "vulnerability_id"], name="unique_finding_vulnerability_id"), + ] + indexes = [ + # Leading on vulnerability_id (the unique constraint's index leads on finding), for the + # vulnerability-id Explorer's GROUP BY vulnerability_id / lookups by exact id. + models.Index(fields=["vulnerability_id"], name="dojo_vuln_id_lookup_idx"), + ] def __str__(self): return self.vulnerability_id + def save(self, *args, **kwargs): + # bulk_create paths set the type at construction; this covers save()/get_or_create. + self.vulnerability_id_type = resolve_vulnerability_id_type(self.vulnerability_id) + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("view_finding", args=[str(self.finding.id)]) + + +class Finding_CWE(models.Model): + # A CWE weakness associated with a finding. Separate from Vulnerability_Id because a CWE is a + # weakness class, not a vulnerability instance identifier — it must not participate in + # hash_code, vulnerability-id deduplication, or the cve field. Stored as the canonical + # ``CWE-`` string (mirrors Vulnerability_Id.vulnerability_id). The primary CWE also stays on + # the legacy int Finding.cwe; this relation lets a finding carry multiple CWEs. + finding = models.ForeignKey("dojo.Finding", editable=False, on_delete=models.CASCADE) + # "CWE-": 4 for the "CWE-" prefix + up to 7 digits (CWE-9999999) — far beyond the current + # max (~CWE-1428) yet tight. (Postgres varchar(n) is a length bound, not a storage cost.) + cwe = models.CharField(max_length=11, db_index=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["finding", "cwe"], name="unique_finding_cwe"), + ] + + def __str__(self): + return self.cwe + def get_absolute_url(self): return reverse("view_finding", args=[str(self.finding.id)]) diff --git a/dojo/finding/ui/forms.py b/dojo/finding/ui/forms.py index 79f3e317059..b075fe12079 100644 --- a/dojo/finding/ui/forms.py +++ b/dojo/finding/ui/forms.py @@ -7,6 +7,7 @@ from tagulous.forms import TagField from dojo.endpoint.utils import validate_endpoints_to_add +from dojo.finding.cwe import cwe_number, parse_cwes from dojo.finding.queries import get_authorized_findings from dojo.jira import services as jira_services from dojo.location.models import Location @@ -42,6 +43,36 @@ "You may enter one vulnerability id per line.", widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) +cwes_field = forms.CharField(max_length=500, + required=False, + label="CWEs", + help_text="CWE numbers associated with this finding. You may enter one per line (e.g. 89 or CWE-89). The first is the primary CWE.", + widget=forms.widgets.Textarea(attrs={"rows": "3", "cols": "400"})) + + +class CweFormMixin: + + """ + Persist the 'cwes' textarea as the primary Finding.cwe plus extra Finding_CWE rows. + + Mirrors how the 'vulnerability_ids' textarea maps to cve + Vulnerability_Id rows. + """ + + def clean_cwes(self): + value = self.cleaned_data.get("cwes", "") + invalid = [token for token in value.replace(",", "\n").split() if cwe_number(token) is None] + if invalid: + msg = f"Invalid CWE(s): {', '.join(invalid)}. Enter numbers like 89 or CWE-89, one per line." + raise forms.ValidationError(msg) + return value + + def save(self, commit=True): # noqa: FBT002 + cwes = parse_cwes(self.cleaned_data.get("cwes")) + self.instance.cwe = cwe_number(cwes[0]) if cwes else 0 + self.instance.unsaved_cwes = cwes[1:] + return super().save(commit=commit) + + EFFORT_FOR_FIXING_INVALID_CHOICE = _("Select valid choice: Low,Medium,High") @@ -183,11 +214,11 @@ def __init__(self, *args, **kwargs): self.fields["accepted_findings"].queryset = get_authorized_findings("edit") -class AddFindingForm(forms.ModelForm): +class AddFindingForm(CweFormMixin, forms.ModelForm): title = forms.CharField(max_length=1000) date = forms.DateField(required=True, widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) + cwes = cwes_field vulnerability_ids = vulnerability_ids_field cvssv3 = forms.CharField(label="CVSS3 Vector", max_length=117, required=False, widget=forms.TextInput(attrs={"class": "cvsscalculator", "data-toggle": "dropdown", "aria-haspopup": "true", "aria-expanded": "false"})) cvssv3_score = forms.FloatField(label="CVSS3 Score", required=False, max_value=10.0, min_value=0.0) @@ -219,7 +250,7 @@ class AddFindingForm(forms.ModelForm): "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", + field_order = ("title", "date", "cwes", "vulnerability_ids", "severity", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "verified", "false_p", "duplicate", "out_of_scope", "risk_accepted", "under_defect_review") @@ -279,15 +310,15 @@ def clean_tags(self): class Meta: model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "cwe", "inherited_tags", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date") -class AdHocFindingForm(forms.ModelForm): +class AdHocFindingForm(CweFormMixin, forms.ModelForm): title = forms.CharField(max_length=1000) date = forms.DateField(required=True, widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) + cwes = cwes_field vulnerability_ids = vulnerability_ids_field cvss_info = forms.CharField( @@ -327,7 +358,7 @@ class AdHocFindingForm(forms.ModelForm): "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", + field_order = ("title", "date", "cwes", "vulnerability_ids", "severity", "cvss_info", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "verified", "false_p", "duplicate", "out_of_scope", "risk_accepted", "under_defect_review", "sla_start_date", "sla_expiration_date") @@ -385,16 +416,16 @@ def clean_tags(self): class Meta: model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "cwe", "inherited_tags", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "endpoints", "sla_start_date", "sla_expiration_date") -class PromoteFindingForm(forms.ModelForm): +class PromoteFindingForm(CweFormMixin, forms.ModelForm): title = forms.CharField(max_length=1000) date = forms.DateField(required=True, widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) + cwes = cwes_field vulnerability_ids = vulnerability_ids_field cvss_info = forms.CharField( @@ -423,7 +454,7 @@ class PromoteFindingForm(forms.ModelForm): references = forms.CharField(widget=forms.Textarea, required=False) # the onyl reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", + field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwes", "vulnerability_ids", "severity", "cvss_info", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", "false_p", "duplicate", "out_of_scope", "risk_accept", "under_defect_review") @@ -469,16 +500,16 @@ def clean_tags(self): class Meta: model = Finding - exclude = ("reporter", "url", "numerical_severity", "active", "false_p", "verified", "endpoint_status", "cve", "inherited_tags", + exclude = ("reporter", "url", "numerical_severity", "active", "false_p", "verified", "endpoint_status", "cve", "cwe", "inherited_tags", "duplicate", "out_of_scope", "under_review", "reviewers", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "planned_remediation_date", "planned_remediation_version", "effort_for_fixing") -class FindingForm(forms.ModelForm): +class FindingForm(CweFormMixin, forms.ModelForm): title = forms.CharField(max_length=1000) group = forms.ModelChoiceField(required=False, queryset=Finding_Group.objects.none(), help_text="The Finding Group to which this finding belongs, leave empty to remove the finding from the group. Groups can only be created via Bulk Edit for now.") date = forms.DateField(required=True, widget=forms.TextInput(attrs={"class": "datepicker", "autocomplete": "off"})) - cwe = forms.IntegerField(required=False) + cwes = cwes_field vulnerability_ids = vulnerability_ids_field cvss_info = forms.CharField( @@ -522,7 +553,7 @@ class FindingForm(forms.ModelForm): "invalid_choice": EFFORT_FOR_FIXING_INVALID_CHOICE}) # the only reliable way without hacking internal fields to get predicatble ordering is to make it explicit - field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwe", "vulnerability_ids", "severity", "cvss_info", "cvssv3", + field_order = ("title", "group", "date", "sla_start_date", "sla_expiration_date", "cwes", "vulnerability_ids", "severity", "cvss_info", "cvssv3", "cvssv3_score", "cvssv4", "cvssv4_score", "description", "mitigation", "impact", "request", "response", "steps_to_reproduce", "severity_justification", "endpoints", "endpoints_to_add", "references", "active", "mitigated", "mitigated_by", "verified", "false_p", "duplicate", "out_of_scope", "risk_accept", "under_defect_review") @@ -537,6 +568,10 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Pre-fill all CWEs (primary first, CWE- form) on edit; mirrors the vulnerability_ids field. + if self.instance and self.instance.pk: + self.fields["cwes"].initial = "\n".join(self.instance.cwes) + if settings.V3_FEATURE_LOCATIONS: self.fields["endpoints"].queryset = Location.objects.filter(products__product=self.instance.test.engagement.product) if self.instance and self.instance.pk: @@ -634,7 +669,7 @@ def _post_clean(self): class Meta: model = Finding - exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "inherited_tags", + exclude = ("reporter", "url", "numerical_severity", "under_review", "reviewers", "cve", "cwe", "inherited_tags", "review_requested_by", "is_mitigated", "jira_creation", "jira_change", "sonarqube_issue", "endpoints", "endpoint_status") diff --git a/dojo/finding/ui/views.py b/dojo/finding/ui/views.py index e4ed5327e1c..1436edd98fe 100644 --- a/dojo/finding/ui/views.py +++ b/dojo/finding/ui/views.py @@ -939,6 +939,7 @@ def process_finding_form(self, request: HttpRequest, finding: Finding, context: self.process_burp_request_response(new_finding, context) # Save the vulnerability IDs finding_helper.save_vulnerability_ids(new_finding, context["form"].cleaned_data["vulnerability_ids"].split()) + finding_helper.save_cwes(new_finding) # Add a success message messages.add_message( request, diff --git a/dojo/finding/vulnerability_id.py b/dojo/finding/vulnerability_id.py new file mode 100644 index 00000000000..1f5248d382b --- /dev/null +++ b/dojo/finding/vulnerability_id.py @@ -0,0 +1,17 @@ +"""Pure helpers for vulnerability identifiers (no model imports, safe to import anywhere).""" + + +def resolve_vulnerability_id_type(vulnerability_id: str | None) -> str | None: + """ + Autodetect the type from the id's leading prefix (the part before the first ``-``). + + Structural, no registry: ``CVE-2024-1 -> "CVE"``, ``GHSA-... -> "GHSA"``, + ``RUSTSEC-2021-0001 -> "RUSTSEC"``, ``ALINUX2-SA-... -> "ALINUX2"``. Returns the uppercased + prefix, or ``None`` when there is no non-numeric prefix (bare numbers / UUIDs / no dash). + """ + if not vulnerability_id: + return None + prefix = str(vulnerability_id).strip().split("-", 1)[0].strip() + if not prefix or prefix.isdigit(): + return None + return prefix.upper() diff --git a/dojo/importers/base_importer.py b/dojo/importers/base_importer.py index fe015b610b0..e578ef6d795 100644 --- a/dojo/importers/base_importer.py +++ b/dojo/importers/base_importer.py @@ -12,6 +12,8 @@ import dojo.finding.helper as finding_helper import dojo.risk_acceptance.helper as ra_helper +from dojo.finding.cwe import finding_cwe_labels +from dojo.finding.vulnerability_id import resolve_vulnerability_id_type from dojo.importers.options import ImporterOptions from dojo.jira.services import is_keep_in_sync from dojo.location.models import Location @@ -27,6 +29,7 @@ Endpoint, FileUpload, Finding, + Finding_CWE, Test, Test_Import, Test_Import_Finding_Action, @@ -80,6 +83,8 @@ def __init__( ImporterOptions.__init__(self, *args, **kwargs) self.pending_vulnerability_ids: list[Vulnerability_Id] = [] self.pending_vuln_id_deletes: list[int] = [] + self.pending_cwes: list[Finding_CWE] = [] + self.pending_cwe_deletes: list[int] = [] self.pending_burp_rr: list[BurpRawRequestResponse] = [] def check_child_implementation_exception(self): @@ -791,23 +796,50 @@ def store_vulnerability_ids( vulnerability_ids_to_process = list(dict.fromkeys(finding.unsaved_vulnerability_ids or [])) vulnerability_ids_to_process = [x for x in vulnerability_ids_to_process if x.strip()] self.pending_vulnerability_ids.extend([ - Vulnerability_Id(finding=finding, vulnerability_id=vid) + Vulnerability_Id(finding=finding, vulnerability_id=vid, vulnerability_id_type=resolve_vulnerability_id_type(vid)) for vid in vulnerability_ids_to_process ]) if vulnerability_ids_to_process: finding.cve = vulnerability_ids_to_process[0] else: finding.cve = None + self.store_cwes(finding) return finding + def finding_cwe_values(self, finding: Finding) -> list[str]: + """Canonical CWE- labels: the primary Finding.cwe plus any parser-supplied unsaved_cwes.""" + return finding_cwe_labels(finding.cwe, getattr(finding, "unsaved_cwes", None)) + + def store_cwes(self, finding: Finding) -> None: + """Accumulate Finding_CWE rows for bulk insert at the batch boundary (via flush_vulnerability_ids).""" + self.pending_cwes.extend([ + Finding_CWE(finding=finding, cwe=cwe) for cwe in self.finding_cwe_values(finding) + ]) + + def reconcile_cwes(self, finding: Finding) -> None: + """Accumulate a delete+insert of Finding_CWE rows for a reimported finding when its CWEs changed.""" + new_cwes = set(self.finding_cwe_values(finding)) + # finding_cwe_set is prefetched on reimport candidates (build_candidate_scope_queryset). + existing_cwes = {row.cwe for row in finding.finding_cwe_set.all()} + if existing_cwes == new_cwes: + return + self.pending_cwe_deletes.append(finding.id) + self.pending_cwes.extend([Finding_CWE(finding=finding, cwe=cwe) for cwe in new_cwes]) + def flush_vulnerability_ids(self) -> None: - """Delete stale and bulk-insert accumulated Vulnerability_Id objects, then clear buffers.""" + """Delete stale and bulk-insert accumulated Vulnerability_Id / Finding_CWE objects, then clear buffers.""" if self.pending_vuln_id_deletes: Vulnerability_Id.objects.filter(finding_id__in=self.pending_vuln_id_deletes).delete() self.pending_vuln_id_deletes.clear() if self.pending_vulnerability_ids: Vulnerability_Id.objects.bulk_create(self.pending_vulnerability_ids, batch_size=1000) self.pending_vulnerability_ids.clear() + if self.pending_cwe_deletes: + Finding_CWE.objects.filter(finding_id__in=self.pending_cwe_deletes).delete() + self.pending_cwe_deletes.clear() + if self.pending_cwes: + Finding_CWE.objects.bulk_create(self.pending_cwes, batch_size=1000, ignore_conflicts=True) + self.pending_cwes.clear() def process_files( self, diff --git a/dojo/importers/default_reimporter.py b/dojo/importers/default_reimporter.py index 9defa8352f2..e0664ca4d37 100644 --- a/dojo/importers/default_reimporter.py +++ b/dojo/importers/default_reimporter.py @@ -13,6 +13,7 @@ find_candidates_for_deduplication_unique_id, find_candidates_for_reimport_legacy, ) +from dojo.finding.vulnerability_id import resolve_vulnerability_id_type from dojo.importers.base_importer import BaseImporter, Parser from dojo.importers.base_location_manager import LocationHandler from dojo.importers.options import ImporterOptions @@ -966,6 +967,10 @@ def reconcile_vulnerability_ids( vulnerability_ids_to_process = list(dict.fromkeys(finding.unsaved_vulnerability_ids or [])) vulnerability_ids_to_process = [x for x in vulnerability_ids_to_process if x.strip()] + # Reconcile CWEs independently of the vulnerability_ids early-exit below (CWEs may change + # while vulnerability_ids do not, and vice versa). + self.reconcile_cwes(finding) + # Use prefetched data directly without triggering queries existing_vuln_ids = {v.vulnerability_id for v in finding.vulnerability_id_set.all()} new_vuln_ids = set(vulnerability_ids_to_process) @@ -981,7 +986,7 @@ def reconcile_vulnerability_ids( # Accumulate delete + insert for batch flush self.pending_vuln_id_deletes.append(finding.id) self.pending_vulnerability_ids.extend([ - Vulnerability_Id(finding=finding, vulnerability_id=vid) + Vulnerability_Id(finding=finding, vulnerability_id=vid, vulnerability_id_type=resolve_vulnerability_id_type(vid)) for vid in vulnerability_ids_to_process ]) if vulnerability_ids_to_process: diff --git a/dojo/management/commands/migrate_cwe.py b/dojo/management/commands/migrate_cwe.py new file mode 100644 index 00000000000..fd0ae0b2b90 --- /dev/null +++ b/dojo/management/commands/migrate_cwe.py @@ -0,0 +1,37 @@ +import logging + +from django.core.management.base import BaseCommand + +from dojo.finding.cwe import cwe_label +from dojo.models import Finding, Finding_CWE + +logger = logging.getLogger(__name__) + +BATCH_SIZE = 1000 + + +class Command(BaseCommand): + + """Create Finding_CWE rows (canonical CWE-) from the legacy Finding.cwe field for all findings.""" + + help = "Usage: manage.py migrate_cwe" + + def handle(self, *args, **options): + logger.info("Starting migration of cwes for Findings") + findings = Finding.objects.filter(cwe__gt=0).only("id", "cwe") + batch = [] + created = 0 + for finding in findings.iterator(chunk_size=BATCH_SIZE): + label = cwe_label(finding.cwe) + if label is None: + continue + batch.append(Finding_CWE(finding_id=finding.id, cwe=label)) + if len(batch) >= BATCH_SIZE: + # The unique (finding, cwe) constraint makes bulk_create idempotent. + Finding_CWE.objects.bulk_create(batch, batch_size=BATCH_SIZE, ignore_conflicts=True) + created += len(batch) + batch = [] + if batch: + Finding_CWE.objects.bulk_create(batch, batch_size=BATCH_SIZE, ignore_conflicts=True) + created += len(batch) + logger.info("Finished migration of cwes for Findings: processed %d rows", created) diff --git a/dojo/models.py b/dojo/models.py index c4d3d0aaa68..582708eaf64 100644 --- a/dojo/models.py +++ b/dojo/models.py @@ -390,6 +390,7 @@ class Meta: CWE, # noqa: F401 -- re-export BurpRawRequestResponse, # noqa: F401 -- re-export Finding, + Finding_CWE, # noqa: F401 -- re-export Finding_Group, # noqa: F401 -- re-export Finding_Template, Vulnerability_Id, # noqa: F401 -- re-export diff --git a/dojo/product/ui/views.py b/dojo/product/ui/views.py index 5d6924ae033..6fd06517051 100644 --- a/dojo/product/ui/views.py +++ b/dojo/product/ui/views.py @@ -1494,6 +1494,7 @@ def process_forms(self, request: HttpRequest, test: Test, context: dict): if all_forms_valid: # if we're removing the "duplicate" in the edit finding screen finding_helper.save_vulnerability_ids(finding, context["form"].cleaned_data["vulnerability_ids"].split()) + finding_helper.save_cwes(finding) # Push things to jira if needed finding.save(push_to_jira=push_to_jira) # Save the burp req resp diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 4c0f161768a..21b28d13660 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1201,7 +1201,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # List of fields that are known to be usable in hash_code computation) # 'endpoints' is a pseudo field that uses the endpoints (for dynamic scanners). If `V3_FEATURE_LOCATIONS` is True, Dojo uses locations (URLs) instead. # 'unique_id_from_tool' is often not needed here as it can be used directly in the dedupe algorithm, but it's also possible to use it for hashing -HASHCODE_ALLOWED_FIELDS = ["title", "cwe", "vulnerability_ids", "line", "file_path", "payload", "component_name", "component_version", "description", "endpoints", "unique_id_from_tool", "severity", "vuln_id_from_tool", "mitigation"] +HASHCODE_ALLOWED_FIELDS = ["title", "cwe", "cwes", "vulnerability_ids", "line", "file_path", "payload", "component_name", "component_version", "description", "endpoints", "unique_id_from_tool", "severity", "vuln_id_from_tool", "mitigation"] # Adding fields to the hash_code calculation regardless of the previous settings HASH_CODE_FIELDS_ALWAYS = ["service"] diff --git a/dojo/templates/dojo/ad_hoc_findings.html b/dojo/templates/dojo/ad_hoc_findings.html index d088b5c0570..bd9fc1af544 100644 --- a/dojo/templates/dojo/ad_hoc_findings.html +++ b/dojo/templates/dojo/ad_hoc_findings.html @@ -64,7 +64,7 @@

Github

elem.id = "req" } - if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && !$(elem).hasClass('select2-search__field')) { + if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && elem.name != 'cwes' && !$(elem).hasClass('select2-search__field')) { var mde = new EasyMDE({ spellChecker: false, inputStyle: "contenteditable", diff --git a/dojo/templates/dojo/add_findings.html b/dojo/templates/dojo/add_findings.html index d515bf7ee28..38f83dc23cd 100644 --- a/dojo/templates/dojo/add_findings.html +++ b/dojo/templates/dojo/add_findings.html @@ -111,7 +111,7 @@

JIRA

elem.id = "req" } - if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && !$(elem).hasClass('select2-search__field')) { + if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && elem.name != 'cwes' && !$(elem).hasClass('select2-search__field')) { var mde = new EasyMDE({ spellChecker: false, inputStyle: "contenteditable", diff --git a/dojo/templates/dojo/edit_finding.html b/dojo/templates/dojo/edit_finding.html index c6726860709..24267a368a5 100644 --- a/dojo/templates/dojo/edit_finding.html +++ b/dojo/templates/dojo/edit_finding.html @@ -171,7 +171,7 @@

GitHub

elem.id = "req" } - if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && !$(elem).hasClass('select2-search__field')) { + if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && elem.name != 'cwes' && !$(elem).hasClass('select2-search__field')) { var mde = new EasyMDE({ spellChecker: false, inputStyle: "contenteditable", diff --git a/dojo/templates/dojo/view_finding.html b/dojo/templates/dojo/view_finding.html index 19d94fe2942..9f4fc6b682b 100755 --- a/dojo/templates/dojo/view_finding.html +++ b/dojo/templates/dojo/view_finding.html @@ -464,6 +464,27 @@

{% endif %} {% endwith %} + {% with finding.cwes|slice:"1:" as additional_cwes %} + {% if additional_cwes %} +
+ + + + + + + +
Additional CWEs
+ {% for cwe in additional_cwes %} + + {{ cwe }} + {% if not forloop.last %}, {% endif %} + {% endfor %} +
+
+ {% endif %} + {% endwith %} + {% if finding.static_finding or finding.line > 0 %} {% if finding.sast_source_object or finding.sast_sink_object or finding.sast_source_file_path or finding.sast_source_line > 0 %} {# For tools that give information on both source (start) and sink (end) of the attack vector #} diff --git a/dojo/templates_classic/dojo/ad_hoc_findings.html b/dojo/templates_classic/dojo/ad_hoc_findings.html index c43a60b3d91..891c556b432 100644 --- a/dojo/templates_classic/dojo/ad_hoc_findings.html +++ b/dojo/templates_classic/dojo/ad_hoc_findings.html @@ -73,7 +73,7 @@

Github

elem.id = "req" } - if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && !$(elem).hasClass('select2-search__field')) { + if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && elem.name != 'cwes' && !$(elem).hasClass('select2-search__field')) { var mde = new EasyMDE({ spellChecker: false, inputStyle: "contenteditable", diff --git a/dojo/templates_classic/dojo/add_findings.html b/dojo/templates_classic/dojo/add_findings.html index 9a552cb0fd6..2656b12070f 100644 --- a/dojo/templates_classic/dojo/add_findings.html +++ b/dojo/templates_classic/dojo/add_findings.html @@ -120,7 +120,7 @@

JIRA

elem.id = "req" } - if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && !$(elem).hasClass('select2-search__field')) { + if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && elem.name != 'cwes' && !$(elem).hasClass('select2-search__field')) { var mde = new EasyMDE({ spellChecker: false, inputStyle: "contenteditable", diff --git a/dojo/templates_classic/dojo/edit_finding.html b/dojo/templates_classic/dojo/edit_finding.html index 17e8c5e9170..21df42aa3de 100644 --- a/dojo/templates_classic/dojo/edit_finding.html +++ b/dojo/templates_classic/dojo/edit_finding.html @@ -180,7 +180,7 @@

GitHub

elem.id = "req" } - if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && !$(elem).hasClass('select2-search__field')) { + if (elem.name != 'endpoints_to_add' && elem.name != 'vulnerability_ids' && elem.name != 'cwes' && !$(elem).hasClass('select2-search__field')) { var mde = new EasyMDE({ spellChecker: false, inputStyle: "contenteditable", diff --git a/dojo/templates_classic/dojo/view_finding.html b/dojo/templates_classic/dojo/view_finding.html index 205a0c7c58f..54b6887e8ec 100755 --- a/dojo/templates_classic/dojo/view_finding.html +++ b/dojo/templates_classic/dojo/view_finding.html @@ -441,9 +441,10 @@

+
{% with finding|additional_vulnerability_ids as additional_vulnerability_ids %} {% if additional_vulnerability_ids %} -
+
@@ -464,7 +465,28 @@

Additional Vulnerability Ids
{% endif %} - {% endwith %} + {% endwith %} + {% with finding.cwes|slice:"1:" as additional_cwes %} + {% if additional_cwes %} +
+ + + + + + + +
Additional CWEs
+ {% for cwe in additional_cwes %} + + {{ cwe }} + {% if not forloop.last %}, {% endif %} + {% endfor %} +
+
+ {% endif %} + {% endwith %} +
{% if finding.static_finding or finding.line > 0 %} {% if finding.sast_source_object or finding.sast_sink_object or finding.sast_source_file_path or finding.sast_source_line > 0 %} diff --git a/dojo/tools/acunetix/parse_acunetix360_json.py b/dojo/tools/acunetix/parse_acunetix360_json.py index 564be900285..98264bba34e 100644 --- a/dojo/tools/acunetix/parse_acunetix360_json.py +++ b/dojo/tools/acunetix/parse_acunetix360_json.py @@ -67,13 +67,14 @@ def get_findings(self, filename, test): for item in data["Vulnerabilities"]: title = item["Name"] findingdetail = text_maker.handle(item.get("Description", "")) + cwes = [] if item["Classification"] is not None and "Cwe" in item["Classification"]: - try: - cwe = int(item["Classification"]["Cwe"].split(",")[0]) - except BaseException: - cwe = None - else: - cwe = None + for cwe_part in item["Classification"]["Cwe"].split(","): + try: + cwes.append(int(cwe_part.strip())) + except (ValueError, TypeError): + continue + cwe = cwes[0] if cwes else None sev = item["Severity"] if sev not in {"Info", "Low", "Medium", "High", "Critical"}: sev = "Info" @@ -117,6 +118,8 @@ def get_findings(self, filename, test): cwe=cwe, static_finding=True, ) + if cwes: + finding.unsaved_cwes = cwes if ( (item["Classification"] is not None) and (item["Classification"]["Cvss"] is not None) diff --git a/dojo/tools/api_edgescan/parser.py b/dojo/tools/api_edgescan/parser.py index 36885fcfeb2..0711c66a380 100644 --- a/dojo/tools/api_edgescan/parser.py +++ b/dojo/tools/api_edgescan/parser.py @@ -48,7 +48,15 @@ def make_finding(self, test, vulnerability): finding.title = vulnerability["name"] finding.date = vulnerability["date_opened"][:10] if vulnerability["cwes"]: - finding.cwe = int(vulnerability["cwes"][0][4:]) + cwes = [] + for cwe in vulnerability["cwes"]: + try: + cwes.append(int(cwe[4:])) + except (ValueError, TypeError): + continue + if cwes: + finding.cwe = cwes[0] + finding.unsaved_cwes = cwes if vulnerability["cves"]: finding.unsaved_vulnerability_ids = vulnerability["cves"] if vulnerability["cvss_version"] == 3: diff --git a/dojo/tools/api_sonarqube/importer.py b/dojo/tools/api_sonarqube/importer.py index d0ae635cfa0..32d9ec884ef 100644 --- a/dojo/tools/api_sonarqube/importer.py +++ b/dojo/tools/api_sonarqube/importer.py @@ -198,11 +198,13 @@ def import_issues(self, test): description = self.clean_rule_description_html( rule_details, ) - cwe = self.clean_cwe(rule_details) + cwes = self.clean_cwes(rule_details) + cwe = cwes[0] if cwes else None references = sonarqube_permalink + self.get_references(rule_details) else: description = "" cwe = None + cwes = [] references = sonarqube_permalink sonarqube_issue, _ = Sonarqube_Issue.objects.update_or_create( @@ -240,6 +242,8 @@ def import_issues(self, test): sonarqube_issue=sonarqube_issue, unique_id_from_tool=issue.get("key"), ) + if cwes: + find.unsaved_cwes = cwes items.append(find) except Exception as e: @@ -321,7 +325,8 @@ def import_hotspots(self, test): "vulnerabilityDescription", "No description provided.", ), ) - cwe = self.clean_cwe(rule.get("fixRecommendations", "")) + cwes = self.clean_cwes(rule.get("fixRecommendations", "")) + cwe = cwes[0] if cwes else None try: sonarqube_permalink = f"[Hotspot permalink]({sonarUrl}security_hotspots?id={hotspot['project']}&hotspots={hotspot['key']}) \n" except KeyError: @@ -361,6 +366,8 @@ def import_hotspots(self, test): sonarqube_issue=sonarqube_issue, unique_id_from_tool=f"hotspot:{hotspot.get('key')}", ) + if cwes: + find.unsaved_cwes = cwes items.append(find) except Exception as e: @@ -392,11 +399,16 @@ def clean_rule_description_html(raw_html): return h.handle(raw_html) @staticmethod - def clean_cwe(raw_html): - search = re.search(r"CWE-(\d+)", raw_html) - if search: - return int(search.group(1)) - return None + def clean_cwes(raw_html): + # A single SonarQube rule can reference multiple CWEs (e.g. in the "See" section). + seen = set() + cwes = [] + for match in re.findall(r"CWE-(\d+)", raw_html): + cwe = int(match) + if cwe not in seen: + seen.add(cwe) + cwes.append(cwe) + return cwes @staticmethod def get_rule_details(rule): diff --git a/dojo/tools/aws_inspector2/parser.py b/dojo/tools/aws_inspector2/parser.py index 2174d06e977..d76927165d4 100644 --- a/dojo/tools/aws_inspector2/parser.py +++ b/dojo/tools/aws_inspector2/parser.py @@ -186,6 +186,8 @@ def get_code_vulnerability(self, finding: Finding, raw_finding: dict) -> Finding string_cwes = ", ".join(cwes) # populate fields finding.cwe = cwes[0] if cwes else None + if cwes: + finding.unsaved_cwes = cwes finding.file_path = f"{file_path}{file_name}" finding.sast_source_file_path = f"{file_path}{file_name}" finding.line = start_line diff --git a/dojo/tools/bearer_cli/parser.py b/dojo/tools/bearer_cli/parser.py index 4fafeb26861..2e4a5f289cf 100644 --- a/dojo/tools/bearer_cli/parser.py +++ b/dojo/tools/bearer_cli/parser.py @@ -49,6 +49,8 @@ def get_findings(self, file, test): # the fingerprint is not constant over time, but because it's not used for dedupe it's safe and useful to set it unique_id_from_tool=bearerfinding["fingerprint"], ) + if bearerfinding["cwe_ids"]: + finding.unsaved_cwes = bearerfinding["cwe_ids"] items.append(finding) diff --git a/dojo/tools/burp/parser.py b/dojo/tools/burp/parser.py index 7cb73283d1d..f2dcc5e33bf 100644 --- a/dojo/tools/burp/parser.py +++ b/dojo/tools/burp/parser.py @@ -40,7 +40,7 @@ def get_fields(self) -> list[str]: - impact: Set to background returned by Burp Scanner. - unique_id_from_tool: Set to serial_number returned by Burp Scanner. - vuln_id_from_tool: Taken from output of Burp Scanner. - - cwe: Set to cwe outputted from Burp Scanner. Multiple cwes is not supported by parser. + - cwe: Set to primary (first) cwe outputted from Burp Scanner. Additional cwes are stored in unsaved_cwes. """ return [ "title", @@ -66,7 +66,7 @@ def get_dedupe_fields(self) -> list[str]: Fields: - title: Made using Burp scanner output's name. - - cwe: Set to cwe outputted from Burp Scanner. Multiple cwes is not supported by parser. + - cwe: Set to primary (first) cwe outputted from Burp Scanner. Additional cwes are stored in unsaved_cwes. - description: Made by combining URL, url_host, path, and detail. NOTE: uses legacy dedupe: ['title', 'cwe', 'line', 'file_path', 'description'] @@ -306,11 +306,7 @@ def get_item(item_node, test): finding.unsaved_endpoints = [Endpoint.from_uri(url_host)] # manage cwes cwes = do_clean_cwe(item_node.findall("vulnerabilityClassifications")) - if len(cwes) > 1: - # TODO: support more than one CWE - logger.debug( - "more than one CWE for a finding %s. NOT supported by parser API", cwes, - ) if len(cwes) > 0: finding.cwe = cwes[0] + finding.unsaved_cwes = cwes return finding diff --git a/dojo/tools/burp_graphql/parser.py b/dojo/tools/burp_graphql/parser.py index 78d3f3f9761..55bf7fd7678 100644 --- a/dojo/tools/burp_graphql/parser.py +++ b/dojo/tools/burp_graphql/parser.py @@ -53,6 +53,10 @@ def create_findings(self, scan_data, test): nb_occurences=1, ) + cwes = issue.get("CWEs") + if cwes: + find.unsaved_cwes = cwes + find.unsaved_req_resp = issue.get("Evidence") if settings.V3_FEATURE_LOCATIONS: find.unsaved_locations = issue.get("Locations") @@ -173,11 +177,13 @@ def create_finding(self, issue): finding["References"] += html2text.html2text( issue["issue_type"].get("vulnerability_classifications_html"), ) - finding["CWE"] = self.get_cwe( + finding["CWEs"] = self.get_cwes( issue["issue_type"].get("vulnerability_classifications_html"), ) + finding["CWE"] = finding["CWEs"][0] if finding["CWEs"] else 0 else: finding["CWE"] = 0 + finding["CWEs"] = [] return finding @@ -229,9 +235,6 @@ def parse_evidence(self, evidence): return req_resp_list - def get_cwe(self, cwe_html): - # Match only the first CWE! - cweSearch = re.search(r"CWE-([0-9]*)", cwe_html, re.IGNORECASE) - if cweSearch: - return cweSearch.group(1) - return 0 + def get_cwes(self, cwe_html): + # Collect all CWEs; the first is used as the primary cwe. + return [int(match) for match in re.findall(r"CWE-([0-9]+)", cwe_html, re.IGNORECASE)] diff --git a/dojo/tools/burp_suite_dast/parser.py b/dojo/tools/burp_suite_dast/parser.py index 93fa8c2b9b8..acf3bfa0a41 100644 --- a/dojo/tools/burp_suite_dast/parser.py +++ b/dojo/tools/burp_suite_dast/parser.py @@ -164,11 +164,15 @@ def _set_or_append_content(self, finding_details: dict, header: str, div_element if header.lower() == "vulnerability classifications": for item in data_list: cleaned_item = item.split(":")[0] - if ( - finding_details["cwe"] is None - and (cwe_search := re.search(r"CWE-([0-9]*)", cleaned_item, re.IGNORECASE)) - ): - finding_details["cwe"] = int(cwe_search.group(1)) + if cwe_search := re.search(r"CWE-([0-9]*)", cleaned_item, re.IGNORECASE): + cwe = int(cwe_search.group(1)) + # First CWE stays the primary; collect all in cwes. + if finding_details["cwe"] is None: + finding_details["cwe"] = cwe + if "cwes" not in finding_details: + finding_details["cwes"] = [cwe] + else: + finding_details["cwes"].append(cwe) if "vulnerability_ids" not in finding_details: finding_details["vulnerability_ids"] = [cleaned_item] else: @@ -235,6 +239,7 @@ def create_findings(self, findings_dict: dict[str, dict], test): endpoints = finding_dict.pop("endpoints", []) request_response_pairs = finding_dict.pop("request_response_pairs", []) vulnerability_ids = finding_dict.pop("vulnerability_ids", []) + cwes = finding_dict.pop("cwes", []) # Crete the finding from the rest of the dict finding = Finding( test=test, @@ -261,6 +266,9 @@ def create_findings(self, findings_dict: dict[str, dict], test): ] # Vulnerability IDs finding.unsaved_vulnerability_ids = vulnerability_ids + # CWEs (primary stays on finding.cwe) + if cwes: + finding.unsaved_cwes = cwes # Add the finding to the final list findings.append(finding) diff --git a/dojo/tools/cyberwatch_galeax/parser.py b/dojo/tools/cyberwatch_galeax/parser.py index f167934f17b..252b28c549a 100644 --- a/dojo/tools/cyberwatch_galeax/parser.py +++ b/dojo/tools/cyberwatch_galeax/parser.py @@ -338,6 +338,8 @@ def create_finding( finding.unsaved_vulnerability_ids = [cve_code] if cwe_num is not None: finding.cwe = cwe_num + all_cwes = [cwe_num, *(additional_cwes or [])] + finding.unsaved_cwes = all_cwes if epss and epss != "N/A": try: finding.epss_score = float(epss) diff --git a/dojo/tools/cyclonedx/json_parser.py b/dojo/tools/cyclonedx/json_parser.py index e7c70529715..1807ae2009e 100644 --- a/dojo/tools/cyclonedx/json_parser.py +++ b/dojo/tools/cyclonedx/json_parser.py @@ -134,13 +134,9 @@ def _get_findings_json(self, file, test): finding.unsaved_vulnerability_ids = vulnerability_ids # if there is some CWE cwes = vulnerability.get("cwes") - if cwes and len(cwes) > 1: - # TODO: support more than one CWE - LOGGER.debug( - "more than one CWE for a finding %s. NOT supported by parser API", cwes, - ) if cwes and len(cwes) > 0: finding.cwe = cwes[0] + finding.unsaved_cwes = cwes # Check for mitigation analysis = vulnerability.get("analysis") if analysis: diff --git a/dojo/tools/cyclonedx/xml_parser.py b/dojo/tools/cyclonedx/xml_parser.py index 682c0fa8f2c..72cb53a953d 100644 --- a/dojo/tools/cyclonedx/xml_parser.py +++ b/dojo/tools/cyclonedx/xml_parser.py @@ -189,13 +189,9 @@ def manage_vulnerability_legacy( finding.severity = cvssv3.severities()[0] # if there is some CWE cwes = self.get_cwes(vulnerability, "v", ns) - if len(cwes) > 1: - # TODO: support more than one CWE - LOGGER.debug( - "more than one CWE for a finding %s. NOT supported by parser API", cwes, - ) if len(cwes) > 0: finding.cwe = cwes[0] + finding.unsaved_cwes = cwes vulnerability_ids = [] # set id as first vulnerability id if vuln_id: @@ -315,13 +311,9 @@ def _manage_vulnerability_xml( cwes = self.get_cwes(vulnerability, "v", ns) if not cwes: cwes = self.get_cwes(vulnerability, "b", ns) - if len(cwes) > 1: - # TODO: support more than one CWE - LOGGER.debug( - "more than one CWE for a finding %s. NOT supported by parser API", cwes, - ) if len(cwes) > 0: finding.cwe = cwes[0] + finding.unsaved_cwes = cwes # Check for mitigation analysis = vulnerability.findall("b:analysis", namespaces=ns) if analysis and len(analysis) == 1: diff --git a/dojo/tools/dependency_check/parser.py b/dojo/tools/dependency_check/parser.py index 03590bdd02d..34a5dcbcf95 100644 --- a/dojo/tools/dependency_check/parser.py +++ b/dojo/tools/dependency_check/parser.py @@ -331,12 +331,16 @@ def get_finding_from_vulnerability( mitigated = None is_Mitigated = False name = vulnerability.findtext(f"{namespace}name") - if vulnerability.find(f"{namespace}cwes") is not None: - cwe_field = vulnerability.find(f"{namespace}cwes").findtext( - f"{namespace}cwe", - ) + cwe_fields = [] + if (cwes_node := vulnerability.find(f"{namespace}cwes")) is not None: + cwe_fields = [ + cwe_node.text for cwe_node in cwes_node.findall(f"{namespace}cwe") if cwe_node.text + ] + cwe_field = cwe_fields[0] if cwe_fields else None else: cwe_field = vulnerability.findtext(f"{namespace}cwe") + if cwe_field: + cwe_fields = [cwe_field] description = vulnerability.findtext(f"{namespace}description") @@ -356,10 +360,13 @@ def get_finding_from_vulnerability( # Use CWE-1035 as fallback cwe = 1035 # Vulnerable Third Party Component - if cwe_field: - m = re.match(r"^(CWE-)?(\d+)", cwe_field) + parsed_cwes = [] + for cwe_value in cwe_fields: + m = re.match(r"^(CWE-)?(\d+)", cwe_value) if m: - cwe = int(m.group(2)) + parsed_cwes.append(int(m.group(2))) + if parsed_cwes: + cwe = parsed_cwes[0] ( component_name, @@ -450,6 +457,9 @@ def get_finding_from_vulnerability( **self.get_severity_and_cvss_meta(vulnerability, namespace), ) + if parsed_cwes: + finding.unsaved_cwes = parsed_cwes + finding.unsaved_tags = tags if settings.V3_FEATURE_LOCATIONS and component_purl: diff --git a/dojo/tools/generic/csv_parser.py b/dojo/tools/generic/csv_parser.py index eddc449c21a..510b28fdc44 100644 --- a/dojo/tools/generic/csv_parser.py +++ b/dojo/tools/generic/csv_parser.py @@ -6,6 +6,7 @@ from dateutil.parser import parse from django.conf import settings +from dojo.finding.cwe import cwe_number, parse_cwes from dojo.models import Endpoint, Finding from dojo.tools.locations import LocationData @@ -71,6 +72,14 @@ def _get_findings_csv(self, filename): # manage CWE if "CweId" in row: finding.cwe = int(row["CweId"]) + # manage multiple CWEs (comma/space separated column), keeping the + # primary on finding.cwe; the full set is persisted via unsaved_cwes. + if row.get("CweIds"): + cwes = parse_cwes(row["CweIds"]) + if cwes: + if not finding.cwe: + finding.cwe = cwe_number(cwes[0]) + finding.unsaved_cwes = cwes if "epss_score" in row: finding.epss_score = float(row["epss_score"]) @@ -139,6 +148,11 @@ def _get_findings_csv(self, filename): find.unsaved_vulnerability_ids = ( finding.unsaved_vulnerability_ids ) + if finding.unsaved_cwes: + if find.unsaved_cwes: + find.unsaved_cwes.extend(finding.unsaved_cwes) + else: + find.unsaved_cwes = finding.unsaved_cwes find.nb_occurences += 1 else: dupes[key] = finding diff --git a/dojo/tools/generic/json_parser.py b/dojo/tools/generic/json_parser.py index c177db5fc58..eebef631781 100644 --- a/dojo/tools/generic/json_parser.py +++ b/dojo/tools/generic/json_parser.py @@ -4,6 +4,7 @@ from django.conf import settings from django.core.files.base import ContentFile +from dojo.finding.cwe import cwe_number from dojo.models import Endpoint, FileUpload, Finding from dojo.tools.locations import LocationData from dojo.tools.parser_test import ParserTest @@ -44,6 +45,12 @@ def _get_test_json(self, data): if "vulnerability_ids" in item: unsaved_vulnerability_ids = item["vulnerability_ids"] del item["vulnerability_ids"] + # remove cwes from the dictionary (multiple CWEs per finding). + # "cwes" is not a Finding field, so it is popped like vulnerability_ids. + unsaved_cwes = None + if "cwes" in item: + unsaved_cwes = item["cwes"] + del item["cwes"] # check for required keys required = {"title", "severity", "description"} @@ -176,5 +183,12 @@ def _get_test_json(self, data): finding.unsaved_vulnerability_ids = ( unsaved_vulnerability_ids ) + # multiple CWEs: keep the primary on finding.cwe (only if not already + # supplied via "cwe") and persist the full set via unsaved_cwes. The + # import pipeline normalizes/deduplicates through finding_cwe_labels(). + if unsaved_cwes: + if not finding.cwe: + finding.cwe = cwe_number(unsaved_cwes[0]) + finding.unsaved_cwes = unsaved_cwes test_internal.findings.append(finding) return test_internal diff --git a/dojo/tools/github_vulnerability/parser.py b/dojo/tools/github_vulnerability/parser.py index 4b9a53afed7..41f810bd219 100644 --- a/dojo/tools/github_vulnerability/parser.py +++ b/dojo/tools/github_vulnerability/parser.py @@ -72,6 +72,9 @@ def get_findings(self, filename, test): cwe_id = cwes[0].get("cweId", "")[4:] if cwe_id.isdigit(): finding.cwe = int(cwe_id) + unsaved_cwes = [node.get("cweId") for node in cwes if node.get("cweId")] + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes if alert.get("vulnerableManifestPath"): finding.file_path = alert.get("vulnerableManifestPath") diff --git a/dojo/tools/gitlab_container_scan/parser.py b/dojo/tools/gitlab_container_scan/parser.py index 048261fcc69..d49b4fb6079 100644 --- a/dojo/tools/gitlab_container_scan/parser.py +++ b/dojo/tools/gitlab_container_scan/parser.py @@ -105,13 +105,17 @@ def get_findings(self, file, test): # Add component fields if not empty unsaved_vulnerability_ids = [] + unsaved_cwes = [] for identifier in identifiers: cve = self._get_identifier_cve(identifier) if cve: unsaved_vulnerability_ids.append(cve) cwe = self._get_identifier_cwe(identifier) if cwe: - finding.cwe = cwe + unsaved_cwes.append(cwe) + if unsaved_cwes: + finding.cwe = unsaved_cwes[0] + finding.unsaved_cwes = unsaved_cwes if unsaved_vulnerability_ids: finding.unsaved_vulnerability_ids = unsaved_vulnerability_ids diff --git a/dojo/tools/gitlab_dast/parser.py b/dojo/tools/gitlab_dast/parser.py index 3e16916e118..dcf2c9672c7 100644 --- a/dojo/tools/gitlab_dast/parser.py +++ b/dojo/tools/gitlab_dast/parser.py @@ -112,10 +112,14 @@ def get_item(self, vuln, test, scanner): vuln.get("name", finding.unique_id_from_tool) ) # cwe - for identifier in vuln["identifiers"]: - if identifier["type"].lower() == "cwe": - finding.cwe = int(identifier["value"]) - break + unsaved_cwes = [ + int(identifier["value"]) + for identifier in vuln["identifiers"] + if identifier["type"].lower() == "cwe" + ] + if unsaved_cwes: + finding.cwe = unsaved_cwes[0] + finding.unsaved_cwes = unsaved_cwes # references if vuln["links"]: diff --git a/dojo/tools/gitlab_dep_scan/parser.py b/dojo/tools/gitlab_dep_scan/parser.py index 9db9d2a6376..6475bf3b78d 100644 --- a/dojo/tools/gitlab_dep_scan/parser.py +++ b/dojo/tools/gitlab_dep_scan/parser.py @@ -105,12 +105,14 @@ def get_item(self, vuln, test, scan): mitigation = vuln["solution"] cwe = None + unsaved_cwes = [] vulnerability_id = None references = "" if "identifiers" in vuln: for identifier in vuln["identifiers"]: if identifier["type"].lower() == "cwe": cwe = identifier["value"] + unsaved_cwes.append(identifier["value"]) elif identifier["type"].lower() == "cve": vulnerability_id = identifier["value"] else: @@ -121,6 +123,9 @@ def get_item(self, vuln, test, scan): references += f"URL: {identifier['url']}\n" references += "\n" + if unsaved_cwes: + cwe = unsaved_cwes[0] + finding = Finding( title=f"{vulnerability_id}: {title}" if vulnerability_id @@ -140,6 +145,9 @@ def get_item(self, vuln, test, scan): dynamic_finding=False, ) + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes + if vulnerability_id: finding.unsaved_vulnerability_ids = [vulnerability_id] diff --git a/dojo/tools/gitlab_sast/parser.py b/dojo/tools/gitlab_sast/parser.py index 456e5b98c6c..8c8ccaf560f 100644 --- a/dojo/tools/gitlab_sast/parser.py +++ b/dojo/tools/gitlab_sast/parser.py @@ -123,6 +123,7 @@ def get_item(self, vuln, scanner): mitigation = vuln.get("solution", "") cwe = None + unsaved_cwes = [] vulnerability_id = None references = "" if "identifiers" in vuln: @@ -130,8 +131,10 @@ def get_item(self, vuln, scanner): if identifier["type"].lower() == "cwe": if isinstance(identifier["value"], int): cwe = identifier["value"] + unsaved_cwes.append(identifier["value"]) elif identifier["value"].isdigit(): cwe = int(identifier["value"]) + unsaved_cwes.append(int(identifier["value"])) elif identifier["type"].lower() == "cve": vulnerability_id = identifier["value"] else: @@ -156,9 +159,11 @@ def get_item(self, vuln, scanner): sast_sink_object=sast_object, sast_source_file_path=sast_source_file_path, sast_source_line=sast_source_line, - cwe=cwe, + cwe=unsaved_cwes[0] if unsaved_cwes else cwe, static_finding=True, dynamic_finding=False) + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes if vulnerability_id: finding.unsaved_vulnerability_ids = [vulnerability_id] diff --git a/dojo/tools/harbor_vulnerability/parser.py b/dojo/tools/harbor_vulnerability/parser.py index 44cabf2d3cc..85ba8607b22 100644 --- a/dojo/tools/harbor_vulnerability/parser.py +++ b/dojo/tools/harbor_vulnerability/parser.py @@ -64,6 +64,7 @@ def get_findings(self, filename, test): references = None cwe = cwe_ids[0].strip("CWE-") if cwe_ids and cwe_ids[0] else None + unsaved_cwes = [cwe_id for cwe_id in cwe_ids if cwe_id] if cwe_ids else [] vulnerability_id = item_id if item_id and item_id.startswith("CVE") else None @@ -89,6 +90,8 @@ def get_findings(self, filename, test): ) if vulnerability_id: find.unsaved_vulnerability_ids = [vulnerability_id] + if unsaved_cwes: + find.unsaved_cwes = unsaved_cwes dupes[dupe_key] = find return list(dupes.values()) diff --git a/dojo/tools/jfrog_xray_api_summary_artifact/parser.py b/dojo/tools/jfrog_xray_api_summary_artifact/parser.py index 2e8cba494a4..df6d8aedd0e 100644 --- a/dojo/tools/jfrog_xray_api_summary_artifact/parser.py +++ b/dojo/tools/jfrog_xray_api_summary_artifact/parser.py @@ -64,6 +64,7 @@ def get_item( ): cve = None cwe = None + unsaved_cwes = [] cvssv3 = None impact_path = ImpactPath("", "", "") @@ -79,6 +80,7 @@ def get_item( if cves: if len(cves[0].get("cwe", [])) > 0: cwe = decode_cwe_number(cves[0].get("cwe", [])[0]) + unsaved_cwes = [decode_cwe_number(value) for value in cves[0].get("cwe", [])] if "cvss_v3" in cves[0]: cvss_v3 = cves[0]["cvss_v3"] # Note: Xray sometimes takes over malformed cvss scores like `5.9` that can not be parsed. @@ -143,6 +145,8 @@ def get_item( vulnerability_ids.append(vulnerability["issue_id"]) if vulnerability_ids: finding.unsaved_vulnerability_ids = vulnerability_ids + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes if settings.V3_FEATURE_LOCATIONS and (artifact_name or artifact_version or len(impact_paths) > 0): impact_path = impact_paths[0] if len(impact_paths) > 0 else "" diff --git a/dojo/tools/jfrogxray/parser.py b/dojo/tools/jfrogxray/parser.py index 9fcea8a5b22..a850abdd9bd 100644 --- a/dojo/tools/jfrogxray/parser.py +++ b/dojo/tools/jfrogxray/parser.py @@ -75,6 +75,7 @@ def get_item(vulnerability, test): vulnerability_ids = [] cwe = None + unsaved_cwes = [] cvssv3 = None cvss_v3 = "No CVSS v3 score." mitigation = None @@ -88,6 +89,7 @@ def get_item(vulnerability, test): # take only the first one for now, limitation of DD model. if len(cves[0].get("cwe", [])) > 0: cwe = decode_cwe_number(cves[0].get("cwe", [])[0]) + unsaved_cwes = [decode_cwe_number(value) for value in cves[0].get("cwe", [])] if "cvss_v3" in cves[0]: cvss_v3 = cves[0]["cvss_v3"] # this dedicated package will clean the vector @@ -168,6 +170,8 @@ def get_item(vulnerability, test): ) if vulnerability_ids: finding.unsaved_vulnerability_ids = vulnerability_ids + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes if settings.V3_FEATURE_LOCATIONS and component_name and component_version: finding.unsaved_locations.append( diff --git a/dojo/tools/netsparker/parser.py b/dojo/tools/netsparker/parser.py index 1859b21ebbe..547aaa7a554 100644 --- a/dojo/tools/netsparker/parser.py +++ b/dojo/tools/netsparker/parser.py @@ -51,11 +51,13 @@ def get_findings(self, filename, test): for item in data["Vulnerabilities"]: title = item["Name"] findingdetail = html2text.html2text(item.get("Description", "")) - if "Cwe" in item["Classification"]: + unsaved_cwes = [] + if item["Classification"].get("Cwe"): try: cwe = int(item["Classification"]["Cwe"].split(",")[0]) except Exception: cwe = None + unsaved_cwes = [value.strip() for value in item["Classification"]["Cwe"].split(",") if value.strip()] else: cwe = None sev = item["Severity"] @@ -82,6 +84,8 @@ def get_findings(self, filename, test): cwe=cwe, static_finding=True, ) + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes state = item.get("State", None) if state == "FalsePositive": finding.active = False diff --git a/dojo/tools/npm_audit/parser.py b/dojo/tools/npm_audit/parser.py index 09086f1b716..b0d0dd1f607 100644 --- a/dojo/tools/npm_audit/parser.py +++ b/dojo/tools/npm_audit/parser.py @@ -6,7 +6,7 @@ from dojo.models import Finding from dojo.tools.locations import LocationData -from dojo.tools.utils import get_npm_cwe +from dojo.tools.utils import get_npm_cwe, get_npm_cwes logger = logging.getLogger(__name__) @@ -101,6 +101,7 @@ def get_item(item_node, test): paths += "\n - ..... (list of paths truncated after 25 paths)" cwe = get_npm_cwe(item_node) + cwes = get_npm_cwes(item_node) try: filepath = censor_path_hashes(item_node["findings"][0]["paths"][0]) except IndexError: @@ -145,6 +146,9 @@ def get_item(item_node, test): dynamic_finding=False, ) + if cwes: + dojo_finding.unsaved_cwes = cwes + if len(item_node["cves"]) > 0: dojo_finding.unsaved_vulnerability_ids = [] for vulnerability_id in item_node["cves"]: diff --git a/dojo/tools/npm_audit_7_plus/parser.py b/dojo/tools/npm_audit_7_plus/parser.py index 653266a12bc..71fc3ffc689 100644 --- a/dojo/tools/npm_audit_7_plus/parser.py +++ b/dojo/tools/npm_audit_7_plus/parser.py @@ -101,6 +101,7 @@ def get_item(item_node, tree, test): unique_id_from_tool = "" cvssv3 = "" cwe = "" + cwes = [] if item_node["severity"] == "low": severity = "Low" @@ -122,7 +123,9 @@ def get_item(item_node, tree, test): elif item_node["via"] and isinstance(item_node["via"][0], dict): title = item_node["via"][0]["title"] component_name = item_node["nodes"][0] - cwe = item_node["via"][0]["cwe"][0] if len(item_node["via"][0]["cwe"]) > 0 else None + via_cwes = item_node["via"][0]["cwe"] + cwe = via_cwes[0] if len(via_cwes) > 0 else None + cwes = [int(c.split("-")[1]) for c in via_cwes if c] references.append(item_node["via"][0]["url"]) unique_id_from_tool = str(item_node["via"][0]["source"]) cvssv3 = item_node["via"][0]["cvss"]["vectorString"] @@ -166,6 +169,9 @@ def get_item(item_node, tree, test): cwe = int(cwe.split("-")[1]) dojo_finding.cwe = cwe + if cwes: + dojo_finding.unsaved_cwes = cwes + if (cvssv3 is not None) and (len(cvssv3) > 0): cvss_data = parse_cvss_data(cvssv3) if cvss_data: diff --git a/dojo/tools/nuclei/parser.py b/dojo/tools/nuclei/parser.py index 72bf47b7264..a0f2ba0cbc3 100644 --- a/dojo/tools/nuclei/parser.py +++ b/dojo/tools/nuclei/parser.py @@ -103,7 +103,8 @@ def get_findings(self, filename, test): if ( classification.get("cwe-id") ): - cwe = classification["cwe-id"][0] + cwe_ids = classification["cwe-id"] + cwe = cwe_ids[0] try: finding.cwe = int(cwe[4:]) except ValueError: @@ -111,6 +112,8 @@ def get_findings(self, filename, test): ignore CWE if non-int several older templates such as https://github.com/projectdiscovery/nuclei-templates/blob/6636c0d2dd540645cc3472822beb4b3819ff8322/http/cves/2004/CVE-2004-0519.yaml#L21 """ + if cwe_ids: + finding.unsaved_cwes = cwe_ids if classification.get("cvss-metrics"): cvss_objects = cvss_parser.parse_cvss_from_text( classification["cvss-metrics"], diff --git a/dojo/tools/osv_scanner/parser.py b/dojo/tools/osv_scanner/parser.py index e44351d5c02..252d35c9c7a 100644 --- a/dojo/tools/osv_scanner/parser.py +++ b/dojo/tools/osv_scanner/parser.py @@ -90,6 +90,7 @@ def get_findings(self, file, test): vulnerabilitydetails = vulnerability.get("details", "") vulnerabilitypackagepurl = "" cwe = None + unsaved_cwes = [] mitigations_by_type = {} # Dictionary to store corrected versions by type # Make sure we have an affected section to work with @@ -100,6 +101,7 @@ def get_findings(self, file, test): vulnerabilitypackagepurl = vulnerabilitypackage.get("purl", "") # Extract the CWE if (cwe := affected[0].get("database_specific", {}).get("cwes", None)) is not None: + unsaved_cwes = [entry["cweId"] for entry in cwe if entry.get("cweId")] cwe = cwe[0]["cweId"] # Extraction of corrected versions by type ranges = affected[0].get("ranges", []) @@ -157,6 +159,8 @@ def get_findings(self, file, test): if vulnerabilityid: finding.unsaved_vulnerability_ids = [vulnerabilityid] + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes if settings.V3_FEATURE_LOCATIONS and (dep := self.get_dependency_info(package_ecosystem, package_name, package_version)): finding.unsaved_locations.append(dep) findings.append(finding) diff --git a/dojo/tools/ptart/assessment_parser.py b/dojo/tools/ptart/assessment_parser.py index 49a1ce1707d..b6a5f89a58e 100644 --- a/dojo/tools/ptart/assessment_parser.py +++ b/dojo/tools/ptart/assessment_parser.py @@ -57,6 +57,9 @@ def get_finding(self, assessment, hit): finding.unsaved_tags = hit["labels"] finding.cwe = ptart_tools.parse_cwe_from_hit(hit) + unsaved_cwes = ptart_tools.parse_cwes_from_hit(hit) + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes if settings.V3_FEATURE_LOCATIONS: finding.unsaved_locations = ptart_tools.parse_locations_from_hit(hit) diff --git a/dojo/tools/ptart/ptart_parser_tools.py b/dojo/tools/ptart/ptart_parser_tools.py index 64ed693f036..c58781d3de4 100644 --- a/dojo/tools/ptart/ptart_parser_tools.py +++ b/dojo/tools/ptart/ptart_parser_tools.py @@ -207,9 +207,24 @@ def parse_cwe_from_hit(hit): if "cwes" not in hit or not hit["cwes"]: return None cwes = hit["cwes"] + if not (isinstance(cwes, list) and len(cwes) > 0): + return None # Grab the last CWE in the list, as it's sorted in numerical order, # and the last element is generally the most specific. - return parse_cwe_id_from_cwe(cwes.pop()) if cwes and isinstance(cwes, list) and len(cwes) > 0 else None + return parse_cwe_id_from_cwe(cwes[-1]) + + +def parse_cwes_from_hit(hit): + # Return the full list of CWE ids for the hit (empties dropped), + # preserving order. The primary CWE remains the last element (see + # parse_cwe_from_hit). + if "cwes" not in hit or not hit["cwes"]: + return [] + cwes = hit["cwes"] + if not isinstance(cwes, list): + return [] + parsed = [parse_cwe_id_from_cwe(cwe) for cwe in cwes] + return [cwe_id for cwe_id in parsed if cwe_id is not None] def parse_cwe_id_from_cwe(cwe): diff --git a/dojo/tools/ptart/retest_parser.py b/dojo/tools/ptart/retest_parser.py index c34f0767e13..84a812668a1 100644 --- a/dojo/tools/ptart/retest_parser.py +++ b/dojo/tools/ptart/retest_parser.py @@ -91,6 +91,9 @@ def get_finding(self, retest, hit): finding.unsaved_tags = original_hit["labels"] finding.cwe = ptart_tools.parse_cwe_from_hit(original_hit) + unsaved_cwes = ptart_tools.parse_cwes_from_hit(original_hit) + if unsaved_cwes: + finding.unsaved_cwes = unsaved_cwes if settings.V3_FEATURE_LOCATIONS: finding.unsaved_locations = ptart_tools.parse_locations_from_hit(original_hit) diff --git a/dojo/tools/sarif/parser.py b/dojo/tools/sarif/parser.py index 0ec6800d568..08cff556888 100644 --- a/dojo/tools/sarif/parser.py +++ b/dojo/tools/sarif/parser.py @@ -222,9 +222,15 @@ def get_items_from_result(self, result, rules, artifacts, run_date): if cve_try(result["ruleId"]): finding.unsaved_vulnerability_ids = [cve_try(result["ruleId"])] + # Collect every CWE reported for this finding (union, order-preserving). + # finding.cwe keeps the existing primary choice (last extracted, per source + # precedence below); the full set is persisted via the Finding_CWE relation. + all_cwes = [] + # some time the rule id is here but the tool doesn't define it if rule is not None: cwes_extracted = get_rule_cwes(rule) + all_cwes.extend(cwes_extracted) if len(cwes_extracted) > 0: finding.cwe = cwes_extracted[-1] @@ -244,19 +250,26 @@ def get_items_from_result(self, result, rules, artifacts, run_date): # manage the case that some tools produce CWE as properties of the result cwes_properties_extracted = get_result_cwes_properties(result) + all_cwes.extend(cwes_properties_extracted) if len(cwes_properties_extracted) > 0: finding.cwe = cwes_properties_extracted[-1] # manage the case that some tools produce CWE using taxa (official SARIF approach) cwes_taxa_extracted = get_result_cwes_taxa(result) + all_cwes.extend(cwes_taxa_extracted) if len(cwes_taxa_extracted) > 0: finding.cwe = cwes_taxa_extracted[-1] # Get custom CWEs if available (uses inheritance) custom_cwes = self.get_finding_cwes(result) + all_cwes.extend(custom_cwes) if custom_cwes: finding.cwe = custom_cwes[-1] # Use the last CWE like other logic + # Persist the full, order-preserving set of CWEs via the Finding_CWE relation + if all_cwes: + finding.unsaved_cwes = list(dict.fromkeys(all_cwes)) + # manage fixes provided in the report if "fixes" in result: finding.mitigation = "\n".join( diff --git a/dojo/tools/semgrep/parser.py b/dojo/tools/semgrep/parser.py index 2dabcac1954..d0c9b135378 100644 --- a/dojo/tools/semgrep/parser.py +++ b/dojo/tools/semgrep/parser.py @@ -100,12 +100,17 @@ def get_findings(self, filename, test): # manage CWE if "cwe" in item["extra"]["metadata"]: if isinstance(item["extra"]["metadata"].get("cwe"), list): + cwe_list = item["extra"]["metadata"].get("cwe") finding.cwe = int( - item["extra"]["metadata"] - .get("cwe")[0] + cwe_list[0] .partition(":")[0] .partition("-")[2], ) + # Persist the full list of CWEs via the Finding_CWE relation + finding.unsaved_cwes = [ + int(cwe.partition(":")[0].partition("-")[2]) + for cwe in cwe_list + ] else: finding.cwe = int( item["extra"]["metadata"] @@ -168,12 +173,17 @@ def get_findings(self, filename, test): # manage CWE if "cweIds" in item["advisory"]["references"]: if isinstance(item["advisory"]["references"].get("cweIds"), list): + cwe_ids = item["advisory"]["references"].get("cweIds") finding.cwe = int( - item["advisory"]["references"] - .get("cweIds")[0] + cwe_ids[0] .partition(":")[0] .partition("-")[2], ) + # Persist the full list of CWEs via the Finding_CWE relation + finding.unsaved_cwes = [ + int(cwe.partition(":")[0].partition("-")[2]) + for cwe in cwe_ids + ] else: finding.cwe = int( item["advisory"]["references"] diff --git a/dojo/tools/semgrep_pro/parser.py b/dojo/tools/semgrep_pro/parser.py index c8da673d967..7a1d9bd2ef7 100644 --- a/dojo/tools/semgrep_pro/parser.py +++ b/dojo/tools/semgrep_pro/parser.py @@ -53,6 +53,15 @@ def get_findings(self, filename, test): finding.cwe = int(cwe_name.split("-")[1].split(":")[0]) except (ValueError, IndexError, KeyError): finding.cwe = None + # Persist the full list of CWEs via the Finding_CWE relation + parsed_cwes = [] + for cwe_name in item["rule"]["cwe_names"]: + try: + parsed_cwes.append(int(cwe_name.split("-")[1].split(":")[0])) + except (ValueError, IndexError, KeyError): + continue + if parsed_cwes: + finding.unsaved_cwes = parsed_cwes # Add references if available references = [] diff --git a/dojo/tools/snyk/parser.py b/dojo/tools/snyk/parser.py index 9c62fd13899..bbae86e1b03 100644 --- a/dojo/tools/snyk/parser.py +++ b/dojo/tools/snyk/parser.py @@ -237,6 +237,8 @@ def get_item(self, vulnerability, test, target_file=None, upgrades=None): # Per the current json format, if several CWEs, take the # first one. finding.cwe = int(cwes[0].split("-")[1]) + # Persist the full list of CWEs via the Finding_CWE relation + finding.unsaved_cwes = [int(c.split("-")[1]) for c in cwes] if len(vulnerability["identifiers"]["CWE"]) > 1: cwe_references = ", ".join(cwes) else: diff --git a/dojo/tools/snyk_issue_api/parser.py b/dojo/tools/snyk_issue_api/parser.py index 2f4daa36cf7..5d5c285f486 100644 --- a/dojo/tools/snyk_issue_api/parser.py +++ b/dojo/tools/snyk_issue_api/parser.py @@ -262,6 +262,10 @@ def get_finding(self, issue, test): risk_accepted=False, ) + # Persist the full list of CWEs via the Finding_CWE relation + if cwes: + finding.unsaved_cwes = cwes + # sca only if attributes.get("key"): finding.vuln_id_from_tool = attributes.get("key") diff --git a/dojo/tools/sonarqube/sonarqube_restapi_json.py b/dojo/tools/sonarqube/sonarqube_restapi_json.py index a01dd44037d..bd3ae183584 100644 --- a/dojo/tools/sonarqube/sonarqube_restapi_json.py +++ b/dojo/tools/sonarqube/sonarqube_restapi_json.py @@ -69,15 +69,17 @@ def get_json_items(self, json_content, test, mode): message = issue.get("message") line = issue.get("line") cwe = None + cwes = [] try: date = str(dateutil.parser.parse(issue.get("creationDate")).date()) except (ValueError, TypeError, dateutil.parser.ParserError): date = timezone.now() if "Category: CWE-" in message: cwe_pattern = r"Category: CWE-\d{1,5}" - cwes = re.findall(cwe_pattern, message) + cwe_matches = re.findall(cwe_pattern, message) + cwes = [match.split("Category: CWE-")[1] for match in cwe_matches] if cwes: - cwe = cwes[0].split("Category: CWE-")[1] + cwe = cwes[0] cvss = None if "CVSS Score: " in message: cvss_pattern = r"CVSS Score: \d{1}.\d{1}" @@ -133,6 +135,8 @@ def get_json_items(self, json_content, test, mode): date=date, ) item.unsaved_tags = ["vulnerability"] + if cwes: + item.unsaved_cwes = cwes vulnids = [] if "Reference: CVE" in message: cve_pattern = r"Reference: CVE-\d{4}-\d{4,7}" diff --git a/dojo/tools/trivy/parser.py b/dojo/tools/trivy/parser.py index 25746cd4089..455b07465ba 100644 --- a/dojo/tools/trivy/parser.py +++ b/dojo/tools/trivy/parser.py @@ -346,6 +346,11 @@ def get_result_items(self, test, results, service_name=None, artifact_name=""): ) finding.unsaved_tags = [tag for tag in (vul_type, target_class) if tag] + # Persist the full list of CWEs via the Finding_CWE relation + cwe_ids = vuln.get("CweIDs", []) + if cwe_ids: + finding.unsaved_cwes = [int(c.split("-")[1]) for c in cwe_ids] + if vuln_id: finding.unsaved_vulnerability_ids = [vuln_id] diff --git a/dojo/tools/utils.py b/dojo/tools/utils.py index 2fbda194371..27c0efc49d8 100644 --- a/dojo/tools/utils.py +++ b/dojo/tools/utils.py @@ -75,26 +75,46 @@ def safe_read_all_zip(file): zf.close() -def get_npm_cwe(item_node): +def get_npm_cwes(item_node): """ - Possible values: + Return the full list of CWE integers for an npm/yarn advisory node. + + Possible input values for item_node["cwe"]: "cwe": null "cwe": ["CWE-173", "CWE-200","CWE-601"] (or []) "cwe": "CWE-1234" "cwe": '["CWE-173","CWE-200","CWE-601"]' (or "[]") + + Returns a list of ints (may be empty when no CWE is present). """ cwe_node = item_node.get("cwe") if cwe_node: if isinstance(cwe_node, list): - return int(cwe_node[0][4:]) + return [int(cwe[4:]) for cwe in cwe_node if cwe] if cwe_node.startswith("CWE-"): cwe_string = cwe_node[4:] if cwe_string: - return int(cwe_string) + return [int(cwe_string)] elif cwe_node.startswith("["): cwe = json.loads(cwe_node) if cwe: - return int(cwe[0][4:]) + return [int(c[4:]) for c in cwe if c] + return [] + + +def get_npm_cwe(item_node): + """ + Return the primary (first) CWE integer for an npm/yarn advisory node. + + Possible values: + "cwe": null + "cwe": ["CWE-173", "CWE-200","CWE-601"] (or []) + "cwe": "CWE-1234" + "cwe": '["CWE-173","CWE-200","CWE-601"]' (or "[]") + """ + cwes = get_npm_cwes(item_node) + if cwes: + return cwes[0] # Use CWE-1035 as fallback (vulnerable third party component) return 1035 diff --git a/dojo/tools/veracode/xml_parser.py b/dojo/tools/veracode/xml_parser.py index bf7508c543f..61c24718419 100644 --- a/dojo/tools/veracode/xml_parser.py +++ b/dojo/tools/veracode/xml_parser.py @@ -280,6 +280,11 @@ def _get_cwe(val): return int(cweSearch.group(1)) return None + @staticmethod + def _get_cwes(val): + # Match all CWEs found in the value. + return [int(match) for match in re.findall(r"CWE-(\d+)", val, re.IGNORECASE)] + @classmethod def __xml_sca_flaw_to_finding( cls, test, report_date, _vendor, library, version, xml_node, @@ -296,6 +301,9 @@ def __xml_sca_flaw_to_finding( finding.severity = cls.__xml_flaw_to_severity(xml_node) finding.unsaved_vulnerability_ids = [xml_node.attrib["cve_id"]] finding.cwe = cls._get_cwe(xml_node.attrib["cwe_id"]) + cwes = cls._get_cwes(xml_node.attrib["cwe_id"]) + if cwes: + finding.unsaved_cwes = cwes finding.title = f"Vulnerable component: {library}:{version}" finding.component_name = library finding.component_version = version diff --git a/dojo/tools/wapiti/parser.py b/dojo/tools/wapiti/parser.py index f0d282aa524..6f1be2311b2 100644 --- a/dojo/tools/wapiti/parser.py +++ b/dojo/tools/wapiti/parser.py @@ -54,15 +54,21 @@ def get_findings(self, file, test): mitigation = vulnerability.findtext("solution") # manage references cwe = None + cwes = [] references = [] for reference in vulnerability.findall("references/reference"): reference_title = reference.findtext("title") if reference_title.startswith("CWE"): - cwe = self.get_cwe(reference_title) + ref_cwe = self.get_cwe(reference_title) + if ref_cwe is not None: + cwes.append(ref_cwe) references.append( f"* [{reference_title}]({reference.findtext('url')})", ) references = "\n".join(references) + # first CWE stays the primary; all are collected in cwes + if cwes: + cwe = cwes[0] for entry in vulnerability.findall("entries/entry"): title = category + ": " + entry.findtext("info") @@ -82,6 +88,8 @@ def get_findings(self, file, test): ) if cwe: finding.cwe = cwe + if cwes: + finding.unsaved_cwes = cwes if settings.V3_FEATURE_LOCATIONS: finding.unsaved_locations = [LocationData.url(url=url)] diff --git a/dojo/tools/xygeni/_common.py b/dojo/tools/xygeni/_common.py index c74e114304d..0550592f0c0 100644 --- a/dojo/tools/xygeni/_common.py +++ b/dojo/tools/xygeni/_common.py @@ -22,24 +22,48 @@ def map_severity(value): def parse_cwe(cwes=None, cwe=None, tags=None): """ - Resolve a CWE integer from any of the Xygeni representations. + Resolve the primary CWE integer from any of the Xygeni representations. Preference order: 1. The numeric ``cwe`` field on the finding. 2. The first ``"CWE-N"`` entry in ``cwes``. 3. The first ``"CWE:N"`` / ``"cwe:N"`` entry in ``tags``. """ + primary, _ = parse_cwes(cwes=cwes, cwe=cwe, tags=tags) + return primary + + +def parse_cwes(cwes=None, cwe=None, tags=None): + """ + Resolve CWEs from any of the Xygeni representations. + + Returns a ``(primary, all_cwes)`` tuple where ``primary`` is the single CWE + kept on ``Finding.cwe`` (following the :func:`parse_cwe` preference order) + and ``all_cwes`` is the deduplicated, order-preserving list of every CWE + integer found across the ``cwe``/``cwes``/``tags`` inputs. ``primary`` is + ``None`` when no CWE is present. + """ + all_cwes = [] + seen = set() + + def _add(value): + if value is not None and value not in seen: + seen.add(value) + all_cwes.append(value) + if isinstance(cwe, int): - return cwe + _add(cwe) for entry in cwes or []: match = _CWE_TAG_RE.match(str(entry)) if match: - return int(match.group(1)) + _add(int(match.group(1))) for entry in tags or []: match = _CWE_TAG_RE.match(str(entry)) if match: - return int(match.group(1)) - return None + _add(int(match.group(1))) + + primary = all_cwes[0] if all_cwes else None + return primary, all_cwes def extract_scan_type(data): diff --git a/dojo/tools/xygeni/sast.py b/dojo/tools/xygeni/sast.py index 2b2f9bd9497..8239d2c5bb7 100644 --- a/dojo/tools/xygeni/sast.py +++ b/dojo/tools/xygeni/sast.py @@ -1,7 +1,7 @@ """Parse Xygeni SAST reports into DefectDojo Findings.""" from dojo.models import Finding -from dojo.tools.xygeni._common import map_severity, parse_cwe +from dojo.tools.xygeni._common import map_severity, parse_cwes def parse_sast(data, test): @@ -25,6 +25,10 @@ def _build_finding(vuln, test): if code_flow_text: description_parts.append(code_flow_text) + primary_cwe, all_cwes = parse_cwes( + cwes=vuln.get("cwes"), cwe=vuln.get("cwe"), tags=vuln.get("tags"), + ) + finding = Finding( test=test, title=str(vuln.get("detector") or "Xygeni SAST finding"), @@ -32,7 +36,7 @@ def _build_finding(vuln, test): severity=map_severity(vuln.get("severity")), file_path=file_path, line=line, - cwe=parse_cwe(cwes=vuln.get("cwes"), cwe=vuln.get("cwe"), tags=vuln.get("tags")), + cwe=primary_cwe, static_finding=True, dynamic_finding=False, # ``uniqueHash`` is Xygeni's identity for a finding across scans. For SAST it is @@ -44,6 +48,9 @@ def _build_finding(vuln, test): vuln_id_from_tool=vuln.get("detector"), ) + if all_cwes: + finding.unsaved_cwes = all_cwes + _apply_code_flow_fields(finding, vuln.get("codeFlows") or []) return finding diff --git a/dojo/tools/xygeni/sca.py b/dojo/tools/xygeni/sca.py index 9b89f9d057b..f068aa76521 100644 --- a/dojo/tools/xygeni/sca.py +++ b/dojo/tools/xygeni/sca.py @@ -1,7 +1,7 @@ """Parse Xygeni SCA (dependency-vulnerability) reports into DefectDojo Findings.""" from dojo.models import Finding -from dojo.tools.xygeni._common import map_severity, parse_cwe +from dojo.tools.xygeni._common import map_severity, parse_cwes def parse_sca(data, test): @@ -39,12 +39,14 @@ def _build_finding(dep, vuln, test): if cvss_score is None or cvss_score < 0: cvss_score = None + primary_cwe, all_cwes = parse_cwes(cwes=vuln.get("cwes")) + finding = Finding( test=test, title=title, description=str(vuln.get("description") or ""), severity=map_severity(vuln.get("severity")), - cwe=parse_cwe(cwes=vuln.get("cwes")), + cwe=primary_cwe, cvssv3_score=cvss_score, mitigation=mitigation, references=references, @@ -62,6 +64,9 @@ def _build_finding(dep, vuln, test): if vuln.get("cve"): finding.cve = vuln["cve"] + if all_cwes: + finding.unsaved_cwes = all_cwes + finding.unsaved_vulnerability_ids = _collect_vulnerability_ids(vuln) return finding diff --git a/dojo/tools/yarn_audit/parser.py b/dojo/tools/yarn_audit/parser.py index 249ab28f2d3..2f4fe878e76 100644 --- a/dojo/tools/yarn_audit/parser.py +++ b/dojo/tools/yarn_audit/parser.py @@ -4,7 +4,7 @@ from dojo.models import Finding from dojo.tools.locations import LocationData -from dojo.tools.utils import get_npm_cwe +from dojo.tools.utils import get_npm_cwe, get_npm_cwes class YarnAuditParser: @@ -144,8 +144,10 @@ def get_items_auditci(self, tree, test): # https://github.com/DefectDojo/django dojo_finding.unsaved_vulnerability_ids = [] for cve in tree.get("advisories").get(element).get("cves"): dojo_finding.unsaved_vulnerability_ids.append(cve) - if tree.get("advisories").get(element).get("cwe") != []: - dojo_finding.cwe = tree.get("advisories").get(element).get("cwe")[0].strip("CWE-") + advisory_cwes = tree.get("advisories").get(element).get("cwe") + if advisory_cwes != []: + dojo_finding.cwe = advisory_cwes[0].strip("CWE-") + dojo_finding.unsaved_cwes = advisory_cwes if settings.V3_FEATURE_LOCATIONS and dojo_finding.component_name and dojo_finding.component_version: dojo_finding.unsaved_locations.append( LocationData.dependency(purl_type="npm", name=dojo_finding.component_name, version=dojo_finding.component_version), @@ -179,6 +181,7 @@ def get_item_yarn(self, item_node, test): if len(finding["paths"]) > 25: paths += "\n - ..... (list of paths truncated after 25 paths)" cwe = get_npm_cwe(item_node) + cwes = get_npm_cwes(item_node) dojo_finding = Finding( title=item_node["title"] + " - " @@ -218,6 +221,8 @@ def get_item_yarn(self, item_node, test): static_finding=True, dynamic_finding=False, ) + if cwes: + dojo_finding.unsaved_cwes = cwes if len(item_node["cves"]) > 0: dojo_finding.unsaved_vulnerability_ids = [] for vulnerability_id in item_node["cves"]: diff --git a/unittests/scans/api_edgescan/many_vulns_multi_cwe_fabricated.json b/unittests/scans/api_edgescan/many_vulns_multi_cwe_fabricated.json new file mode 100644 index 00000000000..107631a2bc3 --- /dev/null +++ b/unittests/scans/api_edgescan/many_vulns_multi_cwe_fabricated.json @@ -0,0 +1,67 @@ +[ + { + "id": 21583, + "asset_id": 60, + "name": "Directory listing", + "location": "example.test.com", + "location_specifier_id": 1904, + "asset_name": "test_asset_2", + "label": "", + "status": "closed", + "pci_compliance_status": "pass", + "date_opened": "2014-09-05 14:53:43 UTC", + "date_closed": "2020-07-13 09:39:56 UTC", + "risk": 5, + "severity": 2, + "threat": 3, + "layer": "application", + "remediation": "Remediation Text 2", + "description": "Description Text 2", + "cwes": [ + "CWE-77", + "CWE-548" + ], + "cves": [ + "CVE-2021-4008" + ], + "pci_exception": "none", + "pci_exception_description": null, + "pci_exception_expiry": null, + "cvss_score": 3.7, + "cvss_vector": "CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N", + "cvss_version": 3, + "asset_tags": "" + }, + { + "id": 21581, + "asset_id": 60, + "name": "Cross-site scripting (reflected)", + "location": "https://test.example.com", + "location_specifier_id": 1904, + "asset_name": "test_asset", + "label": "", + "status": "open", + "pci_compliance_status": "fail", + "date_opened": "2014-12-05 14:53:43 UTC", + "date_closed": null, + "risk": 1, + "severity": 4, + "threat": 5, + "layer": "application", + "remediation": "Remediation Text", + "description": "Description Text", + "cwes": [ + "CWE-75" + ], + "cves": [ + "CVE-2021-5300" + ], + "pci_exception": "none", + "pci_exception_description": null, + "pci_exception_expiry": null, + "cvss_score": 0.0, + "cvss_vector": "AV:N/AC:L/Au:N/C:N/I:N/A:N", + "cvss_version": 2, + "asset_tags": "APPROVED,Demo-Asset" + } +] diff --git a/unittests/scans/api_sonarqube/rule_multi_cwe_fabricated.json b/unittests/scans/api_sonarqube/rule_multi_cwe_fabricated.json new file mode 100644 index 00000000000..22706b9312d --- /dev/null +++ b/unittests/scans/api_sonarqube/rule_multi_cwe_fabricated.json @@ -0,0 +1,35 @@ +{ + "key": "typescript:S1854", + "repo": "typescript", + "name": "Dead stores should be removed", + "createdAt": "2018-01-17T10:11:21-0500", + "htmlDesc": "

A dead store happens when a local variable is assigned a value that is not read by any subsequent instruction. Calculating or retrieving a value\nonly to then overwrite it or throw it away, could indicate a serious error in the code. Even if it's not an error, it is at best a waste of resources.\nTherefore all calculated values should be used.

\n

Noncompliant Code Example

\n
\ni = a + b; // Noncompliant; calculation result not used before value is overwritten\ni = compute();\n
\n

Compliant Solution

\n
\ni = a + b;\ni += compute();\n
\n

Exceptions

\n

This rule ignores initializations to -1, 0, 1, null, true, false, \"\", [] and\n{}.

\n

See

\n", + "mdDesc": "

A dead store happens when a local variable is assigned a value that is not read by any subsequent instruction. Calculating or retrieving a value\nonly to then overwrite it or throw it away, could indicate a serious error in the code. Even if it's not an error, it is at best a waste of resources.\nTherefore all calculated values should be used.

\n

Noncompliant Code Example

\n
\ni = a + b; // Noncompliant; calculation result not used before value is overwritten\ni = compute();\n
\n

Compliant Solution

\n
\ni = a + b;\ni += compute();\n
\n

Exceptions

\n

This rule ignores initializations to -1, 0, 1, null, true, false, \"\", [] and\n{}.

\n

See

\n", + "severity": "MAJOR", + "status": "READY", + "isTemplate": false, + "tags": [ + ], + "sysTags": [ + "cert", + "cwe", + "unused" + ], + "lang": "ts", + "langName": "TypeScript", + "params": [ + ], + "defaultDebtRemFnType": "CONSTANT_ISSUE", + "defaultDebtRemFnOffset": "15min", + "debtOverloaded": false, + "debtRemFnType": "CONSTANT_ISSUE", + "debtRemFnOffset": "15min", + "defaultRemFnType": "CONSTANT_ISSUE", + "defaultRemFnBaseEffort": "15min", + "remFnType": "CONSTANT_ISSUE", + "remFnBaseEffort": "15min", + "remFnOverloaded": false, + "scope": "MAIN", + "isExternal": false, + "type": "CODE_SMELL" +} diff --git a/unittests/scans/aws_inspector2/aws_inspector2_package_vuln_metadata_multi_cwe_fabricated.json b/unittests/scans/aws_inspector2/aws_inspector2_package_vuln_metadata_multi_cwe_fabricated.json new file mode 100644 index 00000000000..f1ad27ee77f --- /dev/null +++ b/unittests/scans/aws_inspector2/aws_inspector2_package_vuln_metadata_multi_cwe_fabricated.json @@ -0,0 +1,350 @@ +{ + "findings": [ + { + "findingArn": "arn:aws:inspector2:us-west-2:123456789012:finding/00000000000000000000000000000013", + "awsAccountId": "123456789012", + "type": "CODE_VULNERABILITY", + "cwes": [ + "CWE-117", + "CWE-93" + ], + "description": "User-provided inputs must be sanitized before they are logged. An attacker can use unsanitized input to break a log's integrity, forge log entries, or bypass log monitors.", + "title": "CWE-117,93 - Log injection", + "remediation": { + "recommendation": { + "text": "You have a log statement that might use unsanitized input originating from HTTP requests or AWS Lambda sources. Depending on the context, this could result in:\n\n1. A log injection attack that breaks log integrity, forges log entries, or bypasses monitors that use the logs. To increase the security of your code, sanitize your inputs before logging them. [Learn more](https://cwe.mitre.org/data/definitions/117.html)\n\n2. A sensitive information leak that exposes users' credentials, private information, or identifying information to an attacker. To preserve privacy in your code, redact sensitive user information before logging it. [Learn more](https://cwe.mitre.org/data/definitions/532.html)" + } + }, + "severity": "HIGH", + "firstObservedAt": "2026-02-18 12:01:00.079000+01:00", + "lastObservedAt": "2026-02-18 13:41:11.095000+01:00", + "updatedAt": "2026-02-18 13:41:11.095000+01:00", + "status": "ACTIVE", + "resources": [ + { + "type": "AWS_LAMBDA_FUNCTION", + "id": "arn:aws:lambda:us-west-2:123456789012:function:web-user-manager-api-stage-healthCheck-EXAMPLE1ABCD:$LATEST", + "partition": "aws", + "region": "us-west-2", + "tags": { + "InspectorCodeExclusion": "None", + "InspectorExclusion": "None", + "aws:cloudformation:logical-id": "healthCheck", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:us-west-2:123456789012:stack/web-user-manager-api-stage/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "aws:cloudformation:stack-name": "web-user-manager-api-stage", + "deployed_at": "2026-02-18T12:38:52Z", + "deployed_by": "user1@example.com", + "env": "stage", + "environment": "stage", + "gitlab_ci_job": "1234567", + "lambda:createdBy": "SAM", + "owner_email": "user1@example.com", + "project": "web-user-manager-api", + "service": "web-user-manager-api", + "team": "backend-team-web-user-manager" + }, + "details": { + "awsLambdaFunction": { + "functionName": "web-user-manager-api-stage-healthCheck-EXAMPLE1ABCD", + "runtime": "PYTHON_3_12", + "codeSha256": "abc123def456ghi789jkl012mno345pqr678stu901vw==", + "version": "$LATEST", + "executionRoleArn": "arn:aws:iam::123456789012:role/web-user-manager-api-stage-LambdaExecutionRole-EXAMPLE12345", + "layers": [ + "arn:aws:lambda:us-west-2:123456789012:layer:web-user-manager-api-stage:12", + "arn:aws:lambda:us-west-2:123456789013:layer:opentelemetry-collector-amd64-0_19_0:1" + ], + "vpcConfig": { + "subnetIds": [ + "subnet-03709ee21c427acf6", + "subnet-0b6f7a6987b5e689d", + "subnet-0123c6fff46639b68" + ], + "securityGroupIds": [ + "sg-0fd8fe5dae2694a95" + ], + "vpcId": "vpc-055aa15ae33f4297b" + }, + "packageType": "ZIP", + "architectures": [ + "X86_64" + ], + "lastModifiedAt": "2026-02-18 13:40:35.830000+01:00" + } + } + } + ], + "codeVulnerabilityDetails": { + "filePath": { + "fileName": "api_client.py", + "filePath": "src/project_api/service/api_client.py", + "startLine": 268, + "endLine": 268 + }, + "detectorTags": [ + "data-integrity", + "injection", + "security", + "owasp-top10" + ], + "referenceUrls": [], + "ruleId": "python-log-injection", + "detectorId": "python/log-injection@v1.0", + "detectorName": "Log injection", + "cwes": [ + "CWE-117", + "CWE-93" + ] + } + }, + { + "findingArn": "arn:aws:inspector2:us-west-2:123456789012:finding/00000000000000000000000000000001", + "awsAccountId": "123456789016", + "type": "PACKAGE_VULNERABILITY", + "description": "Due to the design of the name constraint checking algorithm, the processing time of some inputs scale non-linearly with respect to the size of the certificate. This affects programs which validate arbitrary certificate chains.", + "title": "CVE-2025-58187 - go/stdlib", + "remediation": { + "recommendation": { + "text": "None Provided" + } + }, + "severity": "HIGH", + "firstObservedAt": "2025-12-26 14:21:44.742000+01:00", + "lastObservedAt": "2026-03-13 22:38:57.161000+01:00", + "updatedAt": "2026-03-13 22:38:57.161000+01:00", + "status": "ACTIVE", + "resources": [ + { + "type": "AWS_LAMBDA_FUNCTION", + "id": "arn:aws:lambda:us-west-2:123456789014:function:web-user-manager-api-stage-healthCheck-EXAMPLE1ABCD:$LATEST", + "partition": "aws", + "region": "us-west-2", + "tags": { + "InspectorCodeExclusion": "None", + "InspectorExclusion": "None", + "aws:cloudformation:logical-id": "healthCheck", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:us-west-2:123456789015:stack/web-user-manager-api-stage/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "aws:cloudformation:stack-name": "stack name", + "deployed_at": "2026-02-18T12:38:52Z", + "deployed_by": "user1@example.com", + "env": "stage", + "environment": "stage", + "gitlab_ci_job": "1234568", + "lambda:createdBy": "DeployUser", + "owner_email": "user2@example.com", + "project": "web-user-manager-api", + "service": "web-user-manager-api", + "team": "backend-team-web-user-manager" + }, + "details": { + "awsLambdaFunction": { + "functionName": "web-user-manager-api-stage-healthCheck-EXAMPLE1ABCD", + "runtime": "PYTHON_3_12", + "codeSha256": "abc123def456ghi789jkl012mno345pqr678stu901vw==", + "version": "$LATEST", + "executionRoleArn": "arn:aws:iam::123456789012:role/web-user-manager-api-stage-LambdaExecutionRole-EXAMPLE12345", + "layers": [ + "arn:aws:lambda:us-west-2:123456789012:layer:web-user-manager-api-stage:12", + "arn:aws:lambda:us-west-2:123456789013:layer:opentelemetry-collector-amd64-0_19_0:1" + ], + "vpcConfig": { + "subnetIds": [ + "subnet-03709ee21c427acf6", + "subnet-0b6f7a6987b5e689d", + "subnet-0123c6fff46639b68" + ], + "securityGroupIds": [ + "sg-0fd8fe5dae2694a95" + ], + "vpcId": "vpc-055aa15ae33f4297b" + }, + "packageType": "ZIP", + "architectures": [ + "X86_64" + ], + "lastModifiedAt": "2026-02-18 13:40:35.830000+01:00" + } + } + } + ], + "inspectorScore": 7.5, + "inspectorScoreDetails": { + "adjustedCvss": { + "scoreSource": "NVD", + "cvssSource": "NVD", + "version": "3.1", + "score": 7.5, + "scoringVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "adjustments": [] + } + }, + "packageVulnerabilityDetails": { + "vulnerabilityId": "CVE-2025-58187", + "vulnerablePackages": [ + { + "name": "go/stdlib", + "version": "1.24.4", + "epoch": 0, + "packageManager": "GENERIC", + "filePath": "extensions/collector", + "fixedInVersion": "1.25.3", + "sourceLambdaLayerArn": "arn:aws:lambda:us-west-2:123456789013:layer:opentelemetry-collector-amd64-0_19_0:1" + } + ], + "source": "NVD", + "cvss": [ + { + "baseScore": 7.5, + "scoringVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "version": "3.1", + "source": "NVD" + } + ], + "relatedVulnerabilities": [], + "sourceUrl": "https://nvd.nist.gov/vuln/detail/CVE-2025-58187", + "vendorSeverity": "HIGH", + "vendorCreatedAt": "2025-10-30 00:16:19+01:00", + "vendorUpdatedAt": "2026-01-29 17:02:27+01:00", + "referenceUrls": [ + "https://nvd.nist.gov/vuln/detail/CVE-2025-58187", + "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + ] + }, + "fixAvailable": "YES", + "exploitAvailable": "NO", + "epss": { + "score": 0.00013 + } + }, + { + "findingArn": "arn:aws:inspector2:us-west-2:123456789012:finding/00000000000000000000000000000002", + "awsAccountId": "123456789012", + "type": "PACKAGE_VULNERABILITY", + "description": "Requests is a HTTP library. Due to a URL parsing issue, Requests releases prior to 2.32.4 may leak .netrc credentials to third parties for specific maliciously-crafted URLs. Users should upgrade to version 2.32.4 to receive a fix. For older versions of Requests, use of the .netrc file can be disabled with `trust_env=False` on one's Requests Session.", + "title": "CVE-2024-47081 - requests", + "remediation": { + "recommendation": { + "text": "None Provided" + } + }, + "severity": "MEDIUM", + "firstObservedAt": "2025-06-10 23:43:07.201000+02:00", + "lastObservedAt": "2026-03-07 23:40:17.655000+01:00", + "updatedAt": "2026-03-07 23:40:17.655000+01:00", + "status": "ACTIVE", + "resources": [ + { + "type": "AWS_LAMBDA_FUNCTION", + "id": "arn:aws:lambda:us-west-2:123456789012:function:web-user-manager-api-stage-healthCheck-EXAMPLE1ABCD:$LATEST", + "partition": "aws", + "region": "us-west-2", + "tags": { + "InspectorCodeExclusion": "None", + "InspectorExclusion": "None", + "aws:cloudformation:logical-id": "healthCheck", + "aws:cloudformation:stack-id": "arn:aws:cloudformation:us-west-2:123456789012:stack/web-user-manager-api-stage/a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "aws:cloudformation:stack-name": "web-user-manager-api-stage", + "deployed_at": "2026-02-18T12:38:52Z", + "deployed_by": "user2@example.com", + "env": "stage", + "environment": "stage", + "gitlab_ci_job": "1234567", + "lambda:createdBy": "SAM", + "owner_email": "user1@example.com", + "project": "web-user-manager-api", + "service": "web-user-manager-api", + "team": "backend-team-web-user-manager" + }, + "details": { + "awsLambdaFunction": { + "functionName": "web-user-manager-api-stage-healthCheck-EXAMPLE1ABCD", + "runtime": "PYTHON_3_12", + "codeSha256": "abc123def456ghi789jkl012mno345pqr678stu901vw==", + "version": "$LATEST", + "executionRoleArn": "arn:aws:iam::123456789012:role/web-user-manager-api-stage-LambdaExecutionRole-EXAMPLE12345", + "layers": [ + "arn:aws:lambda:us-west-2:123456789012:layer:web-user-manager-api-stage:12", + "arn:aws:lambda:us-west-2:123456789013:layer:opentelemetry-collector-amd64-0_19_0:1" + ], + "vpcConfig": { + "subnetIds": [ + "subnet-03709ee21c427acf6", + "subnet-0b6f7a6987b5e689d", + "subnet-0123c6fff46639b68" + ], + "securityGroupIds": [ + "sg-0fd8fe5dae2694a95" + ], + "vpcId": "vpc-055aa15ae33f4297b" + }, + "packageType": "ZIP", + "architectures": [ + "X86_64" + ], + "lastModifiedAt": "2026-02-18 13:40:35.830000+01:00" + } + } + } + ], + "inspectorScore": 5.3, + "inspectorScoreDetails": { + "adjustedCvss": { + "scoreSource": "NVD", + "cvssSource": "NVD", + "version": "3.1", + "score": 5.3, + "scoringVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N", + "adjustments": [] + } + }, + "packageVulnerabilityDetails": { + "vulnerabilityId": "CVE-2024-47081", + "vulnerablePackages": [ + { + "name": "requests", + "version": "2.32.0", + "epoch": 0, + "packageManager": "PYTHON", + "filePath": "python/requests-2.32.0.dist-info/METADATA", + "fixedInVersion": "2.32.4", + "sourceLambdaLayerArn": "arn:aws:lambda:us-west-2:123456789012:layer:web-user-manager-api-stage:12" + }, + { + "name": "requests", + "version": "2.32.0", + "epoch": 0, + "packageManager": "PYTHON", + "filePath": "requirements.txt", + "fixedInVersion": "2.32.4" + } + ], + "source": "NVD", + "cvss": [ + { + "baseScore": 5.3, + "scoringVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N", + "version": "3.1", + "source": "NVD" + } + ], + "relatedVulnerabilities": [], + "sourceUrl": "https://nvd.nist.gov/vuln/detail/CVE-2024-47081", + "vendorSeverity": "MEDIUM", + "vendorCreatedAt": "2025-06-09 20:15:24+02:00", + "vendorUpdatedAt": "2025-06-12 18:06:47+02:00", + "referenceUrls": [ + "https://seclists.org/fulldisclosure/2025/Jun/2", + "https://nvd.nist.gov/vuln/detail/CVE-2024-47081", + "https://github.com/psf/requests/commit/96ba401c1296ab1dda74a2365ef36d88f7d144ef", + "https://github.com/psf/requests/pull/6965", + "https://github.com/psf/requests/security/advisories/GHSA-9hjg-9r4m-mvj7" + ] + }, + "fixAvailable": "YES", + "exploitAvailable": "NO", + "epss": { + "score": 0.00154 + } + } + ] +} diff --git a/unittests/scans/bearer_cli/bearer_cli_many_vul_multi_cwe_fabricated.json b/unittests/scans/bearer_cli/bearer_cli_many_vul_multi_cwe_fabricated.json new file mode 100644 index 00000000000..5394c65ea4c --- /dev/null +++ b/unittests/scans/bearer_cli/bearer_cli_many_vul_multi_cwe_fabricated.json @@ -0,0 +1,83 @@ +{ + "high": [ + { + "cwe_ids": [ + "79", + "80" + ], + "id": "javascript_lang_dangerous_insert_html", + "title": "Unsanitized user input in dynamic HTML insertion (XSS)", + "description": "## Description\nThere are XSS vulnerabilities when dynamically inserting HTML that contains unsanitized data.\n\n## Remediations\nMake sure you use HTML sanitization library before inserting html\n\n```javascript\nimport sanitizeHtml from 'sanitize-html';\n\nconst html = `${user.Input}`;\ndocument.body.innerHTML = sanitizeHtml(html)\n```\n\n## Resources\n- [OWASP XSS explained](https://owasp.org/www-community/attacks/xss/)\n", + "documentation_url": "https://docs.bearer.com/reference/rules/javascript_lang_dangerous_insert_html", + "line_number": 581, + "full_filename": "js/adminer/editing.js", + "filename": "js/adminer/editing.js", + "category_groups": [ + "PII", + "Personal Data", + "Personal Data (Sensitive)" + ], + "source": { + "start": 581, + "end": 581, + "column": { + "start": 3, + "end": 24 + } + }, + "sink": { + "start": 581, + "end": 581, + "column": { + "start": 3, + "end": 24 + }, + "content": "help.innerHTML = text" + }, + "parent_line_number": 581, + "snippet": "help.innerHTML = text", + "fingerprint": "804174abc284c6bc747d886b3e9ba757_0", + "old_fingerprint": "2051ee8ff784df0881cb7bfdaefe6cb9_16", + "code_extract": "\t\thelp.innerHTML = text;" + }, + { + "cwe_ids": [ + "79" + ], + "id": "javascript_lang_dangerous_insert_html", + "title": "Unsanitized user input in dynamic HTML insertion (XSS)", + "description": "## Description\nThere are XSS vulnerabilities when dynamically inserting HTML that contains unsanitized data.\n\n## Remediations\nMake sure you use HTML sanitization library before inserting html\n\n```javascript\nimport sanitizeHtml from 'sanitize-html';\n\nconst html = `${user.Input}`;\ndocument.body.innerHTML = sanitizeHtml(html)\n```\n\n## Resources\n- [OWASP XSS explained](https://owasp.org/www-community/attacks/xss/)\n", + "documentation_url": "https://docs.bearer.com/reference/rules/javascript_lang_dangerous_insert_html", + "line_number": 240, + "full_filename": "js/adminer/functions.js", + "filename": "js/adminer/functions.js", + "category_groups": [ + "PII", + "Personal Data", + "Personal Data (Sensitive)" + ], + "source": { + "start": 240, + "end": 240, + "column": { + "start": 4, + "end": 23 + } + }, + "sink": { + "start": 240, + "end": 240, + "column": { + "start": 4, + "end": 23 + }, + "content": "el.innerHTML = html" + }, + "parent_line_number": 240, + "snippet": "el.innerHTML = html", + "fingerprint": "49180977e4f967d24a868cebe2138529_0", + "old_fingerprint": "2a96b7f5d8bf3a186b5c8ecb07a76c8c_6", + "code_extract": "\t\t\tel.innerHTML = html;" + } + ] +} diff --git a/unittests/scans/cyclonedx/multiple_cwes_fabricated.xml b/unittests/scans/cyclonedx/multiple_cwes_fabricated.xml new file mode 100644 index 00000000000..335f6664bca --- /dev/null +++ b/unittests/scans/cyclonedx/multiple_cwes_fabricated.xml @@ -0,0 +1,52 @@ + + + + 2022-07-22T09:45:22+01:00 + + + anchore + grype + 0.41.0 + + + + ./example + + + + + example + 1.0.0 + pkg:maven/org.example/example@1.0.0 + + + + + CVE-2021-99999 + + nvd:cpe + http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-99999 + + + + critical + + + 10 + CVSSv31 + CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H + + + + 79 + 89 + + Example vulnerability with more than one CWE. + + + pkg:maven/org.example/example@1.0.0?package-id=abcdef0123456789 + + + + + diff --git a/unittests/scans/dependency_check/version-6.5.3_multi_cwe_fabricated.xml b/unittests/scans/dependency_check/version-6.5.3_multi_cwe_fabricated.xml new file mode 100644 index 00000000000..c82693f1ccd --- /dev/null +++ b/unittests/scans/dependency_check/version-6.5.3_multi_cwe_fabricated.xml @@ -0,0 +1,649 @@ + + + + 6.5.3 + + NVD CVE Checked + 2022-01-15T15:27:20 + + + NVD CVE Modified + 2022-01-15T14:00:01 + + + VersionCheckOn + 2022-01-15T15:27:20 + + + + + 2022-01-15T14:31:13.042600508Z + This report contains data retrieved from the National Vulnerability Database: https://nvd.nist.gov, NPM Public Advisories: https://www.npmjs.com/advisories, and the RetireJS community. + + + + log4j-api-2.12.4.jar + /home/damien/vulnerabilities/apache-log4j-2.12.4-bin/log4j-api-2.12.4.jar + f0edf6299d91b0661456d539f641cae4 + c1d3c1f6b392ebd4ca8a9d65e8bad549e885fcbc + 109df2737a67e8a6a962fdebd31a5f076cfc61bd348d893cffdb48caa264a826 + The Apache Log4j API + https://www.apache.org/licenses/LICENSE-2.0.txt + + + file + name + log4j-api + + + jar + package name + apache + + + jar + package name + log4j + + + jar + package name + logging + + + jar + package name + org + + + Manifest + bundle-docurl + https://www.apache.org/ + + + Manifest + bundle-symbolicname + org.apache.logging.log4j.api + + + Manifest + implementation-url + https://logging.apache.org/log4j/2.x/log4j-api/ + + + Manifest + Implementation-Vendor + The Apache Software Foundation + + + Manifest + Implementation-Vendor-Id + org.apache.logging.log4j + + + Manifest + log4jreleasekey + B3D8E1BA + + + Manifest + log4jreleasemanager + Ralph Goers + + + Manifest + log4jsigningusername + rgoers@apache.org + + + Manifest + multi-release + true + + + Manifest + require-capability + osgi.ee;filter:="(&(osgi.ee=JavaSE)(version=1.7))" + + + Manifest + specification-vendor + The Apache Software Foundation + + + pom + artifactid + log4j-api + + + pom + groupid + org.apache.logging.log4j + + + pom + name + Apache Log4j API + + + pom + parent-artifactid + log4j + + + file + name + log4j-api + + + jar + package name + apache + + + jar + package name + log4j + + + jar + package name + logging + + + jar + package name + org + + + Manifest + bundle-docurl + https://www.apache.org/ + + + Manifest + Bundle-Name + Apache Log4j API + + + Manifest + bundle-symbolicname + org.apache.logging.log4j.api + + + Manifest + Implementation-Title + Apache Log4j API + + + Manifest + implementation-url + https://logging.apache.org/log4j/2.x/log4j-api/ + + + Manifest + log4jreleasekey + B3D8E1BA + + + Manifest + log4jreleasemanager + Ralph Goers + + + Manifest + log4jsigningusername + rgoers@apache.org + + + Manifest + multi-release + true + + + Manifest + require-capability + osgi.ee;filter:="(&(osgi.ee=JavaSE)(version=1.7))" + + + Manifest + specification-title + Apache Log4j API + + + pom + artifactid + log4j-api + + + pom + groupid + org.apache.logging.log4j + + + pom + name + Apache Log4j API + + + pom + parent-artifactid + log4j + + + file + version + 2.12.4 + + + Manifest + Bundle-Version + 2.12.4 + + + Manifest + Implementation-Version + 2.12.4 + + + Manifest + log4jreleaseversion + 2.12.4 + + + pom + version + 2.12.4 + + + + + pkg:maven/org.apache.logging.log4j/log4j-api@2.12.4 + https://ossindex.sonatype.org/component/pkg:maven/org.apache.logging.log4j/log4j-api@2.12.4?utm_source=dependency-check&utm_medium=integration&utm_content=6.2.2 + + + cpe:2.3:a:apache:log4j:2.12.4:*:*:*:*:*:*:* + https://nvd.nist.gov/vuln/search/results?form_type=Advanced&results_type=overview&search_type=all&cpe_vendor=cpe%3A%2F%3Aapache&cpe_product=cpe%3A%2F%3Aapache%3Alog4j&cpe_version=cpe%3A%2F%3Aapache%3Alog4j%3A2.12.4 + + + + + CVE-2020-9488 + LOW + + 4.3 + NETWORK + MEDIUM + NONE + PARTIAL + NONE + NONE + MEDIUM + 2.0 + 8.6 + 2.9 + + + 3.7 + NETWORK + HIGH + NONE + NONE + UNCHANGED + LOW + NONE + NONE + LOW + 2.2 + 1.4 + 3.1 + + + CWE-295 + CWE-297 + + Improper validation of certificate with host mismatch in Apache Log4j SMTP appender. This could allow an SMTPS connection to be intercepted by a man-in-the-middle attack which could leak any log messages sent through that appender. + + + MISC + https://www.oracle.com/security-alerts/cpuoct2020.html + https://www.oracle.com/security-alerts/cpuoct2020.html + + + MLIST + https://lists.apache.org/thread.html/r9776e71e3c67c5d13a91c1eba0dc025b48b802eb7561cc6956d6961c@%3Cissues.hive.apache.org%3E + [hive-issues] 20201208 [jira] [Work logged] (HIVE-24500) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MISC + https://www.oracle.com/security-alerts/cpujul2020.html + https://www.oracle.com/security-alerts/cpujul2020.html + + + MLIST + https://lists.apache.org/thread.html/rd55f65c6822ff235eda435d31488cfbb9aa7055cdf47481ebee777cc@%3Cissues.zookeeper.apache.org%3E + [zookeeper-issues] 20200504 [jira] [Resolved] (ZOOKEEPER-3817) owasp failing due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/rf1c2a81a08034c688b8f15cf58a4cfab322d00002ca46d20133bee20@%3Cdev.kafka.apache.org%3E + [kafka-dev] 20200514 [jira] [Created] (KAFKA-9997) upgrade log4j lib to address CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r1fc73f0e16ec2fa249d3ad39a5194afb9cc5afb4c023dc0bab5a5881@%3Cissues.hive.apache.org%3E + [hive-issues] 20201207 [jira] [Work started] (HIVE-24500) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/rc2dbc4633a6eea1fcbce6831876cfa17b73759a98c65326d1896cb1a@%3Ctorque-dev.db.apache.org%3E + [db-torque-dev] 20210127 Re: Items for our (delayed) quarterly report to the board? + + + MLIST + https://lists.apache.org/thread.html/r7e739f2961753af95e2a3a637828fb88bfca68e5d6b0221d483a9ee5@%3Cnotifications.zookeeper.apache.org%3E + [zookeeper-notifications] 20200504 [GitHub] [zookeeper] symat opened a new pull request #1346: ZOOKEEPER-3817: suppress log4j SmtpAppender related CVE-2020-9488 + + + MISC + https://lists.apache.org/thread.html/rbc7642b9800249553f13457e46b813bea1aec99d2bc9106510e00ff3@%3Ctorque-dev.db.apache.org%3E + https://lists.apache.org/thread.html/rbc7642b9800249553f13457e46b813bea1aec99d2bc9106510e00ff3@%3Ctorque-dev.db.apache.org%3E + + + CONFIRM + https://issues.apache.org/jira/browse/LOG4J2-2819 + https://issues.apache.org/jira/browse/LOG4J2-2819 + + + MLIST + https://lists.apache.org/thread.html/r4d5dc9f3520071338d9ebc26f9f158a43ae28a91923d176b550a807b@%3Cdev.hive.apache.org%3E + [hive-dev] 20210216 [jira] [Created] (HIVE-24787) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r4db540cafc5d7232c62e076051ef661d37d345015b2e59b3f81a932f@%3Cdev.hive.apache.org%3E + [hive-dev] 20201207 [jira] [Created] (HIVE-24500) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r4285398e5585a0456d3d9db021a4fce6e6fcf3ec027dfa13a450ec98@%3Cissues.zookeeper.apache.org%3E + [zookeeper-issues] 20200504 [jira] [Commented] (ZOOKEEPER-3817) owasp failing due to CVE-2020-9488 + + + MISC + https://www.oracle.com/security-alerts/cpujan2021.html + https://www.oracle.com/security-alerts/cpujan2021.html + + + MLIST + https://lists.apache.org/thread.html/r7e5c10534ed06bf805473ac85e8412fe3908a8fa4cabf5027bf11220@%3Cdev.kafka.apache.org%3E + [kafka-dev] 20200514 [jira] [Created] (KAFKA-9996) upgrade zookeeper to 3.5.8 to address security vulnerabilities + + + MLIST + https://lists.apache.org/thread.html/r8c001b9a95c0bbec06f4457721edd94935a55932e64b82cc5582b846@%3Cissues.zookeeper.apache.org%3E + [zookeeper-issues] 20200504 [jira] [Created] (ZOOKEEPER-3817) owasp failing due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r5a68258e5ab12532dc179edae3d6e87037fa3b50ab9d63a90c432507@%3Cissues.hive.apache.org%3E + [hive-issues] 20210216 [jira] [Assigned] (HIVE-24787) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r7641ee788e1eb1be4bb206a7d15f8a64ec6ef23e5ec6132d5a567695@%3Cnotifications.zookeeper.apache.org%3E + [zookeeper-notifications] 20200504 Build failed in Jenkins: zookeeper-master-maven-owasp #489 + + + MLIST + https://lists.apache.org/thread.html/r48efc7cb5aeb4e1f67aaa06fb4b5479a5635d12f07d0b93fc2d08809@%3Ccommits.zookeeper.apache.org%3E + [zookeeper-commits] 20200504 [zookeeper] branch branch-3.6 updated: ZOOKEEPER-3817: suppress log4j SmtpAppender related CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/rd5d58088812cf8e677d99b07f73c654014c524c94e7fedbdee047604@%3Ctorque-dev.db.apache.org%3E + [db-torque-dev] 20210128 Antwort: Re: Items for our (delayed) quarterly report to the board? + + + CONFIRM + https://security.netapp.com/advisory/ntap-20200504-0003/ + https://security.netapp.com/advisory/ntap-20200504-0003/ + + + MLIST + https://lists.apache.org/thread.html/rd0e44e8ef71eeaaa3cf3d1b8b41eb25894372e2995ec908ce7624d26@%3Ccommits.pulsar.apache.org%3E + [pulsar-commits] 20201215 [GitHub] [pulsar] yanshuchong opened a new issue #8967: CVSS issue list + + + MLIST + https://lists.apache.org/thread.html/r0df3d7a5acb98c57e64ab9266aa21eeee1d9b399addb96f9cf1cbe05@%3Cdev.zookeeper.apache.org%3E + [zookeeper-dev] 20200504 log4j SmtpAppender related CVE + + + MLIST + https://lists.apache.org/thread.html/r22a56beb76dd8cf18e24fda9072f1e05990f49d6439662d3782a392f@%3Cissues.hive.apache.org%3E + [hive-issues] 20210216 [jira] [Resolved] (HIVE-24787) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + DEBIAN + https://www.debian.org/security/2021/dsa-5020 + DSA-5020 + + + MLIST + https://lists.apache.org/thread.html/r2f209d271349bafd91537a558a279c08ebcff8fa3e547357d58833e6@%3Cdev.zookeeper.apache.org%3E + [zookeeper-dev] 20200504 [jira] [Created] (ZOOKEEPER-3817) owasp failing due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/rbc45eb0f53fd6242af3e666c2189464f848a851d408289840cecc6e3@%3Ccommits.zookeeper.apache.org%3E + [zookeeper-commits] 20200504 [zookeeper] branch master updated: ZOOKEEPER-3817: suppress log4j SmtpAppender related CVE-2020-9488 + + + MLIST + https://lists.debian.org/debian-lts-announce/2021/12/msg00017.html + [debian-lts-announce] 20211226 [SECURITY] [DLA 2852-1] apache-log4j2 security update + + + MLIST + https://lists.apache.org/thread.html/r9a79175c393d14d760a0ae3731b4a873230a16ef321aa9ca48a810cd@%3Cissues.zookeeper.apache.org%3E + [zookeeper-issues] 20200504 [jira] [Updated] (ZOOKEEPER-3817) owasp failing due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/rc6b81c013618d1de1b5d6b8c1088aaf87b4bacc10c2371f15a566701@%3Cnotifications.zookeeper.apache.org%3E + [zookeeper-notifications] 20200504 [GitHub] [zookeeper] symat commented on pull request #1346: ZOOKEEPER-3817: suppress log4j SmtpAppender related CVE-2020-9488 + + + MISC + https://lists.apache.org/thread.html/re024d86dffa72ad800f2848d0c77ed93f0b78ee808350b477a6ed987@%3Cgitbox.hive.apache.org%3E + https://lists.apache.org/thread.html/re024d86dffa72ad800f2848d0c77ed93f0b78ee808350b477a6ed987@%3Cgitbox.hive.apache.org%3E + + + MLIST + https://lists.apache.org/thread.html/ra632b329b2ae2324fabbad5da204c4ec2e171ff60348ec4ba698fd40@%3Cissues.hive.apache.org%3E + [hive-issues] 20201207 [jira] [Assigned] (HIVE-24500) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r393943de452406f0f6f4b3def9f8d3c071f96323c1f6ed1a098f7fe4@%3Ctorque-dev.db.apache.org%3E + [db-torque-dev] 20200715 Build failed in Jenkins: Torque4-trunk #685 + + + MLIST + https://lists.apache.org/thread.html/ra051e07a0eea4943fa104247e69596f094951f51512d42c924e86c75@%3Cissues.hive.apache.org%3E + [hive-issues] 20210218 [jira] [Updated] (HIVE-24787) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/rf9fa47ab66495c78bb4120b0754dd9531ca2ff0430f6685ac9b07772@%3Cdev.mina.apache.org%3E + [mina-dev] 20210225 [jira] [Created] (FTPSERVER-500) Security vulnerability in common/lib/log4j-1.2.17.jar + + + MLIST + https://lists.apache.org/thread.html/r2721aba31a8562639c4b937150897e24f78f747cdbda8641c0f659fe@%3Cusers.kafka.apache.org%3E + [kafka-users] 20210617 vulnerabilities + + + MLIST + https://lists.apache.org/thread.html/rd8e87c4d69df335d0ba7d815b63be8bd8a6352f429765c52eb07ddac@%3Cissues.zookeeper.apache.org%3E + [zookeeper-issues] 20200504 [jira] [Assigned] (ZOOKEEPER-3817) owasp failing due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r33864a0fc171c1c4bf680645ebb6d4f8057899ab294a43e1e4fe9d04@%3Cissues.hive.apache.org%3E + [hive-issues] 20210209 [jira] [Resolved] (HIVE-24500) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r45916179811a32cbaa500f972de9098e6ee80ee81c7f134fce83e03a@%3Cissues.flink.apache.org%3E + [flink-issues] 20210510 [GitHub] [flink] zentol opened a new pull request #15879: [FLINK-22407][build] Bump log4j to 2.24.1 + + + MISC + https://www.oracle.com/security-alerts/cpuoct2021.html + https://www.oracle.com/security-alerts/cpuoct2021.html + + + MLIST + https://lists.apache.org/thread.html/r4ed1f49616a8603832d378cb9d13e7a8b9b27972bb46d946ccd8491f@%3Cissues.hive.apache.org%3E + [hive-issues] 20201207 [jira] [Updated] (HIVE-24500) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r0a2699f724156a558afd1abb6c044fb9132caa66dce861b82699722a@%3Cjira.kafka.apache.org%3E + [kafka-jira] 20200514 [jira] [Created] (KAFKA-9997) upgrade log4j lib to address CVE-2020-9488 + + + MISC + https://www.oracle.com/security-alerts/cpuApr2021.html + https://www.oracle.com/security-alerts/cpuApr2021.html + + + MLIST + https://lists.apache.org/thread.html/r3d1d00441c55144a4013adda74b051ae7864128ebcfb6ee9721a2eb3@%3Cissues.hive.apache.org%3E + [hive-issues] 20210125 [jira] [Work logged] (HIVE-24500) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r48bcd06049c1779ef709564544c3d8a32ae6ee5c3b7281a606ac4463@%3Cjira.kafka.apache.org%3E + [kafka-jira] 20200515 [jira] [Commented] (KAFKA-9997) upgrade log4j lib to address CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r65578f3761a89bc164e8964acd5d913b9f8fd997967b195a89a97ca3@%3Cissues.hive.apache.org%3E + [hive-issues] 20201208 [jira] [Updated] (HIVE-24500) Hive - upgrade log4j 2.12.1 to 2.13.2+ due to CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/rec34b1cccf907898e7cb36051ffac3ccf1ea89d0b261a2a3b3fb267f@%3Ccommits.zookeeper.apache.org%3E + [zookeeper-commits] 20200504 [zookeeper] branch branch-3.5 updated: ZOOKEEPER-3817: suppress log4j SmtpAppender related CVE-2020-9488 + + + MLIST + https://lists.apache.org/thread.html/r8e96c340004b7898cad3204ea51280ef6e4b553a684e1452bf1b18b1@%3Cjira.kafka.apache.org%3E + [kafka-jira] 20200514 [jira] [Created] (KAFKA-9996) upgrade zookeeper to 3.5.8 to address security vulnerabilities + + + + cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:communications_application_session_controller:3.9m0p1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:communications_billing_and_revenue_management:7.5.0.23.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:communications_billing_and_revenue_management:12.0.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:communications_eagle_ftp_table_base_retrieval:4.5:*:*:*:*:*:*:* + cpe:2.3:a:oracle:communications_offline_mediation_controller:12.0.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:communications_services_gatekeeper:7.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:communications_unified_inventory_management:7.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:communications_unified_inventory_management:7.4.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:data_integrator:12.2.1.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:data_integrator:12.2.1.4.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:enterprise_manager_for_peoplesoft:13.4.1.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_analytical_applications_infrastructure:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_institutional_performance_analytics:8.0.6:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_institutional_performance_analytics:8.1.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_institutional_performance_analytics:8.7.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_market_risk_measurement_and_management:8.0.6:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_market_risk_measurement_and_management:8.0.8:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_market_risk_measurement_and_management:8.1.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_price_creation_and_discovery:8.0.6:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_price_creation_and_discovery:8.0.7:*:*:*:*:*:*:* + cpe:2.3:a:oracle:financial_services_retail_customer_analytics:8.0.6:*:*:*:*:*:*:* + cpe:2.3:a:oracle:flexcube_core_banking:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:flexcube_core_banking:5.2.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:flexcube_private_banking:12.0.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:flexcube_private_banking:12.1.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:health_sciences_information_manager:3.0.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_insbridge_rating_and_underwriting:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_insbridge_rating_and_underwriting:5.6.1.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_policy_administration_j2ee:10.2.0.37:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_policy_administration_j2ee:10.2.4.12:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_policy_administration_j2ee:11.0.2.25:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_policy_administration_j2ee:11.1.0.15:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_policy_administration_j2ee:11.2.0.26:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_rules_palette:10.2.0.37:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_rules_palette:10.2.4.12:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_rules_palette:11.0.2.25:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_rules_palette:11.1.0.15:*:*:*:*:*:*:* + cpe:2.3:a:oracle:insurance_rules_palette:11.2.0.26:*:*:*:*:*:*:* + cpe:2.3:a:oracle:jd_edwards_world_security:a9.4:*:*:*:*:*:*:* + cpe:2.3:a:oracle:oracle_goldengate_application_adapters:19.1.0.0.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:peoplesoft_enterprise_peopletools:8.56:*:*:*:*:*:*:* + cpe:2.3:a:oracle:peoplesoft_enterprise_peopletools:8.57:*:*:*:*:*:*:* + cpe:2.3:a:oracle:peoplesoft_enterprise_peopletools:8.58:*:*:*:*:*:*:* + cpe:2.3:a:oracle:policy_automation:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:policy_automation_connector_for_siebel:10.4.6:*:*:*:*:*:*:* + cpe:2.3:a:oracle:policy_automation_for_mobile_devices:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:primavera_unifier:18.8:*:*:*:*:*:*:* + cpe:2.3:a:oracle:primavera_unifier:19.12:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_advanced_inventory_planning:14.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_assortment_planning:15.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_assortment_planning:16.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_bulk_data_integration:15.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_bulk_data_integration:16.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_customer_management_and_segmentation_foundation:16.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_customer_management_and_segmentation_foundation:17.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_customer_management_and_segmentation_foundation:18.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_customer_management_and_segmentation_foundation:19.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_eftlink:15.0.2:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_eftlink:16.0.3:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_eftlink:17.0.2:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_eftlink:18.0.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_eftlink:19.0.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_insights_cloud_service_suite:19.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_integration_bus:14.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_integration_bus:15.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_integration_bus:16.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_order_broker_cloud_service:16.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_order_broker_cloud_service:18.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_order_broker_cloud_service:19.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_order_broker_cloud_service:19.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_order_broker_cloud_service:19.2:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_order_broker_cloud_service:19.3:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_predictive_application_server:14.1.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_predictive_application_server:15.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_predictive_application_server:16.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_xstore_point_of_service:15.0.4:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_xstore_point_of_service:16.0.6:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_xstore_point_of_service:17.0.4:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_xstore_point_of_service:18.0.3:*:*:*:*:*:*:* + cpe:2.3:a:oracle:retail_xstore_point_of_service:19.0.2:*:*:*:*:*:*:* + cpe:2.3:a:oracle:siebel_apps_-_marketing:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:siebel_ui_framework:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:spatial_and_graph:18c:*:*:*:*:*:*:* + cpe:2.3:a:oracle:spatial_and_graph:19c:*:*:*:*:*:*:* + cpe:2.3:a:oracle:spatial_and_graph:12.2.0.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:storagetek_tape_analytics_sw_tool:2.3.1:*:*:*:*:*:*:* + cpe:2.3:a:oracle:utilities_framework:*:*:*:*:*:*:*:* + cpe:2.3:a:oracle:utilities_framework:2.2.0.0.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:utilities_framework:4.2.0.2.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:utilities_framework:4.2.0.3.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:utilities_framework:4.4.0.0.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:utilities_framework:4.4.0.2.0:*:*:*:*:*:*:* + cpe:2.3:a:oracle:weblogic_server:10.3.6.0.0:*:*:*:*:*:*:* + + + + + + + diff --git a/unittests/scans/generic/generic_report_multi_cwe_fabricated.json b/unittests/scans/generic/generic_report_multi_cwe_fabricated.json new file mode 100644 index 00000000000..47fe1e70464 --- /dev/null +++ b/unittests/scans/generic/generic_report_multi_cwe_fabricated.json @@ -0,0 +1,23 @@ +{ + "findings": [ + { + "title": "Multiple CWEs finding", + "severity": "High", + "description": "finding carrying a CWE set (mixed int and CWE- forms)", + "cwes": [89, "CWE-79", 22] + }, + { + "title": "Primary CWE plus extra CWEs", + "severity": "Medium", + "description": "single primary cwe with additional cwes", + "cwe": 79, + "cwes": [89, 200] + }, + { + "title": "Single CWE finding", + "severity": "Low", + "description": "legacy single cwe, no cwes list", + "cwe": 22 + } + ] +} diff --git a/unittests/scans/github_vulnerability/github_multi_cwe_fabricated.json b/unittests/scans/github_vulnerability/github_multi_cwe_fabricated.json new file mode 100644 index 00000000000..938dee22454 --- /dev/null +++ b/unittests/scans/github_vulnerability/github_multi_cwe_fabricated.json @@ -0,0 +1,53 @@ +{ + "data": { + "repository": { + "url": "https://github.com/example/repo", + "vulnerabilityAlerts": { + "nodes": [ + { + "number": 1, + "createdAt": "2022-05-09T09:43:40Z", + "state": "OPEN", + "id": "RVA_multi_cwe_0001", + "vulnerableManifestPath": "pom.xml", + "securityVulnerability": { + "severity": "CRITICAL", + "package": { + "name": "com.example:example" + }, + "advisory": { + "description": "Example advisory description with multiple CWEs.", + "summary": "Example vulnerability with multiple CWEs", + "identifiers": [ + { + "type": "GHSA", + "value": "GHSA-multi-cwe-0001" + }, + { + "type": "CVE", + "value": "CVE-2021-0001" + } + ], + "cwes": { + "nodes": [ + { + "cweId": "CWE-79" + }, + { + "cweId": "CWE-89" + } + ] + }, + "references": [ + { + "url": "https://github.com/advisories/GHSA-multi-cwe-0001" + } + ] + } + } + } + ] + } + } + } +} diff --git a/unittests/scans/gitlab_container_scan/gl-container-scanning-report-multiple-cwe-fabricated_v15.json b/unittests/scans/gitlab_container_scan/gl-container-scanning-report-multiple-cwe-fabricated_v15.json new file mode 100644 index 00000000000..390c1d68127 --- /dev/null +++ b/unittests/scans/gitlab_container_scan/gl-container-scanning-report-multiple-cwe-fabricated_v15.json @@ -0,0 +1,62 @@ +{ + "version": "3.0.0", + "vulnerabilities": [ + { + "id": "df52bc8ce9a2ae56bbcb0c4ecda62123fbd6f69b", + "description": "Incorrect sanitation of the 302 redirect field in HTTP transport method of apt versions 1.4.8 and earlier can lead to content injection by a MITM attacker, potentially leading to remote code execution on the target machine.", + "severity": "High", + "solution": "Upgrade apt from 1.4.8 to 1.4.9", + "location": { + "dependency": { + "package": { + "name": "apt" + }, + "version": "1.4.8" + }, + "operating_system": "debian:9.4", + "image": "registry.gitlab.com/gitlab-org/security-products/dast/webgoat-8.0@sha256:bc09fe2e0721dfaeee79364115aeedf2174cce0947b9ae5fe7c33312ee019a4e" + }, + "identifiers": [ + { + "type": "cve", + "name": "CVE-2019-3462", + "value": "CVE-2019-3462", + "url": "http://www.securityfocus.com/bid/106690" + }, + { + "type": "cwe", + "name": "CWE-79", + "value": "CWE-79", + "url": "https://cwe.mitre.org/data/definitions/79.html" + }, + { + "type": "cwe", + "name": "CWE-89", + "value": "CWE-89", + "url": "https://cwe.mitre.org/data/definitions/89.html" + } + ], + "links": [ + { + "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-3462" + } + ] + } + ], + "remediations": [], + "scan": { + "scanner": { + "id": "trivy", + "name": "Trivy", + "url": "https://github.com/aquasecurity/trivy/", + "vendor": { + "name": "GitLab" + }, + "version": "1.0.0" + }, + "type": "container_scanning", + "start_time": "2021-01-01T00:00:00", + "end_time": "2021-01-01T00:00:01", + "status": "success" + } +} diff --git a/unittests/scans/gitlab_dast/gitlab_dast_multiple_cwe_fabricated_v15.json b/unittests/scans/gitlab_dast/gitlab_dast_multiple_cwe_fabricated_v15.json new file mode 100644 index 00000000000..e01eb333574 --- /dev/null +++ b/unittests/scans/gitlab_dast/gitlab_dast_multiple_cwe_fabricated_v15.json @@ -0,0 +1,48 @@ +{ + "__comment__": "Minimal DAST report with a single vulnerability that has more than one CWE identifier", + "vulnerabilities": [ + { + "cve": "10062", + "description": "The response contains Personally Identifiable Information, such as CC number, SSN and similar sensitive data.", + "discovered_at": "2021-04-23T15:46:40.615", + "evidence": [], + "id": "5ec00bbc-2e53-44cb-83e9-3d35365277e3", + "identifiers": [ + { + "name": "PII Disclosure", + "type": "ZAProxy_PluginId", + "url": "https://github.com/zaproxy/zaproxy/blob/w2019-01-14/docs/scanners.md", + "value": "10062" + }, + { + "name": "CWE-359", + "type": "CWE", + "url": "https://cwe.mitre.org/data/definitions/359.html", + "value": "359" + }, + { + "name": "CWE-200", + "type": "CWE", + "url": "https://cwe.mitre.org/data/definitions/200.html", + "value": "200" + } + ], + "links": [], + "location": { + "hostname": "http://api-server", + "method": "GET", + "param": "", + "path": "/v1/trees" + }, + "severity": "High", + "solution": "" + } + ], + "scan": { + "scanner": { + "id": "zaproxy", + "name": "ZAProxy", + "version": "1.0.0" + } + } +} diff --git a/unittests/scans/gitlab_dep_scan/gl-dependency-scanning-report-multiple-cwe-fabricated_v15.json b/unittests/scans/gitlab_dep_scan/gl-dependency-scanning-report-multiple-cwe-fabricated_v15.json new file mode 100644 index 00000000000..08786152d90 --- /dev/null +++ b/unittests/scans/gitlab_dep_scan/gl-dependency-scanning-report-multiple-cwe-fabricated_v15.json @@ -0,0 +1,64 @@ +{ + "version": "3.0.0", + "vulnerabilities": [ + { + "id": "2d8b607cb56d9866c73cdcf33a016f64b4fa37d909c1dd300037b1ac026a3ca5", + "name": "XML Entity Expansion", + "description": "go-yaml is vulnerable to a Billion Laughs Attack.", + "cve": "service/go.sum:gopkg.in/yaml.v2:gemnasium:7368f513-0aa9-4e34-a08d-40ea81f48e0e", + "severity": "Unknown", + "solution": "Upgrade to version 2.2.3 or above.", + "location": { + "file": "service/go.sum", + "dependency": { + "package": { + "name": "gopkg.in/yaml.v2" + }, + "version": "v2.2.2" + } + }, + "identifiers": [ + { + "type": "gemnasium", + "name": "Gemnasium-7368f513-0aa9-4e34-a08d-40ea81f48e0e", + "value": "7368f513-0aa9-4e34-a08d-40ea81f48e0e", + "url": "https://gitlab.com/gitlab-org/security-products/gemnasium-db/-/blob/master/go/gopkg.in/yaml.v2/GMS-2019-2.yml" + }, + { + "type": "cwe", + "name": "CWE-776", + "value": "776", + "url": "https://cwe.mitre.org/data/definitions/776.html" + }, + { + "type": "cwe", + "name": "CWE-400", + "value": "400", + "url": "https://cwe.mitre.org/data/definitions/400.html" + } + ], + "links": [ + { + "url": "https://github.com/docker/cli/pull/2117" + } + ] + } + ], + "remediations": [], + "dependency_files": [], + "scan": { + "scanner": { + "id": "gemnasium", + "name": "Gemnasium", + "url": "https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium", + "vendor": { + "name": "GitLab" + }, + "version": "2.24.1" + }, + "type": "dependency_scanning", + "start_time": "2020-12-23T13:43:48", + "end_time": "2020-12-23T13:43:49", + "status": "success" + } +} diff --git a/unittests/scans/gitlab_sast/gl-sast-report-multiple-cwe-fabricated_v15.json b/unittests/scans/gitlab_sast/gl-sast-report-multiple-cwe-fabricated_v15.json new file mode 100644 index 00000000000..6dcd938d67a --- /dev/null +++ b/unittests/scans/gitlab_sast/gl-sast-report-multiple-cwe-fabricated_v15.json @@ -0,0 +1,53 @@ +{ + "version": "3.0.0", + "vulnerabilities": [ + { + "id": "38e1bf843be70b01cd6847ba877897acb4185d3445256f818871a4b66c0af712", + "name": "Servlet reflected cross site scripting vulnerability", + "description": "HTTP parameter written to Servlet output in servlets.module.lesson.XssLesson.doPost(HttpServletRequest, HttpServletResponse)", + "cve": "3e7ef723fe684a84eb7f9a94b27d1ea2:XSS_REQUEST_PARAMETER_TO_SERVLET_WRITER:src/main/java/servlets/module/lesson/XssLesson.java:96", + "severity": "High", + "location": { + "file": "src/main/java/servlets/module/lesson/XssLesson.java", + "start_line": 96, + "end_line": 96, + "class": "servlets.module.lesson.XssLesson", + "method": "doPost" + }, + "identifiers": [ + { + "type": "find_sec_bugs_type", + "name": "Find Security Bugs-XSS_REQUEST_PARAMETER_TO_SERVLET_WRITER", + "value": "XSS_REQUEST_PARAMETER_TO_SERVLET_WRITER", + "url": "https://spotbugs.readthedocs.io/en/latest/bugDescriptions.html#xss-servlet-reflected-cross-site-scripting-vulnerability-xss-request-parameter-to-servlet-writer" + }, + { + "type": "cwe", + "name": "CWE-79", + "value": "79", + "url": "https://cwe.mitre.org/data/definitions/79.html" + }, + { + "type": "cwe", + "name": "CWE-89", + "value": 89, + "url": "https://cwe.mitre.org/data/definitions/89.html" + } + ] + } + ], + "scan": { + "scanner": { + "id": "find_sec_bugs", + "name": "Find Security Bugs", + "version": "1.0.0", + "vendor": { + "name": "GitLab" + } + }, + "type": "sast", + "start_time": "2022-01-01T00:00:00", + "end_time": "2022-01-01T00:00:01", + "status": "success" + } +} diff --git a/unittests/scans/jfrog_xray_api_summary_artifact/multi_cwe_fabricated.json b/unittests/scans/jfrog_xray_api_summary_artifact/multi_cwe_fabricated.json new file mode 100644 index 00000000000..6b36d6c8fa4 --- /dev/null +++ b/unittests/scans/jfrog_xray_api_summary_artifact/multi_cwe_fabricated.json @@ -0,0 +1,30 @@ +{ + "artifacts": [{ + "general": { + "name": "artifact1:1.0", + "component_id": "artifact1:1.0", + "pkg_type": "Docker", + "path": "artifact_path/artifact1/1.0/", + "sha256": "eaab06c0a28618bfb65481bf31bce7d6dd3a15dac528297690111c202a1cd468" + }, + "issues": [{ + "issue_id": "XRAY-124116", + "summary": "OpenSSL crypto/rc5/rc5_skey.c RC5_32_set_key() Function Key Initialization Stack Buffer Overflow", + "description": "OpenSSL contains an overflow condition in the RC5_32_set_key() function in crypto/rc5/rc5_skey.c that is triggered as certain input is not properly validated when initializing encryption or decryption keys. This may allow a context-dependent attacker to cause a stack-based buffer overflow, resulting in a denial of service or potentially allowing the execution of arbitrary code.", + "issue_type": "security", + "severity": "Critical", + "provider": "JFrog", + "cves": [{ + "cve": "CVE-2016-2183", + "cwe": ["CWE-787", "CWE-119"], + "cvss_v2": "9.3/AV:N/AC:M/Au:N/C:C/I:C/A:C", + "cvss_v3": "9.8/CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "created": "2020-09-09T00:00:00.937Z", + "impact_path": ["artifact_path/artifact1/1.0/sha256__cbc330c4d62cdcdac9408f3fc679b06fc8a22b08638c8f25720f3cd621f52fb6.tar.gz/3.12:openssl:1.1.1k-r0"] + } + ] + } + ] +} diff --git a/unittests/scans/jfrogxray/multi_cwe_fabricated.json b/unittests/scans/jfrogxray/multi_cwe_fabricated.json new file mode 100644 index 00000000000..450e17c90b2 --- /dev/null +++ b/unittests/scans/jfrogxray/multi_cwe_fabricated.json @@ -0,0 +1,40 @@ +{ + "total_count": 1, + "data": [ + { + "id": "", + "severity": "High", + "summary": "An issue was discovered in libX11 through 1.6.5. The function XListExtensions in ListExt.c interprets a variable as signed instead of unsigned, resulting in an out-of-bounds write (of up to 128 bytes), leading to DoS or remote code execution.", + "issue_type": "security", + "provider": "JFrog", + "component": "debian:stretch:libx11", + "source_id": "deb://debian:stretch:libx11", + "source_comp_id": "deb://debian:stretch:libx11:2:1.6.4-3", + "component_versions": { + "id": "debian:stretch:libx11", + "vulnerable_versions": [ + "< 2:1.6.4-3+deb9u1" + ], + "fixed_versions": [ + "≥ 2:1.6.4-3+deb9u1" + ], + "more_details": { + "cves": [ + { + "cve": "CVE-2018-14600", + "cwe": [ + "CWE-787", + "CWE-119" + ], + "cvss_v2": "7.5/CVSS:2.0/AV:N/AC:L/Au:N/C:P/I:P/A:P", + "cvss_v3": "9.8/CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + } + ], + "description": "An issue was discovered in libX11 through 1.6.5. The function XListExtensions in ListExt.c interprets a variable as signed instead of unsigned, resulting in an out-of-bounds write (of up to 128 bytes), leading to DoS or remote code execution.", + "provider": "JFrog" + } + }, + "edited": "2019-05-14T00:21:16Z" + } + ] +} diff --git a/unittests/scans/npm_audit_7_plus/multiple_cwes_fabricated.json b/unittests/scans/npm_audit_7_plus/multiple_cwes_fabricated.json new file mode 100644 index 00000000000..613557db3d2 --- /dev/null +++ b/unittests/scans/npm_audit_7_plus/multiple_cwes_fabricated.json @@ -0,0 +1,59 @@ +{ + "auditReportVersion": 2, + "vulnerabilities": { + "debug": { + "name": "debug", + "severity": "high", + "isDirect": true, + "via": [ + { + "source": 1094222, + "name": "debug", + "dependency": "debug", + "title": "Regular Expression Denial of Service in debug", + "url": "https://github.com/advisories/GHSA-gxpj-cx7g-858c", + "severity": "moderate", + "cwe": [ + "CWE-400", + "CWE-1333" + ], + "cvss": { + "score": 5.3, + "vectorString": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L" + }, + "range": "<2.6.9" + }, + "ms" + ], + "effects": [ + ], + "range": "<=2.6.8", + "nodes": [ + "node_modules/debug" + ], + "fixAvailable": { + "name": "express", + "version": "4.18.3", + "isSemVerMajor": false + } + } + }, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 1, + "critical": 0, + "total": 1 + }, + "dependencies": { + "prod": 98, + "dev": 0, + "optional": 0, + "peer": 0, + "peerOptional": 0, + "total": 97 + } + } +} diff --git a/unittests/scans/nuclei/multi_cwe_fabricated.json b/unittests/scans/nuclei/multi_cwe_fabricated.json new file mode 100644 index 00000000000..e29dbeffddc --- /dev/null +++ b/unittests/scans/nuclei/multi_cwe_fabricated.json @@ -0,0 +1,31 @@ +[ + { + "template": "http/cves/multi-cwe.yaml", + "template-id": "multi-cwe-template", + "info": { + "name": "Multiple CWE Example", + "author": [ + "pdteam" + ], + "tags": [ + "cve" + ], + "description": "Example nuclei finding classified with multiple CWEs.", + "severity": "high", + "classification": { + "cve-id": [ + "CVE-2021-0001" + ], + "cwe-id": [ + "cwe-79", + "cwe-89" + ] + } + }, + "type": "http", + "host": "https://example.com", + "matched-at": "https://example.com/login", + "timestamp": "2023-11-06T14:48:31.591398+01:00", + "matcher-status": true + } +] diff --git a/unittests/scans/osv_scanner/multi_cwe_fabricated.json b/unittests/scans/osv_scanner/multi_cwe_fabricated.json new file mode 100644 index 00000000000..ec1e02d0e53 --- /dev/null +++ b/unittests/scans/osv_scanner/multi_cwe_fabricated.json @@ -0,0 +1,53 @@ +{ + "results": [ + { + "source": { + "path": "/tmp/components/yarn.lock", + "type": "lockfile" + }, + "packages": [ + { + "package": { + "name": "example-pkg", + "version": "1.0.0", + "ecosystem": "npm" + }, + "vulnerabilities": [ + { + "id": "GHSA-multi-cwe-0001", + "summary": "Example vulnerability with multiple CWEs", + "details": "Example details.", + "affected": [ + { + "package": { + "ecosystem": "npm", + "name": "example-pkg", + "purl": "pkg:npm/example-pkg" + }, + "database_specific": { + "cwes": [ + { + "cweId": "CWE-79", + "name": "Cross-site Scripting" + }, + { + "cweId": "CWE-89", + "name": "SQL Injection" + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-multi-cwe-0001" + } + ] + } + ] + } + ] + } + ] +} diff --git a/unittests/scans/ptart/ptart_vuln_plus_retest_multi_cwe_fabricated.json b/unittests/scans/ptart/ptart_vuln_plus_retest_multi_cwe_fabricated.json new file mode 100644 index 00000000000..0e3247277cb --- /dev/null +++ b/unittests/scans/ptart/ptart_vuln_plus_retest_multi_cwe_fabricated.json @@ -0,0 +1,77 @@ +{ + "name": "Test", + "executive_summary": "Mistakes were made", + "engagement_overview": "Things were done", + "conclusion": "Things should be put right", + "scope": "test.example.com", + "client": "Test Client", + "start_date": "2024-08-11", + "end_date": "2024-08-16", + "cvss_type": 3, + "tools": [ + "Burp Suite" + ], + "methodologies": [ + "OWASP Testing Guide V4.2" + ], + "pentesters": [ + { + "username": "hydragyrum", + "first_name": "", + "last_name": "" + } + ], + "assessments": [], + "retests": [ + { + "name": "Test Retest", + "introduction": "REEEEEEEEEEEEE-TEST!", + "conclusion": "Still broke, mate", + "start_date": "2024-09-08", + "end_date": "2024-09-13", + "hits": [ + { + "id": "PTART-2024-00002-RT", + "status": "NF", + "body": "Still borked", + "original_hit": { + "id": "PTART-2024-00002", + "title": "Broken Access Control", + "body": "Access control enforces policy such that users cannot act outside of their intended permissions. Failures typically lead to unauthorized information disclosure, modification or destruction of all data, or performing a business function outside of the limits of the user.", + "remediation": "Access control vulnerabilities can generally be prevented by taking a defense-in-depth approach and applying the following principles:\n\n* Never rely on obfuscation alone for access control.\n* Unless a resource is intended to be publicly accessible, deny access by default.\n* Wherever possible, use a single application-wide mechanism for enforcing access controls.\n* At the code level, make it mandatory for developers to declare the access that is allowed for each resource, and deny access by default.\n* Thoroughly audit and test access controls to ensure they are working as designed.", + "asset": "https://test.example.com", + "severity": 2, + "fix_complexity": 3, + "cvss_vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", + "cvss_score": "10.0", + "added": "2024-09-06T03:33:07.908", + "labels": [ + "A01:2021-Broken Access Control", + "A04:2021-Insecure Design" + ], + "cwes": [ + { + "cwe_id": 284, + "title": "CWE-284 - Improper Access Control" + }, + { + "cwe_id": 862, + "title": "CWE-862 - Missing Authorization" + } + ] + }, + "screenshots": [ + { + "caption": "Yet another Screenshot", + "order": 0, + "screenshot": { + "filename": "screenshots_retest/ea1c661f-7366-4619-a08b-133ec1a6cfd1.png", + "data": "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" + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/unittests/scans/semgrep/multiple_cwes_fabricated.json b/unittests/scans/semgrep/multiple_cwes_fabricated.json new file mode 100644 index 00000000000..40604bf712e --- /dev/null +++ b/unittests/scans/semgrep/multiple_cwes_fabricated.json @@ -0,0 +1,33 @@ +{ + "results": [ + { + "check_id": "java.lang.security.audit.cbc-padding-oracle.cbc-padding-oracle", + "path": "src/main/java/org/owasp/benchmark/testcode/BenchmarkTest02194.java", + "start": { + "line": 64, + "col": 4 + }, + "end": { + "line": 64, + "col": 83 + }, + "extra": { + "message": "Using CBC with PKCS5Padding is susceptible to padding oracle attacks.\n", + "metadata": { + "cwe": [ + "CWE-327: Use of a Broken or Risky Cryptographic Algorithm", + "CWE-696: Incorrect Behavior Order" + ], + "owasp": "A3: Sensitive Data Exposure", + "references": [ + "https://capec.mitre.org/data/definitions/463.html" + ] + }, + "severity": "WARNING", + "fix": "javax crypto Cipher.getInstance(\"AES/GCM/NoPadding\");", + "lines": "\t\t\tjavax.crypto.Cipher c = javax.crypto.Cipher.getInstance(\"DES/CBC/PKCS5Padding\");" + } + } + ], + "errors": [] +} diff --git a/unittests/scans/semgrep_pro/multiple_cwes_fabricated.json b/unittests/scans/semgrep_pro/multiple_cwes_fabricated.json new file mode 100644 index 00000000000..bcafe462610 --- /dev/null +++ b/unittests/scans/semgrep_pro/multiple_cwes_fabricated.json @@ -0,0 +1,46 @@ +{ + "findings": [ + { + "id": 1234567, + "match_based_id": "0f8c79a6f7e0ff2f908ff5bc366ae1548465069bae8892088051e1c3b4b12c6b8df37d5bcbb181eb868aa79f81f239d14bf2336d552786ab8ccdc7279adf07a6_1", + "repository": { + "name": "semgrep", + "url": "https://github.com/semgrep/semgrep" + }, + "triage_state": "untriaged", + "state": "unresolved", + "status": "open", + "severity": "medium", + "confidence": "medium", + "categories": [ + "security" + ], + "created_at": "2020-11-18T23:28:12.391807Z", + "rule_name": "typescript.react.security.audit.react-no-refs.react-no-refs", + "rule_message": "`ref` usage found.\n", + "location": { + "file_path": "frontend/src/corpComponents/Code.tsx", + "line": 120, + "column": 8, + "end_line": 124, + "end_column": 16 + }, + "rule": { + "name": "html.security.plaintext-http-link.plaintext-http-link", + "message": "This link points to a plaintext HTTP URL.", + "confidence": "high", + "category": "security", + "vulnerability_classes": [ + "Mishandled Sensitive Information" + ], + "cwe_names": [ + "CWE-319: Cleartext Transmission of Sensitive Information", + "CWE-311: Missing Encryption of Sensitive Data" + ], + "owasp_names": [ + "A03:2017 - Sensitive Data Exposure" + ] + } + } + ] +} diff --git a/unittests/scans/sonarqube/findings_over_api_multi_cwe_fabricated.json b/unittests/scans/sonarqube/findings_over_api_multi_cwe_fabricated.json new file mode 100644 index 00000000000..76ccd492e9b --- /dev/null +++ b/unittests/scans/sonarqube/findings_over_api_multi_cwe_fabricated.json @@ -0,0 +1,158 @@ +{ + "total": 42, + "p": 1, + "ps": 100, + "paging": { + "pageIndex": 1, + "pageSize": 100, + "total": 3 + }, + "effortTotal": 87, + "issues": [ + { + "key": "fjioefjwoefijo", + "rule": "OWASP:UsingComponentWithKnownVulnerability", + "severity": "MAJOR", + "component": "testapplication", + "project": "testapplication", + "flows": [ + { + "locations": [ + { + "component": "asdfsdf", + "textRange": { + "startLine": 1, + "endLine": 2, + "startOffset": 3, + "endOffset": 4 + }, + "msg": "sdfasfdasfd", + "msgFormattings": [] + } + ] + }, + { + "locations": [ + { + "component": "nonono", + "textRange": { + "startLine": 2, + "endLine": 4, + "startOffset": 7, + "endOffset": 9 + }, + "msg": "fghjfghjgfj", + "msgFormattings": [] + } + ] + } + ], + "status": "OPEN", + "message": "Filename: package:1.1.2 | Reference: CVE-2024-2529 | CVSS Score: 6.4 | Category: CWE-120 | Category: CWE-79 | Versions of the package vulndescription .", + "author": "", + "tags": [ + "cve", + "cwe", + "cwe-937", + "owasp-a9", + "vulnerability" + ], + "creationDate": "2023-10-16T15:05:35+0000", + "updateDate": "2023-11-03T08:00:46+0000", + "type": "VULNERABILITY", + "scope": "MAIN", + "quickFixAvailable": false, + "messageFormattings": [], + "codeVariants": [] + }, + { + "key": "asdfwfewfwefewf", + "rule": "Web:TableWithoutCaptionCheck", + "severity": "MINOR", + "component": "testapplication:src/app/pages/fjiowefjewio/fjwieof/fjiwoe/details.html", + "project": "testapplication", + "line": 59, + "hash": "1de6211e802eff1bb25bff705a0a4cf5", + "textRange": { + "startLine": 59, + "endLine": 59, + "startOffset": 6, + "endOffset": 113 + }, + "flows": [], + "status": "OPEN", + "message": "Add a description to this table.", + "effort": "5min", + "debt": "5min", + "author": "name.name@name.de", + "tags": [ + "accessibility", + "wcag2-a" + ], + "creationDate": "2023-07-25T13:16:32+0000", + "updateDate": "2023-08-18T08:33:52+0000", + "type": "BUG", + "scope": "MAIN", + "quickFixAvailable": false, + "messageFormattings": [], + "codeVariants": [] + }, + { + "key": "fjoiewfjoweifjoihugu-", + "rule": "typescript:S1533", + "severity": "MINOR", + "component": "testapplication:src/app/services/fjweiofwefjiofiwofjwof.fjewoi", + "project": "testapplication", + "line": 15, + "hash": "fw0fu90weu90u3904u1094u1409", + "textRange": { + "startLine": 15, + "endLine": 15, + "startOffset": 49, + "endOffset": 56 + }, + "flows": [], + "status": "OPEN", + "message": "Replace this \"Boolean\" wrapper object with primitive type \"boolean\".", + "effort": "1min", + "debt": "1min", + "author": "name.name@name.de", + "tags": [ + "pitfall" + ], + "creationDate": "2024-01-29T13:50:11+0000", + "updateDate": "2024-01-29T13:50:11+0000", + "type": "CODE_SMELL", + "scope": "MAIN", + "quickFixAvailable": true, + "messageFormattings": [], + "codeVariants": [] + } + ], + "components": [ + { + "key": "testapplication", + "enabled": true, + "qualifier": "TRK", + "name": "testapplication", + "longName": "testapplication" + }, + { + "key": "testapplication:src/app/pages/fjiowefjewio/fjwieof/fjiwoe/details.html", + "enabled": true, + "qualifier": "FIL", + "name": "details.html", + "longName": "src/app/pages/fjiowefjewio/fjwieof/fjiwoe/details.html", + "path": "src/app/pages/fjiowefjewio/fjwieof/fjiwoe/details.html" + }, + { + "key": "testapplication:src/app/services/fjweiofwefjiofiwofjwof.fjewoi", + "enabled": true, + "qualifier": "FIL", + "name": "fjweiofwefjiofiwofjwof.fjewoi", + "longName": "src/app/services/fjweiofwefjiofiwofjwof.fjewoi", + "path": "src/app/services/fjweiofwefjiofiwofjwof.fjewoi" + } + ], + "facets": [] +} \ No newline at end of file diff --git a/unittests/scans/veracode/veracode_scan_multi_cwe_fabricated.xml b/unittests/scans/veracode/veracode_scan_multi_cwe_fabricated.xml new file mode 100644 index 00000000000..611f48ae353 --- /dev/null +++ b/unittests/scans/veracode/veracode_scan_multi_cwe_fabricated.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/unittests/scans/wapiti/cwe_multi_cwe_fabricated.xml b/unittests/scans/wapiti/cwe_multi_cwe_fabricated.xml new file mode 100644 index 00000000000..116d215e088 --- /dev/null +++ b/unittests/scans/wapiti/cwe_multi_cwe_fabricated.xml @@ -0,0 +1,40 @@ + + + + wapiti + Wapiti 3.0.4 + folder + Sat, 27 Mar 2021 20:08:34 +0000 + https://example.org/ + + + + + + + + CWE-798: Use of Hard-coded Credentials + https://cwe.mitre.org/data/definitions/798.html + + + CWE-521: Weak Password Requirements + https://cwe.mitre.org/data/definitions/521.html + + + + + POST + /login + 1 + username=admin&password=admin + Weak credentials detected + + + + + + + + + diff --git a/unittests/scans/xygeni/sast_multi_cwe_fabricated.json b/unittests/scans/xygeni/sast_multi_cwe_fabricated.json new file mode 100644 index 00000000000..50d7dd3c1b9 --- /dev/null +++ b/unittests/scans/xygeni/sast_multi_cwe_fabricated.json @@ -0,0 +1,66 @@ +{ + "metadata": { + "uuid": "11111111-1111-1111-1111-111111111111", + "timestamp": "2026-01-01T00:00:00Z", + "projectName": "multi-cwe-project", + "scanType": "sast", + "format": "sast-xygeni", + "reportProperties": { + "tool.name": "Xygeni", + "tool.version": "6.7.0" + } + }, + "rules": [], + "vulnerabilities": [ + { + "detector": "python.multi_weakness_example", + "kind": "injection", + "severity": "high", + "location": { + "filepath": "app/handlers/multi.py", + "beginLine": 42, + "endLine": 42, + "code": "eval(request.GET['payload'])" + }, + "language": "python", + "confidence": "high", + "cwes": [ + "CWE-94", + "CWE-95" + ], + "tags": [ + "CWE:94", + "CWE:95", + "in-app-code" + ], + "uniqueHash": "MULTI-CWE-HASH-1", + "cwe": 94, + "issueId": "SAS.injection.python.multi_weakness_example.app/handlers/multi.py.42", + "explanation": "Dynamic code execution from untrusted input allows arbitrary code injection." + }, + { + "detector": "python.single_weakness_example", + "kind": "authentication", + "severity": "low", + "location": { + "filepath": "app/handlers/single.py", + "beginLine": 10, + "endLine": 10, + "code": "response.set_cookie('sid', sid)" + }, + "language": "python", + "confidence": "high", + "cwes": [ + "CWE-352" + ], + "tags": [ + "CWE:352" + ], + "uniqueHash": "SINGLE-CWE-HASH-1", + "cwe": 352, + "issueId": "SAS.authentication.python.single_weakness_example.app/handlers/single.py.10", + "explanation": "State-changing request without CSRF protection." + } + ], + "errors": [] +} diff --git a/unittests/scans/yarn_audit/issue_6495_multi_cwe_fabricated.json b/unittests/scans/yarn_audit/issue_6495_multi_cwe_fabricated.json new file mode 100644 index 00000000000..00777765b16 --- /dev/null +++ b/unittests/scans/yarn_audit/issue_6495_multi_cwe_fabricated.json @@ -0,0 +1,143 @@ +{ + "actions": [], + "advisories": { + "1068298": { + "findings": [ + { + "version": "1.3.5", + "paths": [ + "@angular/cli>ini", + "danger>parse-git-config>ini", + "@datorama/akita>schematics-utilities>@schematics/update>ini", + "@datorama/akita-ng-entity-service>@datorama/akita>schematics-utilities>@schematics/update>ini", + "nodemon>update-notifier>latest-version>package-json>registry-auth-token>rc>ini", + "@mikro-orm/cli>@mikro-orm/migrations>knex>liftoff>findup-sync>resolve-dir>global-modules>global-prefix>ini", + "@mikro-orm/cli>@mikro-orm/knex>@mikro-orm/migrations>knex>liftoff>findup-sync>resolve-dir>global-modules>global-prefix>ini", + "@mikro-orm/cli>@mikro-orm/entity-generator>@mikro-orm/knex>@mikro-orm/migrations>knex>liftoff>findup-sync>resolve-dir>global-modules>global-prefix>ini" + ] + } + ], + "metadata": null, + "vulnerable_versions": "<1.3.6", + "module_name": "ini", + "severity": "high", + "github_advisory_id": "GHSA-qqgx-2p2h-9c37", + "cves": [ + "CVE-2020-7788" + ], + "access": "public", + "patched_versions": ">=1.3.6", + "cvss": { + "score": 7.3, + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L" + }, + "updated": "2021-07-28T21:12:38.000Z", + "recommendation": "Upgrade to version 1.3.6 or later", + "cwe": [ + "CWE-1321", + "CWE-915" + ], + "found_by": null, + "deleted": null, + "id": 1068298, + "references": "- https://github.com/npm/ini/commit/56d2805e07ccd94e2ba0984ac9240ff02d44b6f1\n- https://www.npmjs.com/advisories/1589\n- https://snyk.io/vuln/SNYK-JS-INI-1048974\n- https://nvd.nist.gov/vuln/detail/CVE-2020-7788\n- https://lists.debian.org/debian-lts-announce/2020/12/msg00032.html\n- https://github.com/advisories/GHSA-qqgx-2p2h-9c37", + "created": "2020-12-10T16:53:45.000Z", + "reported_by": null, + "title": "Prototype Pollution", + "npm_advisory_id": null, + "overview": "### Overview\nThe `ini` npm package before version 1.3.6 has a Prototype Pollution vulnerability.\n\nIf an attacker submits a malicious INI file to an application that parses it with `ini.parse`, they will pollute the prototype on the application. This can be exploited further depending on the context.\n\n### Patches\n\nThis has been patched in 1.3.6\n\n### Steps to reproduce\n\npayload.ini\n```\n[__proto__]\npolluted = \"polluted\"\n```\n\npoc.js:\n```\nvar fs = require('fs')\nvar ini = require('ini')\n\nvar parsed = ini.parse(fs.readFileSync('./payload.ini', 'utf-8'))\nconsole.log(parsed)\nconsole.log(parsed.__proto__)\nconsole.log(polluted)\n```\n\n```\n> node poc.js\n{}\n{ polluted: 'polluted' }\n{ polluted: 'polluted' }\npolluted\n```", + "url": "https://github.com/advisories/GHSA-qqgx-2p2h-9c37" + }, + "1075625": { + "findings": [ + { + "version": "0.4.3", + "paths": [ + "@playwright/test>jpeg-js", + "@playwright/test>playwright-core>jpeg-js" + ] + } + ], + "metadata": null, + "vulnerable_versions": "<0.4.4", + "module_name": "jpeg-js", + "severity": "high", + "github_advisory_id": "GHSA-xvf7-4v9q-58w6", + "cves": [ + "CVE-2022-25851" + ], + "access": "public", + "patched_versions": ">=0.4.4", + "cvss": { + "score": 7.5, + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "updated": "2022-06-20T21:58:36.000Z", + "recommendation": "Upgrade to version 0.4.4 or later", + "cwe": [ + "CWE-835" + ], + "found_by": null, + "deleted": null, + "id": 1075625, + "references": "- https://nvd.nist.gov/vuln/detail/CVE-2022-25851\n- https://github.com/jpeg-js/jpeg-js/issues/105\n- https://github.com/jpeg-js/jpeg-js/pull/106/\n- https://github.com/jpeg-js/jpeg-js/commit/9ccd35fb5f55a6c4f1902ac5b0f270f675750c27\n- https://snyk.io/vuln/SNYK-JAVA-ORGWEBJARSNPM-2860295\n- https://snyk.io/vuln/SNYK-JS-JPEGJS-2859218\n- https://github.com/advisories/GHSA-xvf7-4v9q-58w6", + "created": "2022-06-11T00:00:17.000Z", + "reported_by": null, + "title": "Infinite loop in jpeg-js", + "npm_advisory_id": null, + "overview": "The package jpeg-js before 0.4.4 is vulnerable to Denial of Service (DoS) where a particular piece of input will cause the program to enter an infinite loop and never return.", + "url": "https://github.com/advisories/GHSA-xvf7-4v9q-58w6" + }, + "1075701": { + "findings": [ + { + "version": "9.6.0", + "paths": [ + "nodemon>update-notifier>latest-version>package-json>got" + ] + } + ], + "metadata": null, + "vulnerable_versions": "<11.8.5", + "module_name": "got", + "severity": "moderate", + "github_advisory_id": "GHSA-pfrx-2q88-qq97", + "cves": [ + "CVE-2022-33987" + ], + "access": "public", + "patched_versions": ">=11.8.5", + "cvss": { + "score": 0, + "vectorString": null + }, + "updated": "2022-06-27T17:09:23.000Z", + "recommendation": "Upgrade to version 11.8.5 or later", + "cwe": [], + "found_by": null, + "deleted": null, + "id": 1075701, + "references": "- https://nvd.nist.gov/vuln/detail/CVE-2022-33987\n- https://github.com/sindresorhus/got/pull/2047\n- https://github.com/sindresorhus/got/compare/v12.0.3...v12.1.0\n- https://github.com/sindresorhus/got/commit/861ccd9ac2237df762a9e2beed7edd88c60782dc\n- https://github.com/sindresorhus/got/releases/tag/v11.8.5\n- https://github.com/sindresorhus/got/releases/tag/v12.1.0\n- https://github.com/advisories/GHSA-pfrx-2q88-qq97", + "created": "2022-06-19T00:00:21.000Z", + "reported_by": null, + "title": "Got allows a redirect to a UNIX socket", + "npm_advisory_id": null, + "overview": "The got package before 11.8.5 and 12.1.0 for Node.js allows a redirect to a UNIX socket.", + "url": "https://github.com/advisories/GHSA-pfrx-2q88-qq97" + } + }, + "muted": [], + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 1, + "high": 10, + "critical": 0 + }, + "dependencies": 2236, + "devDependencies": 121, + "optionalDependencies": 0, + "totalDependencies": 2357 + } +} \ No newline at end of file diff --git a/unittests/test_false_positive_history_logic.py b/unittests/test_false_positive_history_logic.py index 5975348e14e..bbe1beb3585 100644 --- a/unittests/test_false_positive_history_logic.py +++ b/unittests/test_false_positive_history_logic.py @@ -4,6 +4,7 @@ from crum import impersonate from django.conf import settings +from django.test import override_settings from dojo.finding.deduplication import do_false_positive_history_batch from dojo.finding.ui.views import EditFinding @@ -125,6 +126,24 @@ # product 3: Security Podcast +# Module-level candidate-filter hooks for FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD. +# get_custom_method resolves the setting to a dotted path and imports it, so these must be +# importable at module scope. +_fp_candidate_filter_calls = [] + + +def _drop_all_fp_candidates(finding, candidates): + """Hook that discards every candidate — simulates a plugin rejecting all matches.""" + _fp_candidate_filter_calls.append((finding, list(candidates))) + return [] + + +def _passthrough_fp_candidates(finding, candidates): + """Hook that keeps every candidate — FP history must behave exactly as with no hook.""" + _fp_candidate_filter_calls.append((finding, list(candidates))) + return candidates + + @versioned_fixtures class TestFalsePositiveHistoryLogic(DojoTestCase): fixtures = ["dojo_testdata.json"] @@ -175,6 +194,46 @@ def test_fp_history_equal_hash_code_same_test(self): self.assert_finding(find_created_before_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) self.assert_finding(find_created_after_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) + # Candidate-filter hook (FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD) # + + @override_settings( + FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD="unittests.test_false_positive_history_logic._drop_all_fp_candidates", + ) + def test_fp_history_hook_can_suppress_matches(self): + # A plugin hook that drops all candidates must prevent FP replication even when + # findings share a hash_code with an existing false-positive finding. + _fp_candidate_filter_calls.clear() + find_created_before_mark, find_2 = self.copy_and_reset_finding(find_id=2) + find_created_before_mark.save() + find_2 = Finding.objects.get(id=2) + find_2.false_p = True + find_2.save() + find_created_after_mark, find_2 = self.copy_and_reset_finding(find_id=2) + find_created_after_mark.save() + # Hook discarded every candidate, so neither copy is marked despite the shared hash_code. + self.assert_finding(find_created_before_mark, false_p=False, not_pk=2, test_id=3, hash_code=find_2.hash_code) + self.assert_finding(find_created_after_mark, false_p=False, not_pk=2, test_id=3, hash_code=find_2.hash_code) + # And the hook was actually invoked. + self.assertTrue(_fp_candidate_filter_calls) + + @override_settings( + FINDING_FALSE_POSITIVE_HISTORY_CANDIDATE_FILTER_METHOD="unittests.test_false_positive_history_logic._passthrough_fp_candidates", + ) + def test_fp_history_hook_passthrough_matches_default(self): + # A passthrough hook must leave default FP-history behavior unchanged. + _fp_candidate_filter_calls.clear() + find_created_before_mark, find_2 = self.copy_and_reset_finding(find_id=2) + find_created_before_mark.save() + find_2 = Finding.objects.get(id=2) + find_2.false_p = True + find_2.save() + find_created_after_mark, find_2 = self.copy_and_reset_finding(find_id=2) + find_created_after_mark.save() + # Identical outcome to test_fp_history_equal_hash_code_same_test — both copies marked. + self.assert_finding(find_created_before_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) + self.assert_finding(find_created_after_mark, false_p=True, not_pk=2, test_id=3, hash_code=find_2.hash_code) + self.assertTrue(_fp_candidate_filter_calls) + # Finding 2 in Product 2, Engagement 1, Test 3 def test_fp_history_equal_hash_code_same_test_non_retroactive(self): # Disable retroactive FP history diff --git a/unittests/test_finding_cwe.py b/unittests/test_finding_cwe.py new file mode 100644 index 00000000000..3a43317f29e --- /dev/null +++ b/unittests/test_finding_cwe.py @@ -0,0 +1,186 @@ +"""CWE-as-a-separate-relationship (Finding_CWE) and the API.""" +from django.test import SimpleTestCase, override_settings +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from dojo.finding.cwe import ( + cwe_label, + cwe_number, + finding_cwe_labels, + parse_cwes, +) +from dojo.finding.helper import save_cwes +from dojo.models import Finding, Finding_CWE, User +from unittests.dojo_test_case import DojoAPITestCase, DojoTestCase, versioned_fixtures + + +class TestCweHelpers(SimpleTestCase): + + def test_cwe_number(self): + self.assertEqual(cwe_number("CWE-79"), 79) + self.assertEqual(cwe_number("cwe-79"), 79) + self.assertEqual(cwe_number("79"), 79) + self.assertEqual(cwe_number(79), 79) + self.assertIsNone(cwe_number("foo")) + self.assertIsNone(cwe_number("CWE-")) + self.assertIsNone(cwe_number(None)) + # 0 is Finding.cwe's "unset" sentinel, not a real CWE + self.assertIsNone(cwe_number(0)) + self.assertIsNone(cwe_number("CWE-0")) + + def test_cwe_label(self): + self.assertEqual(cwe_label("CWE-79"), "CWE-79") + self.assertEqual(cwe_label("cwe-79"), "CWE-79") + self.assertEqual(cwe_label("79"), "CWE-79") + self.assertEqual(cwe_label(79), "CWE-79") + self.assertIsNone(cwe_label("foo")) + self.assertIsNone(cwe_label(None)) + + def test_parse_cwes(self): + # canonical CWE- labels, deduplicated, order preserved + self.assertEqual(parse_cwes("79\n89\nCWE-22"), ["CWE-79", "CWE-89", "CWE-22"]) + self.assertEqual(parse_cwes("79, 89"), ["CWE-79", "CWE-89"]) + self.assertEqual(parse_cwes("cwe-79"), ["CWE-79"]) + self.assertEqual(parse_cwes("not-a-cwe\n89"), ["CWE-89"]) + self.assertEqual(parse_cwes("79\nCWE-79\n79"), ["CWE-79"]) + self.assertEqual(parse_cwes(""), []) + self.assertEqual(parse_cwes(None), []) + + def test_finding_cwe_labels(self): + # primary first, extras appended, mixed int/str input normalized, deduplicated + self.assertEqual(finding_cwe_labels(79, ["CWE-89", 89, "22"]), ["CWE-79", "CWE-89", "CWE-22"]) + self.assertEqual(finding_cwe_labels(0, [89]), ["CWE-89"]) + self.assertEqual(finding_cwe_labels(None, None), []) + + +@versioned_fixtures +class TestFindingCwe(DojoTestCase): + fixtures = ["dojo_testdata.json"] + + def setUp(self): + self.finding = Finding.objects.get(id=2) + Finding_CWE.objects.filter(finding=self.finding).delete() + + def _stored_cwes(self): + return set(Finding_CWE.objects.filter(finding=self.finding).values_list("cwe", flat=True)) + + def test_primary_cwe_saved_and_exposed(self): + self.finding.cwe = 79 + save_cwes(self.finding) + # stored as canonical CWE- strings (mirrors vulnerability_ids) + self.assertEqual(self._stored_cwes(), {"CWE-79"}) + self.assertEqual(Finding.objects.get(id=2).cwes, ["CWE-79"]) + + def test_multiple_cwes(self): + self.finding.cwe = 79 + self.finding.unsaved_cwes = [89, 89, 22] # includes a duplicate + save_cwes(self.finding) + self.assertEqual(self._stored_cwes(), {"CWE-79", "CWE-89", "CWE-22"}) + # primary first, deduplicated, CWE- form + self.assertEqual(Finding.objects.get(id=2).cwes, ["CWE-79", "CWE-89", "CWE-22"]) + + def test_no_cwe_stores_nothing(self): + self.finding.cwe = 0 + save_cwes(self.finding) + self.assertEqual(self._stored_cwes(), set()) + self.assertEqual(Finding.objects.get(id=2).cwes, []) + + def test_copy_finding_copies_cwes(self): + self.finding.cwe = 79 + self.finding.unsaved_cwes = [89] + save_cwes(self.finding) + copy = self.finding.copy() + self.assertEqual({"CWE-79", "CWE-89"}, set(copy.finding_cwe_set.values_list("cwe", flat=True))) + + +@versioned_fixtures +class TestFindingCwesAPI(DojoAPITestCase): + fixtures = ["dojo_testdata.json"] + + def setUp(self): + super().setUp() + self.system_settings(enable_jira=True) + self.testuser = User.objects.get(username="admin") + self.testuser.usercontactinfo.block_execution = True + self.testuser.usercontactinfo.save() + token = Token.objects.get(user=self.testuser) + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + self.client.force_login(self.get_test_admin()) + + def test_finding_create_with_cwes(self): + # cwes mirror vulnerability_ids: nested [{"cwe": "CWE-79"}], first is the primary Finding.cwe + finding_details = self.get_finding_api(2) + del finding_details["id"] + finding_details.pop("cve", None) + finding_details.pop("cwe", None) + new_cwes = [{"cwe": "CWE-79"}, {"cwe": "CWE-89"}] + finding_details["cwes"] = new_cwes + response = self.post_new_finding_api(finding_details) + # response echoes the CWEs in CWE- form + self.assertEqual({"CWE-79", "CWE-89"}, {entry["cwe"] for entry in response.get("cwes")}) + finding = Finding.objects.get(id=response.get("id")) + # primary cwe (legacy int) set from the first entry; both stored as canonical Finding_CWE rows + self.assertEqual(79, finding.cwe) + self.assertEqual({"CWE-79", "CWE-89"}, set(finding.finding_cwe_set.values_list("cwe", flat=True))) + + +@versioned_fixtures +class TestFindingCweHashCode(DojoTestCase): + + """get_cwes() and its use in compute_hash_code, incl. the unsaved_cwes import path.""" + + fixtures = ["dojo_testdata.json"] + + def setUp(self): + self.finding = Finding.objects.get(id=2) + Finding_CWE.objects.filter(finding=self.finding).delete() + + def test_get_cwes_prefers_unsaved_cwes(self): + # Extra CWE rows are not written yet; get_cwes must still reflect them via unsaved_cwes. + self.finding.cwe = 79 + self.finding.unsaved_cwes = [89, 22] + self.assertEqual(self.finding.get_cwes(), "".join(sorted(["CWE-79", "CWE-89", "CWE-22"]))) + + def test_get_cwes_stable_before_and_after_save(self): + # The pre-save (unsaved_cwes) and post-save (finding_cwe_set) hashes must agree. + self.finding.cwe = 79 + self.finding.unsaved_cwes = [89, 22] + before = self.finding.get_cwes() + save_cwes(self.finding) + reloaded = Finding.objects.get(id=2) # unsaved_cwes is None on a fresh load + self.assertEqual(before, reloaded.get_cwes()) + self.assertEqual(reloaded.get_cwes(), "".join(sorted(["CWE-79", "CWE-89", "CWE-22"]))) + + def test_get_cwes_empty_when_no_cwe(self): + self.finding.cwe = 0 + self.finding.unsaved_cwes = None + save_cwes(self.finding) + self.assertEqual(Finding.objects.get(id=2).get_cwes(), "") + + def test_get_cwes_ignores_stale_cwes_cached_property(self): + # Regression: get_cwes() must read finding_cwe_set directly, not the cwes @cached_property. + # If cwes was cached (e.g. accessed before the rows were written) get_cwes must still be + # correct, otherwise the hash_code is nondeterministic. + self.finding.cwe = 79 + self.finding.unsaved_cwes = [89] + save_cwes(self.finding) + f = Finding.objects.get(id=2) + f.__dict__["cwes"] = [] # poison the cached_property with a stale/empty value + self.assertEqual(f.get_cwes(), "".join(sorted(["CWE-79", "CWE-89"]))) + + def test_compute_hash_code_uses_cwe_set(self): + self.finding.cwe = 79 + scanner = self.finding.test.test_type.name + # FINDING_COMPUTE_HASH_METHOD=None forces the OSS compute_hash_code path under test + # (a Pro deployment would otherwise delegate to its tuner-driven hash method). + with override_settings( + FINDING_COMPUTE_HASH_METHOD=None, + HASHCODE_FIELDS_PER_SCANNER={scanner: ["title", "cwes"]}, + ): + self.finding.unsaved_cwes = [89] + hash_with_89 = self.finding.compute_hash_code() + self.finding.unsaved_cwes = [22] + hash_with_22 = self.finding.compute_hash_code() + # Different CWE set -> different hash, so cwes participates in the hash. + self.assertNotEqual(hash_with_89, hash_with_22) diff --git a/unittests/test_importers_performance.py b/unittests/test_importers_performance.py index 47d30a99824..595a660b82e 100644 --- a/unittests/test_importers_performance.py +++ b/unittests/test_importers_performance.py @@ -345,9 +345,9 @@ def test_import_reimport_reimport_performance_pghistory_async(self): self._import_reimport_performance( expected_num_queries1=157, expected_num_async_tasks1=2, - expected_num_queries2=122, + expected_num_queries2=124, expected_num_async_tasks2=1, - expected_num_queries3=29, + expected_num_queries3=30, expected_num_async_tasks3=1, expected_num_queries4=100, expected_num_async_tasks4=0, @@ -367,11 +367,11 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=173, + expected_num_queries1=174, expected_num_async_tasks1=2, - expected_num_queries2=130, + expected_num_queries2=133, expected_num_async_tasks2=1, - expected_num_queries3=37, + expected_num_queries3=39, expected_num_async_tasks3=1, expected_num_queries4=100, expected_num_async_tasks4=0, @@ -392,11 +392,11 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=183, + expected_num_queries1=184, expected_num_async_tasks1=4, - expected_num_queries2=140, + expected_num_queries2=143, expected_num_async_tasks2=3, - expected_num_queries3=44, + expected_num_queries3=46, expected_num_async_tasks3=3, expected_num_queries4=109, expected_num_async_tasks4=2, @@ -545,9 +545,9 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=109, + expected_num_queries1=110, expected_num_async_tasks1=2, - expected_num_queries2=90, + expected_num_queries2=91, expected_num_async_tasks2=2, ) @@ -635,9 +635,9 @@ def test_import_reimport_reimport_performance_pghistory_async(self): self._import_reimport_performance( expected_num_queries1=164, expected_num_async_tasks1=2, - expected_num_queries2=131, + expected_num_queries2=133, expected_num_async_tasks2=1, - expected_num_queries3=37, + expected_num_queries3=38, expected_num_async_tasks3=1, expected_num_queries4=101, expected_num_async_tasks4=0, @@ -657,11 +657,11 @@ def test_import_reimport_reimport_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._import_reimport_performance( - expected_num_queries1=182, + expected_num_queries1=183, expected_num_async_tasks1=2, - expected_num_queries2=141, + expected_num_queries2=144, expected_num_async_tasks2=1, - expected_num_queries3=47, + expected_num_queries3=49, expected_num_async_tasks3=1, expected_num_queries4=101, expected_num_async_tasks4=0, @@ -682,11 +682,11 @@ def test_import_reimport_reimport_performance_pghistory_no_async_with_product_gr self.system_settings(enable_product_grade=True) self._import_reimport_performance( - expected_num_queries1=195, + expected_num_queries1=196, expected_num_async_tasks1=4, - expected_num_queries2=154, + expected_num_queries2=157, expected_num_async_tasks2=3, - expected_num_queries3=54, + expected_num_queries3=56, expected_num_async_tasks3=3, expected_num_queries4=113, expected_num_async_tasks4=2, @@ -809,8 +809,8 @@ def test_deduplication_performance_pghistory_no_async(self): testuser.usercontactinfo.save() self._deduplication_performance( - expected_num_queries1=118, + expected_num_queries1=119, expected_num_async_tasks1=2, - expected_num_queries2=201, + expected_num_queries2=202, expected_num_async_tasks2=2, ) diff --git a/unittests/test_tag_inheritance_perf.py b/unittests/test_tag_inheritance_perf.py index 698b1126a85..26d80be66de 100644 --- a/unittests/test_tag_inheritance_perf.py +++ b/unittests/test_tag_inheritance_perf.py @@ -590,9 +590,12 @@ def test_baseline_zap_scan_reimport_with_new_findings_v3(self): # the async watson indexer, executed inline under CELERY_TASK_ALWAYS_EAGER); # +5 reimport (no-change + with-new) queries from removal of # WATSON_ASYNC_INDEX_UPDATE_THRESHOLD making async dispatch unconditional. - EXPECTED_ZAP_IMPORT_V2 = 287 - EXPECTED_ZAP_IMPORT_V3 = 311 - EXPECTED_ZAP_REIMPORT_NO_CHANGE_V2 = 74 - EXPECTED_ZAP_REIMPORT_NO_CHANGE_V3 = 86 - EXPECTED_ZAP_REIMPORT_WITH_NEW_V2 = 148 - EXPECTED_ZAP_REIMPORT_WITH_NEW_V3 = 177 + # Multiple-CWEs feature: +2 import / +2 reimport-no-change (Finding_CWE + # store + bulk flush) and +10 reimport-with-new (per-finding reconcile reads + # existing Finding_CWE rows for each changed finding). + EXPECTED_ZAP_IMPORT_V2 = 289 + EXPECTED_ZAP_IMPORT_V3 = 313 + EXPECTED_ZAP_REIMPORT_NO_CHANGE_V2 = 76 + EXPECTED_ZAP_REIMPORT_NO_CHANGE_V3 = 88 + EXPECTED_ZAP_REIMPORT_WITH_NEW_V2 = 158 + EXPECTED_ZAP_REIMPORT_WITH_NEW_V3 = 187 diff --git a/unittests/test_vulnerability_id_type.py b/unittests/test_vulnerability_id_type.py new file mode 100644 index 00000000000..ea9276b7875 --- /dev/null +++ b/unittests/test_vulnerability_id_type.py @@ -0,0 +1,51 @@ +"""Vulnerability_Id type autodetection, the backfill helper path, and the uniqueness constraint.""" +from django.db import IntegrityError, transaction +from django.test import SimpleTestCase + +from dojo.finding.helper import save_vulnerability_ids +from dojo.finding.vulnerability_id import resolve_vulnerability_id_type +from dojo.models import Finding, Vulnerability_Id +from unittests.dojo_test_case import DojoTestCase, versioned_fixtures + + +class TestVulnerabilityIdTypeResolver(SimpleTestCase): + + def test_autodetect(self): + self.assertEqual(resolve_vulnerability_id_type("CVE-2024-1234"), "CVE") + self.assertEqual(resolve_vulnerability_id_type("GHSA-9v3m-xxxx"), "GHSA") + self.assertEqual(resolve_vulnerability_id_type("RUSTSEC-2021-0001"), "RUSTSEC") + self.assertEqual(resolve_vulnerability_id_type("ALINUX2-SA-2021"), "ALINUX2") + self.assertEqual(resolve_vulnerability_id_type("cve-2024-1"), "CVE") + # No non-numeric prefix -> None + self.assertIsNone(resolve_vulnerability_id_type("1234")) + self.assertIsNone(resolve_vulnerability_id_type("")) + self.assertIsNone(resolve_vulnerability_id_type(None)) + + +@versioned_fixtures +class TestVulnerabilityIdType(DojoTestCase): + fixtures = ["dojo_testdata.json"] + + def setUp(self): + self.finding = Finding.objects.get(id=2) + Vulnerability_Id.objects.filter(finding=self.finding).delete() + + def test_save_sets_type(self): + row = Vulnerability_Id.objects.create(finding=self.finding, vulnerability_id="CVE-2024-1234") + self.assertEqual(row.vulnerability_id_type, "CVE") + # bare number -> no type + row2 = Vulnerability_Id.objects.create(finding=self.finding, vulnerability_id="12345") + self.assertIsNone(row2.vulnerability_id_type) + + def test_bulk_save_sets_type(self): + # save_vulnerability_ids uses bulk_create, which sets the type at construction + save_vulnerability_ids(self.finding, ["CVE-2024-1", "GHSA-aaaa-bbbb"]) + types = dict( + Vulnerability_Id.objects.filter(finding=self.finding).values_list("vulnerability_id", "vulnerability_id_type"), + ) + self.assertEqual(types, {"CVE-2024-1": "CVE", "GHSA-aaaa-bbbb": "GHSA"}) + + def test_unique_constraint(self): + Vulnerability_Id.objects.create(finding=self.finding, vulnerability_id="CVE-2024-1234") + with self.assertRaises(IntegrityError), transaction.atomic(): + Vulnerability_Id.objects.create(finding=self.finding, vulnerability_id="CVE-2024-1234") diff --git a/unittests/tools/test_acunetix_parser.py b/unittests/tools/test_acunetix_parser.py index a13088124a4..2ea927cd875 100644 --- a/unittests/tools/test_acunetix_parser.py +++ b/unittests/tools/test_acunetix_parser.py @@ -301,6 +301,7 @@ def test_parse_file_with_mulitple_cwe(self): finding = findings[0] self.assertEqual("Medium", finding.severity) self.assertEqual(16, finding.cwe) + self.assertEqual([16, 200], finding.unsaved_cwes) self.assertIsNotNone(finding.description) self.assertGreater(len(finding.description), 0) self.assertEqual("CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N/E:H/RL:O/RC:C", finding.cvssv3) diff --git a/unittests/tools/test_api_edgescan_parser.py b/unittests/tools/test_api_edgescan_parser.py index 33058a548f2..5d4dfcf2016 100644 --- a/unittests/tools/test_api_edgescan_parser.py +++ b/unittests/tools/test_api_edgescan_parser.py @@ -101,3 +101,11 @@ def test_parse_file_with_multiple_vuln_has_multiple_finding(self): self.assertEqual(1, len(self.get_unsaved_locations(finding_2))) self.assertEqual(self.get_unsaved_locations(finding_2)[0].host, "example.test.com") self.assertFalse(self.get_unsaved_locations(finding_2)[0].protocol) + + def test_parse_file_with_multi_cwe_finding(self): + with (get_unit_tests_scans_path("api_edgescan") / "many_vulns_multi_cwe_fabricated.json").open(encoding="utf-8") as testfile: + parser = ApiEdgescanParser() + findings = parser.get_findings(testfile, Test()) + # The multi-CWE vulnerability is the first entry in the file and therefore the first finding. + self.assertEqual(findings[0].cwe, 77) + self.assertEqual(findings[0].unsaved_cwes, [77, 548]) diff --git a/unittests/tools/test_api_sonarqube_parser.py b/unittests/tools/test_api_sonarqube_parser.py index 7e86adc44f2..2f74522def4 100644 --- a/unittests/tools/test_api_sonarqube_parser.py +++ b/unittests/tools/test_api_sonarqube_parser.py @@ -29,6 +29,11 @@ def dummy_rule(self, *args, **kwargs): return json.load(json_file) +def dummy_rule_multi_cwe(self, *args, **kwargs): + with (get_unit_tests_scans_path("api_sonarqube") / "rule_multi_cwe_fabricated.json").open(encoding="utf-8") as json_file: + return json.load(json_file) + + def dummy_hotspot_rule(self, *args, **kwargs): with (get_unit_tests_scans_path("api_sonarqube") / "hotspots" / "rule.json").open(encoding="utf-8") as json_file: return json.load(json_file) @@ -62,3 +67,16 @@ def test_get_findings(self): parser = ApiSonarQubeParser() findings = parser.get_findings(None, self.test) self.assertEqual(2, len(findings)) + + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.get_project", dummy_product) + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.get_rule", dummy_rule_multi_cwe) + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.find_issues", dummy_issues) + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.get_hotspot_rule", dummy_hotspot_rule) + @mock.patch("dojo.tools.api_sonarqube.api_client.SonarQubeAPI.find_hotspots", empty_list) + def test_get_findings_multi_cwe(self): + # A single SonarQube rule can reference multiple CWEs (e.g. in the "See" section); + # the first is the primary cwe and the full ordered list is kept in unsaved_cwes. + parser = ApiSonarQubeParser() + findings = parser.get_findings(None, self.test) + self.assertEqual(563, findings[0].cwe) + self.assertEqual([563, 570], findings[0].unsaved_cwes) diff --git a/unittests/tools/test_aws_inspector2_parser.py b/unittests/tools/test_aws_inspector2_parser.py index 21b1cecf70c..edf777c3f7e 100644 --- a/unittests/tools/test_aws_inspector2_parser.py +++ b/unittests/tools/test_aws_inspector2_parser.py @@ -74,6 +74,16 @@ def test_aws_inspector2_package_vuln_metadata_fields(self): self.assertEqual("1.24.4", dependency_locations[0].data["version"]) self.assertEqual("extensions/collector", dependency_locations[0].data["file_path"]) + def test_aws_inspector2_parser_multi_cwe_finding(self): + """A CODE_VULNERABILITY finding populates the primary cwe plus the full unsaved_cwes list.""" + with (get_unit_tests_scans_path("aws_inspector2") / "aws_inspector2_package_vuln_metadata_multi_cwe_fabricated.json").open(encoding="utf-8") as testfile: + parser = AWSInspector2Parser() + findings = parser.get_findings(testfile, Test()) + # The multi-CWE CODE_VULNERABILITY finding is the first entry in the file and therefore the first finding. + self.assertEqual("CWE-117,93 - Log injection", findings[0].title) + self.assertEqual("CWE-117", findings[0].cwe) + self.assertEqual(["CWE-117", "CWE-93"], findings[0].unsaved_cwes) + def test_aws_inspector2_parser_empty_with_error(self): with self.assertRaises(TypeError) as context, \ (get_unit_tests_scans_path("aws_inspector2") / "empty_with_error.json").open(encoding="utf-8") as testfile: diff --git a/unittests/tools/test_bearer_cli_parser.py b/unittests/tools/test_bearer_cli_parser.py index 33898942dd2..30fb032b07f 100644 --- a/unittests/tools/test_bearer_cli_parser.py +++ b/unittests/tools/test_bearer_cli_parser.py @@ -28,3 +28,12 @@ def test_bearer_parser_with_many_vuln_has_many_findings(self): findings = parser.get_findings(testfile, Test()) testfile.close() self.assertEqual(4, len(findings)) + + def test_bearer_parser_with_multi_cwe_finding(self): + testfile = (get_unit_tests_scans_path("bearer_cli") / "bearer_cli_many_vul_multi_cwe_fabricated.json").open(encoding="utf-8") + parser = BearerCLIParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + # The multi-CWE vulnerability is the first entry in the file and therefore the first finding. + self.assertEqual("79", findings[0].cwe) + self.assertEqual(["79", "80"], findings[0].unsaved_cwes) diff --git a/unittests/tools/test_burp_graphql_parser.py b/unittests/tools/test_burp_graphql_parser.py index 62891accc8f..9e81be1cf88 100644 --- a/unittests/tools/test_burp_graphql_parser.py +++ b/unittests/tools/test_burp_graphql_parser.py @@ -15,6 +15,7 @@ def test_burp_one_finding(self): self.assertEqual(1, len(findings)) self.assertEqual("Finding", findings[0].title) self.assertEqual(79, findings[0].cwe) + self.assertEqual([79], findings[0].unsaved_cwes) self.assertIn("description 1", findings[0].description) self.assertIn("remediation 1", findings[0].mitigation) self.assertIn("issue description 1", findings[0].impact) @@ -62,6 +63,9 @@ def test_burp_null_request_segments(self): findings = parser.get_findings(test_file, Test()) self.validate_locations(findings) self.assertEqual(1, len(findings)) + # A single Burp issue_type can carry multiple CWE classifications + self.assertEqual(295, findings[0].cwe) + self.assertEqual([295, 326, 327], findings[0].unsaved_cwes) def test_burp_null_data(self): with (get_unit_tests_scans_path("burp_graphql") / "null_data.json").open(encoding="utf-8") as test_file: diff --git a/unittests/tools/test_burp_parser.py b/unittests/tools/test_burp_parser.py index 7db1e05c97e..87141fc8405 100644 --- a/unittests/tools/test_burp_parser.py +++ b/unittests/tools/test_burp_parser.py @@ -54,6 +54,7 @@ def test_burp_with_one_vuln_with_cwe(self): self.assertEqual("7340288", findings[0].vuln_id_from_tool) self.assertEqual("Cacheable HTTPS response", findings[0].title) self.assertEqual(524, findings[0].cwe) + self.assertEqual([524, 525], findings[0].unsaved_cwes) self.assertEqual("Info", findings[0].severity) def test_burp_issue4399(self): diff --git a/unittests/tools/test_burp_suite_dast_parser.py b/unittests/tools/test_burp_suite_dast_parser.py index a9fde38061b..8f1fe67f821 100644 --- a/unittests/tools/test_burp_suite_dast_parser.py +++ b/unittests/tools/test_burp_suite_dast_parser.py @@ -18,6 +18,7 @@ def test_burp_suite_dast_with_multiple_vulns(self): self.assertEqual("High", finding.severity) self.assertTrue(finding.dynamic_finding) self.assertEqual(942, finding.cwe) + self.assertEqual([942], finding.unsaved_cwes) self.assertEqual("Cross-origin resource sharing: arbitrary origin trusted", finding.title) self.assertIn("**Issue detail**:\nThe application implements an HTML5 cross-origin resource sharing (CORS) policy", finding.description) self.assertIn("An HTML5 cross-origin resource sharing (CORS) policy controls", finding.impact) @@ -25,6 +26,13 @@ def test_burp_suite_dast_with_multiple_vulns(self): self.assertEqual(1, len(self.get_unsaved_locations(finding))) self.assertEqual("example.com", self.get_unsaved_locations(finding)[0].host) + with self.subTest(i=3): + # A single Burp Suite DAST issue can list multiple CWE classifications + finding = findings[3] + self.assertEqual("TLS certificate", finding.title) + self.assertEqual(295, finding.cwe) + self.assertEqual([295, 326, 327], finding.unsaved_cwes) + with self.subTest(i=5): finding = findings[5] self.assertEqual("Info", finding.severity) diff --git a/unittests/tools/test_cyberwatch_galeax_parser.py b/unittests/tools/test_cyberwatch_galeax_parser.py index 503e56d4f42..75b64a74f6c 100644 --- a/unittests/tools/test_cyberwatch_galeax_parser.py +++ b/unittests/tools/test_cyberwatch_galeax_parser.py @@ -48,6 +48,7 @@ def test_one_cve(self): self.assertEqual(0.00044, finding.epss_score) self.assertEqual("Updated At: 2024-12-06T14:15:19.530+01:00", finding.references) self.assertEqual(787, finding.cwe) + self.assertEqual([787, "118", "664", "119"], finding.unsaved_cwes) self.assertEqual(1, len(self.get_unsaved_locations(finding))) # Validate locations locations = [e.host for e in self.get_unsaved_locations(finding)] diff --git a/unittests/tools/test_cyclonedx_parser.py b/unittests/tools/test_cyclonedx_parser.py index e98b5338ff8..725528b116c 100644 --- a/unittests/tools/test_cyclonedx_parser.py +++ b/unittests/tools/test_cyclonedx_parser.py @@ -155,7 +155,8 @@ def test_cyclonedx_1_4_xml(self): self.assertEqual("Critical", finding.severity) self.assertEqual("jackson-databind", finding.component_name) self.assertEqual("2.9.4", finding.component_version) - self.assertIn(finding.cwe, [184, 502]) # there is 2 CWE in the report + self.assertEqual(184, finding.cwe) # primary is the first CWE in the report + self.assertEqual([184, 502], finding.unsaved_cwes) # there are 2 CWE in the report self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) self.assertIn( "FasterXML jackson-databind before 2.7.9.3, 2.8.x before 2.8.11.1 and 2.9.x before 2.9.5 allows unauthenticated remote code execution", @@ -186,6 +187,16 @@ def test_cyclonedx_1_4_xml(self): self.assertTrue(finding.is_mitigated) self.assertFalse(finding.active) + def test_cyclonedx_multiple_cwes_xml(self): + """CycloneDX native 1.4 XML vulnerability block with more than one CWE""" + with (get_unit_tests_scans_path("cyclonedx") / "multiple_cwes_fabricated.xml").open(encoding="utf-8") as file: + parser = CycloneDXParser() + findings = list(parser.get_findings(file, Test())) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual(79, finding.cwe) # primary is the first CWE in the report + self.assertEqual([79, 89], finding.unsaved_cwes) # there are 2 CWE in the report + def test_cyclonedx_1_4_json(self): """CycloneDX version 1.4 JSON format""" with (get_unit_tests_scans_path("cyclonedx") / "valid-vulnerability-1.4.json").open(encoding="utf-8") as file: @@ -201,6 +212,8 @@ def test_cyclonedx_1_4_json(self): self.assertEqual("Critical", finding.severity) self.assertEqual("jackson-databind", finding.component_name) self.assertEqual("2.9.4", finding.component_version) + self.assertEqual(184, finding.cwe) # primary is the first CWE in the report + self.assertEqual([184, 502], finding.unsaved_cwes) # there are 2 CWE in the report self.assertEqual("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", finding.cvssv3) self.assertIn( "FasterXML jackson-databind before 2.7.9.3, 2.8.x before 2.8.11.1 and 2.9.x before 2.9.5 allows unauthenticated remote code execution", diff --git a/unittests/tools/test_dependency_check_parser.py b/unittests/tools/test_dependency_check_parser.py index 67349f25871..54c65b4c97a 100644 --- a/unittests/tools/test_dependency_check_parser.py +++ b/unittests/tools/test_dependency_check_parser.py @@ -258,6 +258,15 @@ def test_parse_java_6_5_3(self): self.assertEqual(items[i].file_path, "log4j-api-2.12.4.jar") self.assertEqual(items[i].date, datetime(2022, 1, 15, 14, 31, 13, 42600, tzinfo=UTC)) + def test_parse_java_6_5_3_multi_cwe(self): + """A vulnerability with multiple entries populates the primary cwe plus the full unsaved_cwes list.""" + with (get_unit_tests_scans_path("dependency_check") / "version-6.5.3_multi_cwe_fabricated.xml").open(encoding="utf-8") as test_file: + parser = DependencyCheckParser() + items = parser.get_findings(test_file, Test()) + # The multi-CWE vulnerability is the only/first vulnerability and therefore the first finding. + self.assertEqual(items[0].cwe, 295) + self.assertEqual(items[0].unsaved_cwes, [295, 297]) + def test_parse_file_pr6439(self): with (get_unit_tests_scans_path("dependency_check") / "PR6439.xml").open(encoding="utf-8") as testfile: parser = DependencyCheckParser() diff --git a/unittests/tools/test_generic_parser.py b/unittests/tools/test_generic_parser.py index 441c669ff62..2bf7fbb7bd6 100644 --- a/unittests/tools/test_generic_parser.py +++ b/unittests/tools/test_generic_parser.py @@ -1,5 +1,6 @@ import datetime +from dojo.finding.cwe import finding_cwe_labels from dojo.models import Engagement, Finding, Product, Test from dojo.tools.generic.parser import GenericParser from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path @@ -542,6 +543,75 @@ def test_parse_endpoints_and_vulnerability_ids_json(self): self.assertEqual("GHSA-5mrr-rgp6-x4gr", finding.unsaved_vulnerability_ids[0]) self.assertEqual("CVE-2015-9235", finding.unsaved_vulnerability_ids[1]) + def test_parse_multiple_cwes_json(self): + with (get_unit_tests_scans_path("generic") / "generic_report_multi_cwe_fabricated.json").open(encoding="utf-8") as file: + parser = GenericParser() + findings = parser.get_findings(file, Test()) + self.validate_locations(findings) + self.assertEqual(3, len(findings)) + + # "cwes" set with no "cwe": the first entry becomes the primary, + # the full set (mixed int / "CWE-" forms) is kept on unsaved_cwes. + finding = findings[0] + self.assertEqual(89, finding.cwe) + self.assertEqual([89, "CWE-79", 22], finding.unsaved_cwes) + self.assertEqual( + ["CWE-89", "CWE-79", "CWE-22"], + finding_cwe_labels(finding.cwe, finding.unsaved_cwes), + ) + + # explicit "cwe" is kept as the primary; "cwes" adds the extras. + finding = findings[1] + self.assertEqual(79, finding.cwe) + self.assertEqual([89, 200], finding.unsaved_cwes) + self.assertEqual( + ["CWE-79", "CWE-89", "CWE-200"], + finding_cwe_labels(finding.cwe, finding.unsaved_cwes), + ) + + # legacy single "cwe" is unchanged and sets no unsaved_cwes. + finding = findings[2] + self.assertEqual(22, finding.cwe) + self.assertIsNone(finding.unsaved_cwes) + self.assertEqual( + ["CWE-22"], + finding_cwe_labels(finding.cwe, finding.unsaved_cwes), + ) + + def test_parse_multiple_cwes_csv(self): + content = """Date,Title,CweId,CweIds,Severity,Description +11/7/2015,Multi CWE row,79,"89, CWE-22",High,desc +""" + file = TestFile("findings.csv", content) + parser = GenericParser() + findings = parser.get_findings(file, self.test) + self.validate_locations(findings) + finding = findings[0] + # CweId stays the primary; CweIds populates the full set (canonical form). + self.assertEqual(79, finding.cwe) + self.assertEqual(["CWE-89", "CWE-22"], finding.unsaved_cwes) + self.assertEqual( + ["CWE-79", "CWE-89", "CWE-22"], + finding_cwe_labels(finding.cwe, finding.unsaved_cwes), + ) + + def test_parse_multiple_cwes_without_primary_csv(self): + content = """Date,Title,CweIds,Severity,Description +11/7/2015,CWE set only,"CWE-611, 918",High,desc +""" + file = TestFile("findings.csv", content) + parser = GenericParser() + findings = parser.get_findings(file, self.test) + self.validate_locations(findings) + finding = findings[0] + # No CweId column: the first CweIds entry becomes the primary. + self.assertEqual(611, finding.cwe) + self.assertEqual(["CWE-611", "CWE-918"], finding.unsaved_cwes) + self.assertEqual( + ["CWE-611", "CWE-918"], + finding_cwe_labels(finding.cwe, finding.unsaved_cwes), + ) + def test_mitigated_csv_findings(self): with (get_unit_tests_scans_path("generic") / "generic_report3.csv").open(encoding="utf-8") as file: parser = GenericParser() diff --git a/unittests/tools/test_github_vulnerability_parser.py b/unittests/tools/test_github_vulnerability_parser.py index 803ac8a5f02..8eba91362d8 100644 --- a/unittests/tools/test_github_vulnerability_parser.py +++ b/unittests/tools/test_github_vulnerability_parser.py @@ -39,6 +39,18 @@ def test_parse_file_with_one_vuln_has_one_findings(self): self.assertEqual(finding.component_name, "package") self.assertEqual(finding.unique_id_from_tool, "aabbccddeeff1122334401") + def test_parse_file_with_multi_cwe(self): + """Sample where the advisory carries multiple CWE nodes""" + with (get_unit_tests_scans_path("github_vulnerability") / "github_multi_cwe_fabricated.json").open( + encoding="utf-8", + ) as testfile: + parser = GithubVulnerabilityParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual(79, finding.cwe) + self.assertEqual(["CWE-79", "CWE-89"], finding.unsaved_cwes) + def test_parse_file_with_one_vuln_has_one_finding_and_dependabot_direct_link(self): """Sample with dependabot PR and repository alert link""" with (get_unit_tests_scans_path("github_vulnerability") / "github-1-vuln-repo-dependabot-link.json").open( diff --git a/unittests/tools/test_gitlab_container_scan_parser.py b/unittests/tools/test_gitlab_container_scan_parser.py index 8027e5906a1..38bdaa0367a 100644 --- a/unittests/tools/test_gitlab_container_scan_parser.py +++ b/unittests/tools/test_gitlab_container_scan_parser.py @@ -46,6 +46,16 @@ def test_gitlab_container_scan_parser_with_one_vuln_has_one_findings_v15(self): self.assertEqual("Upgrade apt from 1.4.8 to 1.4.9", first_finding.mitigation) self.assertEqual("df52bc8ce9a2ae56bbcb0c4ecda62123fbd6f69b", first_finding.unique_id_from_tool) + def test_gitlab_container_scan_parser_with_multiple_cwe_v15(self): + with (get_unit_tests_scans_path("gitlab_container_scan") / "gl-container-scanning-report-multiple-cwe-fabricated_v15.json").open(encoding="utf-8") as testfile: + parser = GitlabContainerScanParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + first_finding = findings[0] + self.assertEqual("CWE-79", first_finding.cwe) # primary is the first CWE identifier + self.assertEqual(["CWE-79", "CWE-89"], first_finding.unsaved_cwes) # both CWE identifiers are kept + self.assertEqual(["CVE-2019-3462"], first_finding.unsaved_vulnerability_ids) + def test_gitlab_container_scan_parser_with_five_vuln_has_five_findings_v14(self): with (get_unit_tests_scans_path("gitlab_container_scan") / "gl-container-scanning-report-5-vuln_v14.json").open(encoding="utf-8") as testfile: parser = GitlabContainerScanParser() diff --git a/unittests/tools/test_gitlab_dast_parser.py b/unittests/tools/test_gitlab_dast_parser.py index 08bc6d00246..8ae64a9764d 100644 --- a/unittests/tools/test_gitlab_dast_parser.py +++ b/unittests/tools/test_gitlab_dast_parser.py @@ -61,6 +61,15 @@ def test_parse_file_with_one_vuln_has_one_finding_v15(self): self.assertEqual(359, finding.cwe) + def test_parse_file_with_multiple_cwe_v15(self): + with (get_unit_tests_scans_path("gitlab_dast") / "gitlab_dast_multiple_cwe_fabricated_v15.json").open(encoding="utf-8") as testfile: + parser = GitlabDastParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual(359, finding.cwe) # primary is the first CWE identifier + self.assertEqual([359, 200], finding.unsaved_cwes) # both CWE identifiers are kept + def test_parse_file_with_multiple_vuln_has_multiple_findings_v14(self): with (get_unit_tests_scans_path("gitlab_dast") / "gitlab_dast_many_vul_v14.json").open(encoding="utf-8") as testfile: parser = GitlabDastParser() diff --git a/unittests/tools/test_gitlab_dep_scan_parser.py b/unittests/tools/test_gitlab_dep_scan_parser.py index 70c40e78aa1..e57d2a74bda 100644 --- a/unittests/tools/test_gitlab_dep_scan_parser.py +++ b/unittests/tools/test_gitlab_dep_scan_parser.py @@ -23,6 +23,15 @@ def test_parse_file_with_one_vuln_has_one_finding_v15(self): findings = parser.get_findings(testfile, Test()) self.assertEqual(1, len(findings)) + def test_parse_file_with_multiple_cwe_v15(self): + with (get_unit_tests_scans_path("gitlab_dep_scan") / "gl-dependency-scanning-report-multiple-cwe-fabricated_v15.json").open(encoding="utf-8") as testfile: + parser = GitlabDepScanParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual("776", finding.cwe) # primary is the first CWE identifier + self.assertEqual(["776", "400"], finding.unsaved_cwes) # both CWE identifiers are kept + def test_parse_file_with_two_vuln_has_one_missing_component__v14(self): with (get_unit_tests_scans_path("gitlab_dep_scan") / "gl-dependency-scanning-report-2-vuln-missing-component_v14.json").open(encoding="utf-8") as testfile: parser = GitlabDepScanParser() diff --git a/unittests/tools/test_gitlab_sast_parser.py b/unittests/tools/test_gitlab_sast_parser.py index 49a85dd7c3a..8f568fa2442 100644 --- a/unittests/tools/test_gitlab_sast_parser.py +++ b/unittests/tools/test_gitlab_sast_parser.py @@ -115,6 +115,14 @@ def test_parse_file_with_various_cwes_v15(self): self.assertEqual(89, findings[1].cwe) self.assertEqual(None, findings[2].cwe) + def test_parse_file_with_multiple_cwes_v15(self): + with (get_unit_tests_scans_path("gitlab_sast") / "gl-sast-report-multiple-cwe-fabricated_v15.json").open(encoding="utf-8") as testfile: + parser = GitlabSastParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(len(findings), 1) + self.assertEqual(79, findings[0].cwe) # primary is the first CWE identifier + self.assertEqual([79, 89], findings[0].unsaved_cwes) # both CWE identifiers are kept + def test_parse_file_issue4336_v14(self): with (get_unit_tests_scans_path("gitlab_sast") / "gl-sast-report_issue4344_v14.json").open(encoding="utf-8") as testfile: parser = GitlabSastParser() diff --git a/unittests/tools/test_harbor_vulnerability_parser.py b/unittests/tools/test_harbor_vulnerability_parser.py index c75b6255b2c..d0c3692c98d 100644 --- a/unittests/tools/test_harbor_vulnerability_parser.py +++ b/unittests/tools/test_harbor_vulnerability_parser.py @@ -55,6 +55,7 @@ def test_parse_file_with_multiple_vuln_has_multiple_trivy_findings(self): finding = findings[0] self.assertEqual(finding.severity, "High") self.assertEqual(finding.cwe, "125") + self.assertEqual(["CWE-125", "CWE-787"], finding.unsaved_cwes) # Sample with harborapi pip def test_parse_file_with_multiple_vuln_has_harborapi_pip_package(self): diff --git a/unittests/tools/test_jfrog_xray_api_summary_artifact_parser.py b/unittests/tools/test_jfrog_xray_api_summary_artifact_parser.py index 2307a4c18bd..fa1b035964d 100644 --- a/unittests/tools/test_jfrog_xray_api_summary_artifact_parser.py +++ b/unittests/tools/test_jfrog_xray_api_summary_artifact_parser.py @@ -62,6 +62,18 @@ def test_parse_file_with_many_vulns(self): self.assertEqual(1, len(finding.unsaved_vulnerability_ids)) self.assertEqual("CVE-2021-42385", finding.unsaved_vulnerability_ids[0]) + def test_parse_file_with_multi_cwe(self): + testfile = ( + get_unit_tests_scans_path("jfrog_xray_api_summary_artifact") / "multi_cwe_fabricated.json").open(encoding="utf-8", + ) + parser = JFrogXrayApiSummaryArtifactParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual(787, finding.cwe) + self.assertEqual([787, 119], finding.unsaved_cwes) + def test_parse_file_with_malformed_cvssv3_score(self): testfile = ( get_unit_tests_scans_path("jfrog_xray_api_summary_artifact") / "malformed_cvssv3.json").open(encoding="utf-8", diff --git a/unittests/tools/test_jfrogxray_parser.py b/unittests/tools/test_jfrogxray_parser.py index 4d3cf7cf41c..94f4b6ae759 100644 --- a/unittests/tools/test_jfrogxray_parser.py +++ b/unittests/tools/test_jfrogxray_parser.py @@ -60,6 +60,16 @@ def test_parse_file_with_many_vulns2(self): self.assertEqual(787, item.cwe) self.assertEqual("CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H", item.cvssv3) + def test_parse_file_with_multi_cwe(self): + testfile = (get_unit_tests_scans_path("jfrogxray") / "multi_cwe_fabricated.json").open(encoding="utf-8") + parser = JFrogXrayParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(1, len(findings)) + item = findings[0] + self.assertEqual(787, item.cwe) + self.assertEqual([787, 119], item.unsaved_cwes) + def test_decode_cwe_number(self): with self.subTest(val="CWE-1234"): self.assertEqual(1234, decode_cwe_number("CWE-1234")) diff --git a/unittests/tools/test_netsparker_parser.py b/unittests/tools/test_netsparker_parser.py index 276fc977dd8..275038e1bf8 100644 --- a/unittests/tools/test_netsparker_parser.py +++ b/unittests/tools/test_netsparker_parser.py @@ -140,6 +140,11 @@ def test_parse_file_issue_11020(self): self.assertEqual(205, finding.cwe) # Generated date is "2024-10-08 02:33 PM" self.assertEqual("08/10/2024", finding.date.strftime("%d/%m/%Y")) + with self.subTest(i=1): + # "Out-of-date Version (Apache)" carries multiple CWEs: "1035, 937" + finding = findings[1] + self.assertEqual(1035, finding.cwe) + self.assertEqual(["1035", "937"], finding.unsaved_cwes) @override_settings(USE_FIRST_SEEN=True) def test_parse_file_issue_11020_first_seen(self): diff --git a/unittests/tools/test_npm_audit_7_plus_parser.py b/unittests/tools/test_npm_audit_7_plus_parser.py index 81a31ede8aa..77df63d8a6f 100644 --- a/unittests/tools/test_npm_audit_7_plus_parser.py +++ b/unittests/tools/test_npm_audit_7_plus_parser.py @@ -40,6 +40,19 @@ def test_npm_audit_7_plus_parser_with_many_vuln_has_many_findings(self): self.assertGreater(len(finding.description), 0) self.assertEqual("@vercel/fun", finding.title) + def test_npm_audit_7_plus_parser_multiple_cwes_per_finding(self): + # a single via entry can report multiple CWEs, e.g. "cwe": ["CWE-400","CWE-1333"] + testfile = (get_unit_tests_scans_path("npm_audit_7_plus") / "multiple_cwes_fabricated.json").open(encoding="utf-8") + parser = NpmAudit7PlusParser() + findings = parser.get_findings(testfile, Test()) + testfile.close() + self.assertEqual(1, len(findings)) + with self.subTest(i=0): + finding = findings[0] + self.assertEqual("High", finding.severity) + self.assertEqual(400, finding.cwe) + self.assertEqual([400, 1333], finding.unsaved_cwes) + def test_npm_audit_7_plus_parser_issue_10801(self): testfile = (get_unit_tests_scans_path("npm_audit_7_plus") / "issue_10801.json").open(encoding="utf-8") parser = NpmAudit7PlusParser() diff --git a/unittests/tools/test_npm_audit_parser.py b/unittests/tools/test_npm_audit_parser.py index 28a0f8bca19..2e00713b705 100644 --- a/unittests/tools/test_npm_audit_parser.py +++ b/unittests/tools/test_npm_audit_parser.py @@ -52,6 +52,7 @@ def test_npm_audit_parser_multiple_cwes_per_finding_list(self): findings = parser.get_findings(testfile, Test()) self.assertEqual(6, len(findings)) self.assertEqual(918, findings[0].cwe) + self.assertEqual([918, 1333], findings[0].unsaved_cwes) def test_npm_audit_parser_with_one_criticle_vuln_has_null_as_cwe(self): with (get_unit_tests_scans_path("npm_audit") / "cwe_null.json").open(encoding="utf-8") as testfile: diff --git a/unittests/tools/test_nuclei_parser.py b/unittests/tools/test_nuclei_parser.py index f2888fc18bc..38804aa3a03 100644 --- a/unittests/tools/test_nuclei_parser.py +++ b/unittests/tools/test_nuclei_parser.py @@ -31,6 +31,15 @@ def test_parse_issue_9201(self): finding = findings[-1] self.assertEqual("example.com", self.get_unsaved_locations(finding)[0].host) + def test_parse_multi_cwe(self): + with (get_unit_tests_scans_path("nuclei") / "multi_cwe_fabricated.json").open(encoding="utf-8") as testfile: + parser = NucleiParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual(79, finding.cwe) + self.assertEqual(["cwe-79", "cwe-89"], finding.unsaved_cwes) + def test_parse_many_findings(self): with (get_unit_tests_scans_path("nuclei") / "many_findings.json").open(encoding="utf-8") as testfile: parser = NucleiParser() diff --git a/unittests/tools/test_osv_scanner_parser.py b/unittests/tools/test_osv_scanner_parser.py index 3b0be91f4de..18f9349ee9f 100644 --- a/unittests/tools/test_osv_scanner_parser.py +++ b/unittests/tools/test_osv_scanner_parser.py @@ -23,6 +23,15 @@ def test_some_findings(self): self.assertEqual(finding.unsaved_vulnerability_ids[0], "MAL-2023-1035") self.assertEqual(finding.severity, "Low") + def test_multi_cwe(self): + with (get_unit_tests_scans_path("osv_scanner") / "multi_cwe_fabricated.json").open(encoding="utf-8") as testfile: + parser = OSVScannerParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual(finding.cwe, "CWE-79") + self.assertEqual(["CWE-79", "CWE-89"], finding.unsaved_cwes) + def test_many_findings(self): with (get_unit_tests_scans_path("osv_scanner") / "many_findings.json").open(encoding="utf-8") as testfile: parser = OSVScannerParser() diff --git a/unittests/tools/test_ptart_parser.py b/unittests/tools/test_ptart_parser.py index 9841fa24b0f..2d8755d1d3c 100644 --- a/unittests/tools/test_ptart_parser.py +++ b/unittests/tools/test_ptart_parser.py @@ -8,6 +8,7 @@ parse_attachment_from_hit, parse_cwe_from_hit, parse_cwe_id_from_cwe, + parse_cwes_from_hit, parse_locations_from_hit, parse_ptart_fix_effort, parse_ptart_severity, @@ -446,6 +447,59 @@ def test_ptart_parser_tools_parse_cwe_from_hit(self): with self.subTest("No CWE in hit"): hit = {} self.assertEqual(None, parse_cwe_from_hit(hit)) + with self.subTest("Multiple CWEs does not mutate hit"): + hit = { + "cwes": [{ + "cwe_id": 862, + "title": "CWE-862 - Missing Authorization", + }, { + "cwe_id": 863, + "title": "CWE-863 - Improper Authorization", + }], + } + self.assertEqual(863, parse_cwe_from_hit(hit)) + # The primary lookup must not consume/mutate the cwes list. + self.assertEqual(2, len(hit["cwes"])) + + def test_ptart_parser_tools_parse_cwes_from_hit(self): + with self.subTest("Single CWE"): + hit = { + "cwes": [{ + "cwe_id": 862, + "title": "CWE-862 - Missing Authorization", + }], + } + self.assertEqual([862], parse_cwes_from_hit(hit)) + with self.subTest("Multiple CWEs preserves order"): + hit = { + "cwes": [{ + "cwe_id": 284, + "title": "CWE-284 - Improper Access Control", + }, { + "cwe_id": 862, + "title": "CWE-862 - Missing Authorization", + }], + } + self.assertEqual([284, 862], parse_cwes_from_hit(hit)) + with self.subTest("Partial CWE Definition (title only)"): + hit = { + "cwes": [{ + "title": "CWE-862 - Missing Authorization", + }], + } + self.assertEqual([862], parse_cwes_from_hit(hit)) + with self.subTest("Drops unparseable CWEs"): + hit = { + "cwes": [{ + "cwe_id": 284, + "title": "CWE-284 - Improper Access Control", + }, {}], + } + self.assertEqual([284], parse_cwes_from_hit(hit)) + with self.subTest("Empty CWEs"): + self.assertEqual([], parse_cwes_from_hit({"cwes": []})) + with self.subTest("No CWE in hit"): + self.assertEqual([], parse_cwes_from_hit({})) def get_locations_from_hit(self, hit): if not settings.V3_FEATURE_LOCATIONS: @@ -788,6 +842,17 @@ def test_ptart_parser_with_retest_campaign(self): self.assertEqual("Yet another Screenshot.png", screenshot["title"]) self.assertTrue(screenshot["data"].startswith("iVBORw0KGgoAAAAN"), "Invalid Screenshot Data") + def test_ptart_parser_with_multi_cwe_fabricated_copy(self): + # Fabricated copy: the retest's original_hit carries multiple CWEs + # ([284, 862]); the primary CWE remains the last element (862). + with (get_unit_tests_scans_path("ptart") / "ptart_vuln_plus_retest_multi_cwe_fabricated.json").open(encoding="utf-8") as testfile: + parser = PTARTParser() + findings = parser.get_findings(testfile, self.test) + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual(862, finding.cwe) + self.assertEqual([284, 862], finding.unsaved_cwes) + def test_ptart_parser_with_single_vuln_containing_multiple_cwes(self): with (get_unit_tests_scans_path("ptart") / "ptart_one_vul_multiple_cwe.json").open(encoding="utf-8") as testfile: parser = PTARTParser() @@ -818,6 +883,7 @@ def test_ptart_parser_with_single_vuln_containing_multiple_cwes(self): self.assertEqual("Test Assessment", finding.component_name) self.assertEqual("2024-09-06", finding.date.strftime("%Y-%m-%d")) self.assertEqual(862, finding.cwe) + self.assertEqual([284, 862], finding.unsaved_cwes) self.assertEqual(2, len(finding.unsaved_tags)) self.assertEqual([ "A01:2021-Broken Access Control", diff --git a/unittests/tools/test_sarif_parser.py b/unittests/tools/test_sarif_parser.py index d9036043d18..5ff976bef2c 100644 --- a/unittests/tools/test_sarif_parser.py +++ b/unittests/tools/test_sarif_parser.py @@ -412,6 +412,7 @@ def test_flawfinder(self): self.assertEqual("src/common/io.cc", finding.file_path) self.assertEqual(31, finding.line) self.assertEqual(120, finding.cwe) + self.assertEqual([120], finding.unsaved_cwes) self.assertEqual("FF1004", finding.vuln_id_from_tool) self.assertEqual( "327fc54b75ab37bbbb31a1b71431aaefa8137ff755acc103685ad5adf88f5dda", finding.unique_id_from_tool, @@ -434,6 +435,10 @@ def test_flawfinder(self): self.assertEqual(description, finding.description) self.assertEqual("src/cli_main.cc", finding.file_path) self.assertEqual(482, finding.line) + # rule reports two CWEs (CWE-120, CWE-20); primary cwe keeps the existing + # last-extracted choice while the full set is persisted via unsaved_cwes + self.assertEqual(20, finding.cwe) + self.assertEqual([120, 20], finding.unsaved_cwes) self.assertEqual("FF1021", finding.vuln_id_from_tool) self.assertEqual( "ad8408027235170e870e7662751a01386beb2d2ed8beb75dd4ba8e4a70e91d65", finding.unique_id_from_tool, diff --git a/unittests/tools/test_semgrep_parser.py b/unittests/tools/test_semgrep_parser.py index 9f306c0dc24..405ab94c3fd 100644 --- a/unittests/tools/test_semgrep_parser.py +++ b/unittests/tools/test_semgrep_parser.py @@ -100,10 +100,21 @@ def test_parse_cwe_list(self): self.assertEqual("index.js", finding.file_path) self.assertEqual(12, finding.line) self.assertEqual(352, finding.cwe) + self.assertEqual([352], finding.unsaved_cwes) self.assertEqual("javascript.express.security.audit.express-check-csurf-middleware-usage.express-check-csurf-middleware-usage", finding.vuln_id_from_tool) self.assertIn("const app = express();", finding.description) self.assertIn("A CSRF middleware was not detected in your express application. Ensure you are either using one such as `csurf` or `csrf` (see rule references) and/or you are properly doing CSRF validation in your routes with a token or cookies.", finding.description) + def test_parse_multiple_cwes(self): + with (get_unit_tests_scans_path("semgrep") / "multiple_cwes_fabricated.json").open(encoding="utf-8") as testfile: + parser = SemgrepParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + finding = findings[0] + # primary CWE is the first in the list; the full list is persisted via unsaved_cwes + self.assertEqual(327, finding.cwe) + self.assertEqual([327, 696], finding.unsaved_cwes) + def test_different_lines_same_fingerprint(self): with (get_unit_tests_scans_path("semgrep") / "semgrep_version_1_30_0_line_26.json").open(encoding="utf-8") as testfile: parser = SemgrepParser() diff --git a/unittests/tools/test_semgrep_pro_parser.py b/unittests/tools/test_semgrep_pro_parser.py index 2b40ff32c4c..5c89bf2df5d 100644 --- a/unittests/tools/test_semgrep_pro_parser.py +++ b/unittests/tools/test_semgrep_pro_parser.py @@ -27,6 +27,7 @@ def test_parse_one_finding(self): self.assertEqual("frontend/src/corpComponents/Code.tsx", finding.file_path) self.assertEqual(120, finding.line) self.assertEqual(319, finding.cwe) # CWE-319: Cleartext Transmission of Sensitive Information + self.assertEqual([319], finding.unsaved_cwes) self.assertTrue(finding.static_finding) self.assertFalse(finding.dynamic_finding) @@ -71,3 +72,14 @@ def test_parse_one_finding(self): # Unique identifier self.assertEqual("0f8c79a6f7e0ff2f908ff5bc366ae1548465069bae8892088051e1c3b4b12c6b8df37d5bcbb181eb868aa79f81f239d14bf2336d552786ab8ccdc7279adf07a6_1", finding.unique_id_from_tool) + + def test_parse_multiple_cwes(self): + path = get_unit_tests_scans_path("semgrep_pro") / "multiple_cwes_fabricated.json" + with path.open(encoding="utf-8") as testfile: + parser = SemgrepProParser() + findings = parser.get_findings(testfile, Test()) + self.assertEqual(1, len(findings)) + finding = findings[0] + # primary CWE is the first in cwe_names; the full list is persisted via unsaved_cwes + self.assertEqual(319, finding.cwe) + self.assertEqual([319, 311], finding.unsaved_cwes) diff --git a/unittests/tools/test_snyk_issue_api_parser.py b/unittests/tools/test_snyk_issue_api_parser.py index fed08f3c405..7e61062536d 100644 --- a/unittests/tools/test_snyk_issue_api_parser.py +++ b/unittests/tools/test_snyk_issue_api_parser.py @@ -1,6 +1,8 @@ from django.test import TestCase +from dojo.models import Test from dojo.tools.snyk_issue_api.parser import SnykIssueApiParser +from unittests.dojo_test_case import get_unit_tests_scans_path class TestSnykIssueApiParser(TestCase): @@ -361,3 +363,14 @@ def test_extract_convert_created_date_invalid(self): # Test None input result = parser.extract_convert_created_date(None) self.assertIsNone(result) + + def test_get_findings_multiple_cwes(self): + with (get_unit_tests_scans_path("snyk_issue_api") / "snyk_code_scan_api_many_vuln.json").open(encoding="utf-8") as testfile: + parser = SnykIssueApiParser() + findings = parser.get_findings(testfile, Test()) + by_id = {f.unique_id_from_tool: f for f in findings} + # This issue carries two CWE classes (CWE-259, CWE-798). + # primary cwe is the first entry; the full list is persisted via unsaved_cwes + finding = by_id["922e2d65-d2ce-4a5c-818c-ab196ba834c3"] + self.assertEqual(259, finding.cwe) + self.assertEqual([259, 798], finding.unsaved_cwes) diff --git a/unittests/tools/test_snyk_parser.py b/unittests/tools/test_snyk_parser.py index 1f210145b65..1028d8757db 100644 --- a/unittests/tools/test_snyk_parser.py +++ b/unittests/tools/test_snyk_parser.py @@ -163,6 +163,21 @@ def test_snykParser_target_file(self): self.assertEqual("Critical", finding.severity) self.assertIn("target_file:Mobile-Security-Framework-MobSF/requirements.txt", finding.unsaved_tags) + def test_snykParser_multiple_cwes(self): + with (get_unit_tests_scans_path("snyk") / "all_containers_target_output.json").open(encoding="utf-8") as testfile: + parser = SnykParser() + findings = parser.get_findings(testfile, Test()) + by_id = {f.vuln_id_from_tool: f for f in findings} + # SNYK-PYTHON-NUMPY-2321966 reports identifiers CWE ["CWE-119", "CWE-120"]. + # primary cwe is the first entry; the full list is persisted via unsaved_cwes + finding = by_id["SNYK-PYTHON-NUMPY-2321966"] + self.assertEqual(119, finding.cwe) + self.assertEqual([119, 120], finding.unsaved_cwes) + # SNYK-PYTHON-NUMPY-2321970 reports ["CWE-400", "CWE-1023"] + finding = by_id["SNYK-PYTHON-NUMPY-2321970"] + self.assertEqual(400, finding.cwe) + self.assertEqual([400, 1023], finding.unsaved_cwes) + def test_snykParser_update_libs_tag(self): with (get_unit_tests_scans_path("snyk") / "single_project_upgrade_libs.json").open(encoding="utf-8") as testfile: parser = SnykParser() diff --git a/unittests/tools/test_sonarqube_parser.py b/unittests/tools/test_sonarqube_parser.py index abe7fda0120..389da05ebe9 100644 --- a/unittests/tools/test_sonarqube_parser.py +++ b/unittests/tools/test_sonarqube_parser.py @@ -600,6 +600,19 @@ def test_parse_json_file_from_api_with_multiple_findings_json(self): self.assertEqual("2023-10-16", item.date) my_file_handle.close() + def test_parse_json_file_from_api_with_multi_cwe_fabricated_copy(self): + # Fabricated copy: the first finding's message carries multiple CWE + # categories (CWE-120 + CWE-79); the primary CWE is the first (120). + my_file_handle, _product, _engagement, test = self.init( + get_unit_tests_scans_path("sonarqube") / "findings_over_api_multi_cwe_fabricated.json", + ) + parser = SonarQubeParser() + findings = parser.get_findings(my_file_handle, test) + item = findings[0] + self.assertEqual("120", item.cwe) + self.assertEqual(["120", "79"], item.unsaved_cwes) + my_file_handle.close() + def test_parse_json_file_from_api_with_multiple_findings_hotspots_json(self): my_file_handle, _product, _engagement, test = self.init( get_unit_tests_scans_path("sonarqube") / "findings_over_api_hotspots.json", diff --git a/unittests/tools/test_trivy_parser.py b/unittests/tools/test_trivy_parser.py index 9aef80e263e..0e45df2938a 100644 --- a/unittests/tools/test_trivy_parser.py +++ b/unittests/tools/test_trivy_parser.py @@ -306,6 +306,26 @@ def test_all_statuses(self): self.assertEqual(False, finding.out_of_scope) self.assertEqual(False, finding.is_mitigated) + with self.subTest("multiple_cwes"): + # CVE-2018-1000876 reports CweIDs ["CWE-190", "CWE-787"]. + # primary cwe is the first entry; the full list is persisted via unsaved_cwes + finding = findings[3] + self.assertEqual("CVE-2018-1000876 binutils 2.31.1-16", finding.title) + self.assertEqual(190, finding.cwe) + self.assertEqual([190, 787], finding.unsaved_cwes) + + with self.subTest("single_cwe"): + # CVE-2018-12697 reports a single CweID; unsaved_cwes mirrors it + finding = findings[4] + self.assertEqual(476, finding.cwe) + self.assertEqual([476], finding.unsaved_cwes) + + with self.subTest("no_cwe"): + # findings without CweIDs leave unsaved_cwes unset + finding = findings[2] + self.assertEqual(0, finding.cwe) + self.assertIsNone(finding.unsaved_cwes) + def test_cvss_severity_sources(self): """Testing with two findings - one where SeveritySource matches the CVSS entry, and one that does not""" with sample_path("cvss_severity_source.json").open(encoding="utf-8") as test_file: diff --git a/unittests/tools/test_veracode_parser.py b/unittests/tools/test_veracode_parser.py index 6ec91dae6f5..c45534c3175 100644 --- a/unittests/tools/test_veracode_parser.py +++ b/unittests/tools/test_veracode_parser.py @@ -139,6 +139,17 @@ def parse_file_with_multiple_finding2(self): self.assertEqual(4.3, finding.cvssv3_score) return findings[0] + def test_parse_file_with_multi_cwe_fabricated_copy(self): + # Fabricated copy: the CVE-2012-6153 SCA node carries multiple CWEs + # (cwe_id="CWE-20 CWE-295"); the primary CWE is the first (20). + with (get_unit_tests_scans_path("veracode") / "veracode_scan_multi_cwe_fabricated.xml").open(encoding="utf-8") as testfile: + parser = VeracodeParser() + findings = parser.get_findings(testfile, Test()) + finding = findings[0] + self.assertEqual("CVE-2012-6153", finding.unsaved_vulnerability_ids[0]) + self.assertEqual(20, finding.cwe) + self.assertEqual([20, 295], finding.unsaved_cwes) + @override_settings(USE_FIRST_SEEN=True) def test_parse_file_with_mitigated_finding_first_seen(self): self.parse_file_with_mitigated_finding() diff --git a/unittests/tools/test_wapiti_parser.py b/unittests/tools/test_wapiti_parser.py index fe040d17eec..9296adc25ff 100644 --- a/unittests/tools/test_wapiti_parser.py +++ b/unittests/tools/test_wapiti_parser.py @@ -57,3 +57,14 @@ def test_parse_cwe(self): self.assertEqual("Cross Site Request Forgery: CSP is not set", finding.title) self.assertEqual("Low", finding.severity) self.assertEqual(352, finding.cwe) + + def test_parse_cwe_multi_cwe_fabricated_copy(self): + """Fabricated copy: a single Wapiti vulnerability references multiple CWEs (first is primary)""" + with (get_unit_tests_scans_path("wapiti") / "cwe_multi_cwe_fabricated.xml").open(encoding="utf-8") as testfile: + parser = WapitiParser() + findings = parser.get_findings(testfile, Test()) + self.validate_locations(findings) + weak_creds = findings[0] + self.assertEqual("Weak credentials: Weak credentials detected", weak_creds.title) + self.assertEqual(798, weak_creds.cwe) + self.assertEqual([798, 521], weak_creds.unsaved_cwes) diff --git a/unittests/tools/test_xygeni_parser.py b/unittests/tools/test_xygeni_parser.py index 6418f3b0bc7..339e37dbfe7 100644 --- a/unittests/tools/test_xygeni_parser.py +++ b/unittests/tools/test_xygeni_parser.py @@ -85,6 +85,30 @@ def test_sast_same_code_repeated_in_same_file_shares_unique_id(self): "the detector is the shared, non-unique grouping id", ) + def test_sast_multi_cwe_populates_unsaved_cwes(self): + # A SAST finding carrying several CWEs keeps the first as Finding.cwe (the primary) + # and exposes the full list via Finding.unsaved_cwes, which the import pipeline + # persists into the Finding_CWE relation. + with self._load("sast_multi_cwe_fabricated.json") as testfile: + findings = XygeniParser().get_findings(testfile, Test()) + + multi = next( + (f for f in findings if f.unique_id_from_tool == "MULTI-CWE-HASH-1"), + None, + ) + self.assertIsNotNone(multi, "expected the multi-CWE SAST finding by uniqueHash") + self.assertEqual(94, multi.cwe, "primary cwe stays the first/numeric cwe") + self.assertEqual([94, 95], multi.unsaved_cwes, "full CWE list is surfaced") + + # A single-CWE finding still gets a one-element unsaved_cwes list. + single = next( + (f for f in findings if f.unique_id_from_tool == "SINGLE-CWE-HASH-1"), + None, + ) + self.assertIsNotNone(single, "expected the single-CWE SAST finding by uniqueHash") + self.assertEqual(352, single.cwe) + self.assertEqual([352], single.unsaved_cwes) + def test_sca_many_findings(self): with self._load("sca_many_findings.json") as testfile: findings = XygeniParser().get_findings(testfile, Test()) @@ -116,6 +140,25 @@ def test_sca_many_findings(self): # overallCvssScore = -1.0 in this fixture → must be dropped, not surfaced self.assertIsNone(match.cvssv3_score) + def test_sca_multi_cwe_populates_unsaved_cwes(self): + # The dot-prop@4.2.0 / CVE-2020-8116 advisory carries two CWEs. The first stays the + # primary Finding.cwe; the full list is surfaced via Finding.unsaved_cwes. + with self._load("sca_many_findings.json") as testfile: + findings = XygeniParser().get_findings(testfile, Test()) + + match = next( + ( + f for f in findings + if f.component_name == "dot-prop" + and f.component_version == "4.2.0" + and f.vuln_id_from_tool == "CVE-2020-8116" + ), + None, + ) + self.assertIsNotNone(match, "expected the dot-prop@4.2.0 / CVE-2020-8116 SCA finding") + self.assertEqual(1321, match.cwe, "primary cwe is the first CWE in the list") + self.assertEqual([1321, 471], match.unsaved_cwes) + def test_secrets_many_findings(self): with self._load("secrets_many_findings.json") as testfile: findings = XygeniParser().get_findings(testfile, Test()) diff --git a/unittests/tools/test_yarn_audit_parser.py b/unittests/tools/test_yarn_audit_parser.py index e4f06a7155d..b14466ed918 100644 --- a/unittests/tools/test_yarn_audit_parser.py +++ b/unittests/tools/test_yarn_audit_parser.py @@ -58,6 +58,7 @@ def test_yarn_audit_parser_with_multiple_cwes_per_finding_list(self): findings = parser.get_findings(testfile, self.get_test()) self.assertEqual(2, len(findings)) self.assertEqual(findings[0].cwe, 918) + self.assertEqual(findings[0].unsaved_cwes, [918, 1333]) self.assertEqual(findings[1].cwe, 1035) self.assertEqual(findings[1].cve, None) self.assertEqual(findings[1].unsaved_vulnerability_ids[0], "CVE-2021-3807") @@ -82,6 +83,16 @@ def test_yarn_audit_parser_issue_6495(self): self.assertEqual(findings[1].unsaved_vulnerability_ids[0], "CVE-2022-25851") self.assertEqual(findings[1].cve, None) + def test_yarn_audit_parser_multi_cwe_fabricated_copy(self): + # Fabricated copy: the first advisory's cwe list carries multiple CWEs + # (["CWE-1321", "CWE-915"]); the primary CWE is the first (1321). + with (get_unit_tests_scans_path("yarn_audit") / "issue_6495_multi_cwe_fabricated.json").open(encoding="utf-8") as testfile: + parser = YarnAuditParser() + findings = parser.get_findings(testfile, self.get_test()) + testfile.close() + self.assertEqual(findings[0].cwe, "1321") + self.assertEqual(findings[0].unsaved_cwes, ["CWE-1321", "CWE-915"]) + def test_yarn_audit_parser_yarn2_audit_issue9911(self): with (get_unit_tests_scans_path("yarn_audit") / "yarn2_audit_issue9911.json").open(encoding="utf-8") as testfile: parser = YarnAuditParser()