Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions docs/content/releases/os_upgrading/3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: 'Upgrading to DefectDojo Version 3.0.x'
toc_hide: true
weight: -20260615
description: Locations and Asset/Organization labels are now enabled by default; Authorized Users panel replaces Members/Groups under legacy authorization; SSO providers move to DefectDojo Pro; removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings
description: Locations and Asset/Organization labels are now enabled by default; Authorized Users panel replaces Members/Groups under legacy authorization; SSO providers move to DefectDojo Pro; removal of Questionnaire API Endpoints, Credential Manager, and Stub Findings; Dependency Check parser no longer emits separate findings for related dependencies; related file paths are now listed in the main finding's description.
---

## Locations enabled by default
Expand Down Expand Up @@ -146,5 +146,28 @@ Any requests to this endpoint will now return a 404 Not Found error. The Stub Fi

In [PR 14881](https://github.com/DefectDojo/django-DefectDojo/pull/14881)We optimized the way the Django Watson search index is updated during imports and reimports. There is not a single configuration setting to manage the threshold: `DD_WATSON_ASYNC_INDEX_UPDATE_BATCH_SIZE`. The default value should work fine for most instances.

## Dependency Check parser: related dependencies folded into the main finding

For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.0.0).
The Dependency Check parser previously created one finding per `<relatedDependency>` in the report, in addition to the finding for the main vulnerable dependency. Because OWASP Dependency-Check attaches the vulnerability only to the main dependency in the XML and the related entries are metadata pointing to other files in the same logical component, this produced N findings sharing the same title, CVE, component name and component version — only the file path differed. For projects with Spring Boot, ActiveMQ, or other libraries whose CPE matches many sibling artifacts this produced significant noise.

Starting in 2.59.1, the parser emits exactly one finding per vulnerability per main dependency. The file paths of any related dependencies are surfaced in the finding description under a `**Related Filepaths:**` block.

### Background: what `<relatedDependencies>` actually contains

OWASP Dependency-Check's `DependencyBundlingAnalyzer` merges co-grouped artifacts into a single main dependency and lists the others under `<relatedDependencies>`. It does this under five scenarios:

1. **Identical content (`hashesMatch`)** — the same jar (matching sha1) found at multiple paths, e.g. the same library packaged into multiple ear/war archives.
2. **Shaded jar (`isShadedJar`)** — a `.jar` and a `pom.xml` extracted from inside it share the same CPE; the pom.xml is recorded as related.
3. **WebJar (`isWebJar`)** — a `.js` file extracted from a WebJar matches the jar's CPE (mapped via `pkg:maven/org.webjars/<name>@<version>`); the js file is recorded as related to the jar.
4. **Same CPE + base path + vulnerabilities + filename match** — sibling artifacts in the same project that share a CPE. Example: `spring-boot`, `spring-boot-actuator`, `spring-boot-starter-jdbc`, etc. all map to the `spring_boot` CPE and are grouped under the main `spring-boot` jar.
5. **NPM same name + version** — the same npm package discovered via different resolution paths (for example `package-lock.json` plus `node_modules`).

Only scenario 1 represents the same vulnerable artifact at multiple deploy locations. Scenarios 2-5 are different files representing one logical component. Both cases were previously inflated into separate findings; both now collapse to one finding with the related paths listed in the description.

### Required actions

- **Users filtering or grouping by the `related` tag**: that tag is no longer applied because related findings are no longer created. Update any saved filters, dashboards, or rules that depend on it. Equivalent information is now available in the finding description (look for `**Related Filepaths:**`).
- **Reimport behavior**: on the next reimport of an existing Dependency Check report, the previously-created `related` findings will be closed as no longer present in the report. This is expected and matches the new parsing behavior.


For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.0.0).
16 changes: 15 additions & 1 deletion docs/content/supported_tools/parsers/file/dependency_check.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,21 @@ OWASP Dependency Check output can be imported in Xml format. This parser ingests
* Suppressed vulnerabilities are tagged with the tag: `suppressed`.
* Suppressed vulnerabilities are marked as mitigated.
* If the suppression is missing any `<notes>` tag, it tags them as `no_suppression_document`.
* Related vulnerable dependencies are tagged with `related` tag.

### Related dependencies

OWASP Dependency-Check's `DependencyBundlingAnalyzer` merges co-grouped artifacts into a single main dependency and lists the others under `<relatedDependencies>` in the report. The vulnerability is attached only to the main dependency; the related entries are metadata pointing to other files in the same logical component.

The parser emits one finding per vulnerability per main dependency and surfaces the related file paths in the finding's description under a `**Related Filepaths:**` block (rather than creating a separate finding per related entry, which produced N findings sharing the same title and CVE differing only in file path).

DC bundles dependencies under five scenarios:

1. **Identical content (`hashesMatch`)** — the same jar (matching sha1) found at multiple paths (e.g. packaged into multiple ear/war archives).
2. **Shaded jar (`isShadedJar`)** — a `.jar` and a `pom.xml` extracted from inside it share the same CPE.
3. **WebJar (`isWebJar`)** — a `.js` file extracted from a WebJar maps to the jar's CPE via `pkg:maven/org.webjars/<name>@<version>`.
4. **Same CPE + base path + vulnerabilities + filename match** — sibling artifacts sharing a CPE (e.g. `spring-boot`, `spring-boot-actuator`, `spring-boot-starter-jdbc` all map to the `spring_boot` CPE).
5. **NPM same name + version** — the same npm package discovered via different resolution paths (e.g. `package-lock.json` + `node_modules`).


### Sample Scan Data
Sample Dependency Check scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/dependency_check).
Expand Down
14 changes: 14 additions & 0 deletions dojo/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@
TextQuestion,
)


# Django's default admin gate is `is_active and is_staff`. Under the legacy
# OS auth model is_staff is already a near-superuser bypass for queryset
# filters and most permission checks, so the standard UserAdmin change form
# would let any is_staff user with auth.change_user tick is_superuser on
# themselves. Rather than narrowing each ModelAdmin individually, restrict
# the entire admin site to superusers — there is no DefectDojo OS role that
# legitimately needs /admin/ access without being a superuser.
def _admin_site_has_permission(request):
return request.user.is_active and request.user.is_superuser


admin.site.has_permission = _admin_site_has_permission

# Conditionally unregister LogEntry from auditlog if it's registered
try:
from auditlog.models import LogEntry
Expand Down
6 changes: 6 additions & 0 deletions dojo/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,12 @@ def validate(self, data):
msg = "Only superusers are allowed to add or edit superusers."
raise ValidationError(msg)

instance_is_staff = self.instance.is_staff if self.instance is not None else False
data_is_staff = data.get("is_staff", instance_is_staff)
if not self.context["request"].user.is_superuser and data_is_staff != instance_is_staff:
msg = "Only superusers are allowed to add or edit staff users."
raise ValidationError(msg)

if self.context["request"].method in {"PATCH", "PUT"} and "password" in data:
msg = "Update of password though API is not allowed"
raise ValidationError(msg)
Expand Down
1 change: 1 addition & 0 deletions dojo/finding/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ def reconfigure_duplicate_cluster(original, cluster_outside):
duplicate=False,
duplicate_finding=None,
active=original.active,
verified=original.verified,
is_mitigated=original.is_mitigated,
)
new_original.found_by.set(original.found_by.all())
Expand Down
5 changes: 1 addition & 4 deletions dojo/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2176,16 +2176,13 @@ class ReviewFindingForm(forms.Form):

def __init__(self, *args, **kwargs):
finding = kwargs.pop("finding", None)
user = kwargs.pop("user", None)
kwargs.pop("user", None)
super().__init__(*args, **kwargs)
# Get the list of users
if finding is not None:
users = get_authorized_users_for_product_and_product_type(None, finding.test.engagement.product, "edit")
else:
users = get_authorized_users("edit").filter(is_active=True)
# Remove the current user
if user is not None:
users = users.exclude(id=user.id)
# Save a copy of the original query to be used in the validator
self.reviewer_queryset = users
# Set the users in the form
Expand Down
19 changes: 15 additions & 4 deletions dojo/importers/default_reimporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,9 +801,16 @@ def process_matched_mitigated_finding(
existing_finding.mitigated = None
existing_finding.is_mitigated = False
existing_finding.mitigated_by = None
existing_finding.active = True
if self.verified is not None:
existing_finding.verified = self.verified
# A duplicate finding must stay inactive/unverified (see set_duplicate and the
# "Duplicate findings cannot be verified or active" form validation). Un-mitigate it
# but do not reactivate it, otherwise we create an invalid active/verified duplicate state.
if existing_finding.duplicate:
existing_finding.active = False
existing_finding.verified = False
else:
existing_finding.active = True
if self.verified is not None:
existing_finding.verified = self.verified

component_name = getattr(unsaved_finding, "component_name", None)
component_version = getattr(unsaved_finding, "component_version", None)
Expand All @@ -819,7 +826,11 @@ def process_matched_mitigated_finding(
# don't dedupe before endpoints/locations are added, postprocessing will be done on next save (in calling method)
existing_finding.save_no_options()

note = Notes(entry=f"Re-activated by {self.scan_type} re-upload.", author=self.user)
if existing_finding.duplicate:
note_entry = f"Un-mitigated by {self.scan_type} re-upload but kept inactive because the finding is a duplicate."
else:
note_entry = f"Re-activated by {self.scan_type} re-upload."
note = Notes(entry=note_entry, author=self.user)
note.save()
self.location_handler.record_reactivations_for_finding(existing_finding)
existing_finding.notes.add(note)
Expand Down
2 changes: 1 addition & 1 deletion dojo/settings/settings.dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
DD_DJANGO_METRICS_ENABLED=(bool, False),
DD_LOGIN_REDIRECT_URL=(str, "/"),
DD_LOGIN_URL=(str, "/login"),
DD_DJANGO_ADMIN_ENABLED=(bool, True),
DD_DJANGO_ADMIN_ENABLED=(bool, False),
DD_SESSION_COOKIE_HTTPONLY=(bool, True),
DD_CSRF_COOKIE_HTTPONLY=(bool, True),
DD_SECURE_SSL_REDIRECT=(bool, False),
Expand Down
5 changes: 3 additions & 2 deletions dojo/settings/template-env
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Django Debug, don't enable on production!
DD_DEBUG=False

# Enables Django Admin
DD_DJANGO_ADMIN_ENABLED=True
# Enables the Django admin interface at /admin/. Off by default; uncomment
# this line to expose it. Access is restricted to superusers regardless.
# DD_DJANGO_ADMIN_ENABLED=True

# A secret key for a particular Django installation.
DD_SECRET_KEY=#DD_SECRET_KEY#
Expand Down
2 changes: 2 additions & 0 deletions dojo/templates/dojo/metrics.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,11 @@ <h3 {% if not critical_prods %}class="has-filters" {% endif %}>
</h3>
</div>
</div>
{% if form %}
<div id="the-filters" class="is-filters panel-body collapse">
{% include "dojo/filter_snippet.html" with form=form clear_link="/metrics/product/type" %}
</div>
{% endif %}
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions dojo/templates_classic/dojo/metrics.html
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,11 @@ <h3 {% if not critical_prods %}class="has-filters" {% endif %}>
</h3>
</div>
</div>
{% if form %}
<div id="the-filters" class="is-filters panel-body collapse">
{% include "dojo/filter_snippet.html" with form=form clear_link="/metrics/product/type" %}
</div>
{% endif %}
</div>
</div>
</div>
Expand Down
75 changes: 56 additions & 19 deletions dojo/tools/dependency_check/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,57 @@ def add_finding(self, finding, dupes):
if key not in dupes:
dupes[key] = finding

def build_related_dependencies_block(self, dependency, namespace):
"""
Return a markdown block listing related dependencies, or ''.

Dependency-Check's DependencyBundlingAnalyzer merges co-grouped artifacts
into one main dependency and lists the others under <relatedDependencies>.
The vulnerability is attached only to the main dependency in the XML; the
related entries are metadata pointing to other files in the same logical
component. Previously we emitted one finding per related entry, which
multiplied a single CVE into N findings sharing the same title and CVE
with only the file_path differing — pure noise for the user. Instead we
keep one finding for the main dependency and surface the related file
paths in its description.

DC bundles dependencies under five scenarios (see
DependencyBundlingAnalyzer.evaluateDependencies):

1. hashesMatch — identical content (same sha1) found at multiple paths,
e.g. the same jar packaged into multiple ear/war archives.
2. isShadedJar — a .jar and a pom.xml extracted from inside it share the
same CPE; the pom.xml is recorded as related to the jar.
3. isWebJar — a .js file extracted from a WebJar matches the jar's CPE
(mapped via pkg:maven/org.webjars/<name>@<version>); the js is
related to the jar.
4. CPE + base path + vulnerabilities + filename match — sibling artifacts
in the same project that share a CPE (e.g. spring-boot,
spring-boot-actuator, spring-boot-starter all map to the
spring_boot CPE).
5. NPM same name + version — the same npm package discovered via
different resolution paths (e.g. package-lock.json + node_modules).

Scenario 1 is the only case where the related entries are genuinely
separate vulnerable locations; scenarios 2-5 are different files
representing one logical component. Listing all related paths in the
description preserves the per-location information for scenario 1 while
removing the noise from scenarios 2-5.
"""
related = dependency.find(namespace + "relatedDependencies")
if related is None:
return ""
entries = []
for rd in related.findall(namespace + "relatedDependency"):
file_name = rd.findtext(f"{namespace}fileName")
if not file_name:
continue
file_path = (rd.findtext(f"{namespace}filePath") or "").strip()
entries.append(f"- {file_name} ({file_path})" if file_path else f"- {file_name}")
if not entries:
return ""
return "\n**Related Filepaths:**\n" + "\n".join(entries)

def get_filename_and_path_from_dependency(
self,
dependency,
Expand Down Expand Up @@ -448,6 +499,7 @@ def get_findings(self, filename, test):
namespace + "vulnerabilities",
)
if vulnerabilities is not None:
related_block = self.build_related_dependencies_block(dependency, namespace)
for vulnerability in vulnerabilities.findall(
namespace + "vulnerability",
):
Expand All @@ -459,29 +511,12 @@ def get_findings(self, filename, test):
test,
namespace,
)
if related_block:
finding.description += related_block
if scan_date:
finding.date = scan_date
self.add_finding(finding, dupes)

relatedDependencies = dependency.find(
namespace + "relatedDependencies",
)
if relatedDependencies is not None:
for relatedDependency in relatedDependencies.findall(
namespace + "relatedDependency",
):
finding = self.get_finding_from_vulnerability(
dependency,
relatedDependency,
vulnerability,
test,
namespace,
)
if finding: # could be None
if scan_date:
finding.date = scan_date
self.add_finding(finding, dupes)

for suppressedVulnerability in vulnerabilities.findall(
namespace + "suppressedVulnerability",
):
Expand All @@ -493,6 +528,8 @@ def get_findings(self, filename, test):
test,
namespace,
)
if related_block:
finding.description += related_block
if scan_date:
finding.date = scan_date
self.add_finding(finding, dupes)
Expand Down
Loading
Loading