Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ The exact metadata available will depend on the parser/scanner that revealed the

This metadata improves filtering, reporting, and prioritization across your security program, enabling long-term tracking and trend analysis. Additional details and metadata descriptions can be found [here](/triage_findings/findings_workflows/intro_to_findings/#a-finding-page).

### Host Operating System

Some infrastructure scanners report the operating system of each scanned host: Nmap (OS detection, `-O`), Nexpose / Rapid7 InsightVM, Qualys Scan, and Qualys Infrastructure Scan (WebGUI XML). When Locations are enabled, DefectDojo stores the reported operating system alongside each affected host of a Finding, as it appeared in that scan.

- **Filtering**: use the **Host OS** filter on a Test's Findings list (or any Findings list) to show only Findings affecting hosts whose operating system contains the entered text, e.g. `windows` or `ubuntu 22`. The match is case-insensitive. In the API, use the `location_os` parameter on `/api/v2/findings/`, or the `host_os_*` parameters on the Location Finding reference endpoint to list the matching hosts themselves.
- **Matching hosts**: in the hosts popover of a Finding, each host shows its reported operating system, and hosts matching the active Host OS filter are shown in bold.
- **Reimport**: reimporting a scan updates the stored operating system when the scanner reports a different one. If a report contains no operating system for a host, the previously stored value is kept.
- Findings from reports without operating system information are unaffected; they simply don't match a Host OS filter.

### Deduplication

DefectDojo includes deduplication capabilities that help identify and manage Findings representing the same underlying vulnerability. As scan results are imported from one or more tools, DefectDojo uses configurable matching logic to identify Findings that represent the same vulnerability.
Expand Down
17 changes: 17 additions & 0 deletions dojo/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,23 @@ def filter_endpoints_base(queryset, name, value, statuses=None, host=None):
return queryset.filter(**filters_kwargs)


def filter_location_os_base(queryset, name, value, statuses=None, host=None, endpoint_id=None):
"""
Apply `location_os` filter (the host OS a scanner reported for a finding's location), and if location_status,
host or endpoints are present, combine them on the same row. Uses Exists() so a finding with several matching
hosts is returned once.
"""
filters_kwargs = {"locations__relationship_data__os__icontains": value}
if endpoint_id:
filters_kwargs["locations__location"] = endpoint_id
if statuses:
filters_kwargs["locations__status__in"] = statuses
if host:
filters_kwargs["locations__location__url__host__icontains"] = host

return queryset.filter(Exists(queryset.model.objects.filter(pk=OuterRef("pk"), **filters_kwargs)))


def filter_endpoints_host_base(queryset, name, value, statuses=None, endpoint_id=None):
"""
Apply `endpoints__host` filter, and if endpoints or location_status
Expand Down
7 changes: 7 additions & 0 deletions dojo/finding/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ class ApiFindingFilter(DojoFilter):
risk_acceptance = extend_schema_field(OpenApiTypes.NUMBER)(ExistsRiskAcceptanceFilter())

tag = CharFilter(field_name="tags__name", lookup_expr="icontains", help_text="Tag name contains")
location_os = CharFilter(
method="filter_location_os",
help_text="Operating system the scanner reported for an affected host (contains, case-insensitive)",
)
tags = CharFieldInFilter(
field_name="tags__name",
lookup_expr="in",
Expand Down Expand Up @@ -234,6 +238,9 @@ def _has(**lookup):
def filter_endpoints(self, queryset, _name, value):
return queryset.filter(self._has(endpoints__id__in=value)) if value else queryset

def filter_location_os(self, queryset, _name, value):
return queryset.filter(self._has(locations__relationship_data__os__icontains=value)) if value else queryset

def filter_found_by(self, queryset, _name, value):
return queryset.filter(self._has(found_by__in=value)) if value else queryset

Expand Down
15 changes: 15 additions & 0 deletions dojo/finding/ui/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
cwe_options,
filter_endpoints_base,
filter_endpoints_host_base,
filter_location_os_base,
get_finding_filterset_fields,
vulnerability_id_filter,
)
Expand Down Expand Up @@ -121,6 +122,20 @@ class FindingFilterHelper(FilterSet):
field_name="locations__location__url__host", method="filter_endpoints_host", label="Endpoint Host",
)
endpoints = NumberFilter(field_name="locations__location", method="filter_endpoints", widget=HiddenInput())
location_os = CharFilter(
method="filter_location_os", label="Host OS",
help_text="Operating system the scanner reported for an affected host (contains, case-insensitive)",
)

def filter_location_os(self, queryset, name, value):
return filter_location_os_base(
queryset,
name,
value,
endpoint_id=self.data.get("endpoints"),
statuses=self.data.getlist("location_status"),
host=self.data.get("endpoints__host"),
)

def filter_endpoints_host(self, queryset, name, value):
return filter_endpoints_host_base(
Expand Down
51 changes: 40 additions & 11 deletions dojo/importers/location_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

if TYPE_CHECKING:
from dojo.models import Dojo_User, Finding, Product
from dojo.tools.locations import LocationAssociationData

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -209,35 +210,42 @@ def _persist_locations(self) -> None:
))
existing_product_refs.add(location.location_id)

# Determine necessary finding refs to create
# Determine necessary finding refs to create (and existing ones whose association data changed)
refs_to_update: dict[int, LocationFindingReference] = {}
if self._locations_by_finding:
all_finding_ids = [finding.id for finding, _ in self._locations_by_finding]
# Strictly speaking this returns more rows than we need (it's the cross of the location/finding lists rather
# than scoped per-finding), but more straightforward than constructing a per-finding lookup. We won't create
# any unwanted associations below anyway.
existing_finding_ref_keys: set[tuple[int, int]] = set(
LocationFindingReference.objects.filter(
existing_finding_refs: dict[tuple[int, int], LocationFindingReference] = {
(ref.finding_id, ref.location_id): ref
for ref in LocationFindingReference.objects.filter(
location_id__in=all_location_ids,
finding_id__in=all_finding_ids,
).values_list("finding_id", "location_id"),
)
).only("id", "finding_id", "location_id", "relationship", "relationship_data")
}

for finding, cleaned_locations in self._locations_by_finding:
# Locations were already cleaned at record time — identity_hash is set, so we can
# look up the persisted location directly.
for location in cleaned_locations:
saved_loc = saved_by_key[type(location), location.identity_hash]
finding_ref_key = (finding.id, saved_loc.location_id)
if finding_ref_key not in existing_finding_ref_keys:
assoc = saved_loc.get_association_data()
all_finding_refs.append(LocationFindingReference(
# Association data is per finding, so read it from this finding's own location object rather
# than the deduplicated saved one (which only carries the data of one of its duplicates).
assoc = location.get_association_data()
if (existing_ref := existing_finding_refs.get(finding_ref_key)) is None:
new_ref = LocationFindingReference(
location_id=saved_loc.location_id,
finding=finding,
status=FindingLocationStatus.Active,
relationship=assoc.relationship_type,
relationship_data=assoc.relationship_data,
))
existing_finding_ref_keys.add(finding_ref_key)
relationship_data=dict(assoc.relationship_data),
)
all_finding_refs.append(new_ref)
existing_finding_refs[finding_ref_key] = new_ref
elif assoc and self._merge_association_data(existing_ref, assoc) and existing_ref.pk:
refs_to_update[existing_ref.pk] = existing_ref

# Bulk create references
all_finding_refs = self._drop_finding_refs_for_missing_findings(all_finding_refs)
Expand All @@ -249,6 +257,10 @@ def _persist_locations(self) -> None:
LocationProductReference.objects.bulk_create(
all_product_refs, batch_size=1000, ignore_conflicts=True,
)
if refs_to_update:
LocationFindingReference.objects.bulk_update(
refs_to_update.values(), ["relationship", "relationship_data"], batch_size=1000,
)

# Trigger bulk tag inheritance only when the Location's product
# membership actually changed. New product refs are the only thing
Expand Down Expand Up @@ -319,6 +331,23 @@ def _clear_location_accumulators(self) -> None:
self._location_slot_by_finding.clear()
self._product_locations.clear()

@staticmethod
def _merge_association_data(ref: LocationFindingReference, assoc: LocationAssociationData) -> bool:
"""
Merge parser-supplied association data into an existing finding ref. Keys the parser supplies overwrite stored
values; keys it omits are kept, so a report lacking e.g. host OS doesn't erase a previously reported one.
Returns True if the ref changed.
"""
changed = False
if assoc.relationship_type and ref.relationship != assoc.relationship_type:
ref.relationship = assoc.relationship_type
changed = True
merged = {**(ref.relationship_data or {}), **assoc.relationship_data}
if merged != ref.relationship_data:
ref.relationship_data = merged
changed = True
return changed

def _persist_status_updates(self) -> None:
"""
Bulk persist finding/product ref statuses.
Expand Down
2 changes: 2 additions & 0 deletions dojo/location/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ class LocationFindingReferenceFilter(CommonFilters):
"location_type", "Location Type", locals(), model_field_name="location__location_type")
CommonFilters.create_char_filters(
"location_value", "Location Value", locals(), model_field_name="location__location_value")
CommonFilters.create_char_filters(
"host_os", "Host OS reported by the scanner", locals(), model_field_name="relationship_data__os")
CommonFilters.create_ordering_filters(
locals(),
(
Expand Down
5 changes: 4 additions & 1 deletion dojo/location/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,10 @@ def from_location_data(cls, location_data: LocationData) -> Self:
if location_data.type != cls.get_location_type():
error_message = f"Cannot create instance of {cls} from LocationData of type {location_data.type}"
raise ValueError(error_message)
return cls._from_location_data_impl(location_data)
location = cls._from_location_data_impl(location_data)
if location_data.association:
location._association_data = location_data.association
return location

@classmethod
def _from_location_data_impl(cls, location_data: LocationData) -> Self:
Expand Down
11 changes: 5 additions & 6 deletions dojo/templates/dojo/findings_list_snippet.html
Original file line number Diff line number Diff line change
Expand Up @@ -570,12 +570,11 @@ <h3 class="has-filters">
{% if finding.locations.exists %}
<i class="fa-solid fa-sitemap has-popover dojo-sup" data-html="true" data-content="
{% for ref in finding.locations.all %}
{% if ref.status == "Mitigated" %} &#10003; {{ ref.location }}
<br/>
{% else %}
&#10005; {{ ref.location }}
<br/>
{% endif %}
{% if ref.status == "Mitigated" %} &#10003; {% else %} &#10005; {% endif %}
{% if ref|host_os_matches:request.GET.location_os %}<b>{{ ref.location }}</b>{% else %}{{ ref.location }}{% endif %}
{% comment %} Double-escaped: the popover renders data-content as HTML {% endcomment %}
{% if ref.relationship_data.os %} ({{ ref.relationship_data.os|force_escape|force_escape }}){% endif %}
<br/>
{% endfor %}
"></i>
{% endif %}
Expand Down
11 changes: 5 additions & 6 deletions dojo/templates/dojo/view_test.html
Original file line number Diff line number Diff line change
Expand Up @@ -1175,12 +1175,11 @@ <h4 class="has-filters">
{% if finding.locations.exists %}
<i class="fa-solid fa-sitemap has-popover dojo-sup" data-html="true" data-content="
{% for ref in finding.locations.all %}
{% if ref.status == "Mitigated" %} &#10003; {{ ref.location }}
<br/>
{% else %}
&#10005; {{ ref.location }}
<br/>
{% endif %}
{% if ref.status == "Mitigated" %} &#10003; {% else %} &#10005; {% endif %}
{% if ref|host_os_matches:request.GET.location_os %}<b>{{ ref.location }}</b>{% else %}{{ ref.location }}{% endif %}
{% comment %} Double-escaped: the popover renders data-content as HTML {% endcomment %}
{% if ref.relationship_data.os %} ({{ ref.relationship_data.os|force_escape|force_escape }}){% endif %}
<br/>
{% endfor %}
"></i>
{% endif %}
Expand Down
7 changes: 7 additions & 0 deletions dojo/templatetags/display_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@
]


@register.filter
def host_os_matches(location_ref, query):
"""True if the host OS reported for a LocationFindingReference contains `query` (case-insensitive)."""
os_name = (location_ref.relationship_data or {}).get("os") or ""
return bool(query and os_name and query.strip().lower() in os_name.lower())


@register.filter
def markdown_render(value):
if value:
Expand Down
2 changes: 1 addition & 1 deletion dojo/templatetags/filter_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"title", "component_name", "component_version",
"vulnerability_id",
"file_path", "unique_id_from_tool", "vuln_id_from_tool",
"endpoints__host",
"endpoints__host", "location_os",
]),
("Severity & Risk", [
"severity", "cwe", "epss_score", "epss_score_range",
Expand Down
9 changes: 9 additions & 0 deletions dojo/tools/locations.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
class LocationData:
type: str
data: dict[str, Any] | None = None
association: LocationAssociationData | None = None

def __post_init__(self) -> None:
if not self.data:
Expand All @@ -26,6 +27,7 @@ def url(
query: str = "",
fragment: str = "",
user_info: str = "",
association: LocationAssociationData | None = None,
) -> LocationData:
return cls(
type="url",
Expand All @@ -39,6 +41,7 @@ def url(
"fragment": fragment,
"user_info": user_info,
},
association=association,
)

@classmethod
Expand Down Expand Up @@ -179,3 +182,9 @@ class LocationAssociationData:

def __bool__(self) -> bool:
return bool(self.relationship_type) or bool(self.relationship_data)

@classmethod
def host_os(cls, os_name: str | None) -> LocationAssociationData:
"""Association recording the host operating system a scanner reported; empty (falsey) when unknown."""
os_name = (os_name or "").strip()
return cls(relationship_data={"os": os_name}) if os_name else cls()
28 changes: 25 additions & 3 deletions dojo/tools/nexpose/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from dojo.location.feature import locations_enabled
from dojo.models import Endpoint, Finding
from dojo.tools.locations import LocationData
from dojo.tools.locations import LocationAssociationData, LocationData


class NexposeParser:
Expand Down Expand Up @@ -207,14 +207,35 @@ def get_vuln_definitions(self, tree):
vulns[vid] = vuln
return vulns

@staticmethod
def parse_os_fingerprint(node):
"""Return the node's most certain OS fingerprint as e.g. 'Microsoft Windows Server 2019', or '' if none."""
best = None
for os_element in node.findall("fingerprints/os"):
try:
certainty = float(os_element.get("certainty", 0))
except ValueError:
certainty = 0.0
if best is None or certainty > best[0]:
best = (certainty, os_element)
if best is None:
return ""
os_element = best[1]
vendor = os_element.get("vendor", "")
product = os_element.get("product", "")
# Avoid "Linux Linux" when the vendor is already part of the product name
parts = [vendor] if vendor and not product.lower().startswith(vendor.lower()) else []
parts += [product, os_element.get("version", "")]
return " ".join(part for part in parts if part)

def get_items(self, tree, vulns, test):
hosts = []
for nodes in tree.findall("nodes"):
for node in nodes.findall("node"):
host = {}
host["name"] = node.get("address")
host["hostnames"] = set()
host["os"] = ""
host["os"] = self.parse_os_fingerprint(node)
host["services"] = []
host["vulns"] = self.parse_tests_type(node, vulns)

Expand Down Expand Up @@ -289,7 +310,7 @@ def get_items(self, tree, vulns, test):
find = self.findings(dupe_key, dupes, vuln)

if locations_enabled():
location = LocationData.url(host=host["name"])
location = LocationData.url(host=host["name"], association=LocationAssociationData.host_os(host["os"]))
find.unsaved_locations.append(location)
else:
# TODO: Delete this after the move to Locations
Expand All @@ -316,6 +337,7 @@ def get_items(self, tree, vulns, test):
else None,
# A little dirty hack but in case of DNS it is
# important to know if vulnerability is on TCP or UDP
association=LocationAssociationData.host_os(host["os"]),
)
find.unsaved_locations.append(location)
else:
Expand Down
Loading
Loading