Skip to content

Commit 3edfceb

Browse files
valentijnscholtenMaffoochclaude
authored
feat(finding): multiple CWEs per finding (#15143)
* feat(finding): multiple CWEs per finding Stacked on top of the vulnerability_id-type change (feat/vulnerability-id-type). Adds a Finding_CWE relationship so a finding can carry multiple CWEs, mirroring vulnerability ids: the primary CWE stays on Finding.cwe; additional CWEs live in the relationship and are exposed via finding.cwes. Wired through the UI, the API (cwes field), and parsers (finding.unsaved_cwes). CWE is a weakness class, kept out of hash_code and the cve field, so existing hash codes/dedup are unaffected. Migrations 0279_finding_cwe (create table) and 0280_backfill_finding_cwe (seed from legacy Finding.cwe), chained after the vulnerability_id migrations (0278). Backfill existing findings with: manage.py migrate_cwe * feat(generic): support multiple CWEs per finding in generic importers The generic JSON and CSV importers emitted only a single `cwe` per finding, so a finding could not be imported with a CWE *set*. Mirror the existing `vulnerability_ids` handling to accept multiple CWEs: - JSON: accept an optional `cwes` list (popped like `vulnerability_ids`, since it is not a Finding field). The first entry becomes the primary `finding.cwe` when `cwe` is not already given; the full set is kept on `finding.unsaved_cwes`. Mixed int / "CWE-<n>" forms are supported. - CSV: accept a delimited `CweIds` column alongside the existing single `CweId`, with the same primary-plus-set behavior; merge CWE sets across internally-deduplicated rows like vulnerability ids. The import pipeline persists the set via `Finding_CWE`; normalization and deduplication happen in `finding_cwe_labels()`. The legacy single `cwe` path is unchanged. Adds a fabricated multi-CWE fixture and parser tests (JSON + CSV). * feat(finding): make the CWE set a hashable dedup field (cwes) Add 'cwes' to HASHCODE_ALLOWED_FIELDS and hash it via a new get_cwes(), which mirrors get_vulnerability_ids. Because the primary cwe is a scalar that is always set (so Finding.cwes is non-empty even before the extra Finding_CWE rows are written during import), get_cwes prefers unsaved_cwes whenever present, so the pre-save and post-save import hashes agree. Tests: get_cwes prefers unsaved_cwes, is stable across save, empty with no cwe, and cwes participates in compute_hash_code. * docs(finding): note the primary cwe field can be removed in the future Mirror the existing cve/vulnerability_ids deprecation note: the single primary cwe field is merged into the cwes set for backward compatibility and can be removed once no longer needed. * fix(finding): compute cwe hash from finding_cwe_set directly, not the cached property get_cwes() used the cwes @cached_property for the saved path; if finding.cwes was accessed before the Finding_CWE rows were written (serializer/signal/log), it cached an empty/partial set and the hash then used that stale value -> nondeterministic hash_code (flaky dedup under parallel test runs). Mirror get_vulnerability_ids: read the finding_cwe_set reverse relation directly (uncached, prefetch-honoring), falling back to the unsaved values before save. save_cwes stores the primary cwe as a row too, so finding_cwe_set carries the full set. * refactor(finding): move CWE helpers to dojo/finding/cwe.py Move cwe_number, cwe_label, parse_cwes, finding_cwe_labels out of finding/vulnerability_id.py into a dedicated finding/cwe.py (CWEs are a weakness class, distinct from vulnerability identifiers). resolve_vulnerability_id_type stays in vulnerability_id.py. All importers updated. * fix(cwe): guard save_cwes on partial update; bound CWE number to column width Two data-safety fixes for the multi-CWE relation: - FindingSerializer.update() called save_cwes() unconditionally, so any PATCH/PUT that omitted `cwes` deleted a finding's extra Finding_CWE rows (leaving only the primary). Guard the call on `parsed_cwes is not None`, mirroring the vulnerability-id path. - cwe_number() had no upper bound, but Finding_CWE.cwe stores "CWE-<n>" in a varchar(11). A number > 9,999,999 passed validation then raised DataError at insert (API/form 500, and would abort the 0280 backfill's bulk_create, since ignore_conflicts does not swallow DataError). Reject it as invalid input (None) so it becomes a clean 400/skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): renumber CWE migrations 0283/0284 after restack Restacked onto the renumbered vulnerability-id chain; the CWE migrations move 0279/0280 -> 0283/0284 and re-parent onto 0282_unique_finding_vulnerability_id so the branch has a single linear migration leaf again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(upgrade): fix migration numbers and the redundant migrate_cwe step (F10) - Update the migration list to the renumbered chain (0280-0284). - Remove the contradiction: 0284_backfill_finding_cwe already backfills every existing finding automatically, so migrate_cwe is NOT a required upgrade step (it would repeat that work). Reframe migrate_cwe as an optional, idempotent re-scan for later use (e.g. after a parser CWE change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(perf): re-baseline importer perf counts on merged dev (CWE) The pghistory_no_async_with_product_grading counts were placeholders from the pre-merge branch base. Update import1/reimport1 to the actual counts on the merged dev (from CI): the multi-CWE work adds ~2 queries per import phase and the async-task counts follow dev's current values. Small: import1 182q/5t, reimport1 141q/4t SmallLocations: import1 194q/5t, reimport1 155q/4t Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0f2a5af commit 3edfceb

133 files changed

Lines changed: 3879 additions & 160 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/content/releases/os_upgrading/3.2.md

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,58 @@
22
title: 'Upgrading to DefectDojo Version 3.2.x'
33
toc_hide: true
44
weight: -20260701
5-
description: Vulnerability ids gain an autodetected type. A migration adds the type column, de-duplicates vulnerability-id rows, and adds a uniqueness constraint. Existing hash codes are unaffected.
5+
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.
66
---
77

88
## Vulnerability id type
99

1010
Each `Vulnerability_Id` gains an autodetected `vulnerability_id_type` — the identifier's leading
1111
prefix (`CVE-2024-1234``CVE`, `GHSA-…``GHSA`, `RUSTSEC-…``RUSTSEC`). It is derived
1212
structurally (no registry) and stored (indexed) so identifiers can be filtered and grouped by type
13-
efficiently. It is `NULL` when there is no non-numeric prefix.
13+
efficiently. It is `NULL` when there is no non-numeric prefix. It is populated automatically on
14+
import and on `save()`; existing rows are backfilled by migration. It does not participate in
15+
`hash_code`, so **existing hash codes and deduplication are unaffected**.
1416

15-
The type is populated automatically: on import (bulk paths) and on `save()`. Existing rows are
16-
backfilled by the migration below. This is a denormalized, derived attribute — it does not
17-
participate in `hash_code`, so **existing hash codes and deduplication are unaffected**.
17+
A unique constraint is also added on `(finding, vulnerability_id)`; pre-existing duplicate rows
18+
(unintended) are consolidated first.
19+
20+
## Multiple CWEs per finding
21+
22+
A finding could previously store only one CWE (the integer `cwe` field). This release adds a
23+
dedicated `Finding_CWE` relationship so a finding can carry **multiple CWEs**, using the same
24+
approach as vulnerability ids: the primary CWE stays on `Finding.cwe` (unchanged — legacy
25+
deduplication and hash codes still use it), and additional CWEs live in the relationship.
26+
27+
CWE is modeled separately from vulnerability identifiers on purpose: a CWE is a weakness *class*,
28+
not a vulnerability *instance* identifier, so it must not participate in `hash_code`,
29+
vulnerability-id deduplication, or the `cve` field. Because of this separation, **existing hash
30+
codes and deduplication are unaffected**.
31+
32+
CWEs are populated automatically on import and when a finding is created or edited (from the
33+
finding's CWE field, plus any additional CWEs a parser supplies). The finding exposes them via
34+
`finding.cwes` (primary first, deduplicated).
1835

1936
## Database migration
2037

21-
Three migrations run automatically on upgrade:
38+
Five migrations run automatically on upgrade:
2239

23-
- `0276_vulnerability_id_type` — adds the indexed `vulnerability_id_type` column and a leading
40+
- `0280_vulnerability_id_type` — adds the indexed `vulnerability_id_type` column and a leading
2441
index on `vulnerability_id`.
25-
- `0277_backfill_vulnerability_id_type` — backfills `vulnerability_id_type` for existing rows and
26-
removes duplicate `(finding, vulnerability_id)` rows, keeping the earliest of each — such
27-
duplicates are unintended, and consolidating them allows a uniqueness constraint to be added.
28-
- `0278_unique_finding_vulnerability_id` — adds a unique constraint on `(finding, vulnerability_id)`.
42+
- `0281_backfill_vulnerability_id_type` — backfills `vulnerability_id_type` and removes duplicate
43+
`(finding, vulnerability_id)` rows (keeping the earliest).
44+
- `0282_unique_finding_vulnerability_id` — adds the unique constraint on `(finding, vulnerability_id)`.
45+
- `0283_finding_cwe` — creates the `Finding_CWE` table (unique per `(finding, cwe)`).
46+
- `0284_backfill_finding_cwe` — seeds `Finding_CWE` rows for all existing findings from their
47+
legacy `Finding.cwe` values.
2948

3049
### What you need to do
3150

32-
The migrations are applied automatically. No manual steps are required.
51+
Nothing — the migrations are applied automatically, `0284_backfill_finding_cwe` backfills every
52+
existing finding, and new/edited findings populate their CWE relationship on save and import.
53+
54+
The `manage.py migrate_cwe` management command is **optional** and is *not* required on upgrade
55+
(it would repeat the work `0284` already did). It exists only as an idempotent re-scan you can run
56+
later — for example after a parser upgrade changes how a scan's CWEs are derived — to reconcile
57+
`Finding_CWE` rows from the current `Finding.cwe` values.
3358

3459
For more information, check the [Release Notes](https://github.com/DefectDojo/django-DefectDojo/releases/tag/3.2.0).
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import django.db.models.deletion
2+
from django.db import migrations, models
3+
4+
5+
class Migration(migrations.Migration):
6+
7+
"""Schema only: create the Finding_CWE relationship (multiple CWEs per finding). The data
8+
backfill from the legacy Finding.cwe field is kept in a separate migration (0280) so data
9+
migrations are never mixed with schema migrations."""
10+
11+
dependencies = [
12+
("dojo", "0282_unique_finding_vulnerability_id"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="Finding_CWE",
18+
fields=[
19+
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
20+
("cwe", models.CharField(db_index=True, max_length=11)),
21+
("finding", models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, to="dojo.finding")),
22+
],
23+
),
24+
migrations.AddConstraint(
25+
model_name="finding_cwe",
26+
constraint=models.UniqueConstraint(fields=("finding", "cwe"), name="unique_finding_cwe"),
27+
),
28+
]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from django.db import migrations
2+
3+
from dojo.finding.cwe import cwe_label
4+
5+
BATCH_SIZE = 1000
6+
7+
8+
def create_finding_cwe_records(apps, schema_editor):
9+
"""Backfill Finding_CWE rows (canonical CWE-<n>) from the legacy int Finding.cwe field."""
10+
Finding = apps.get_model("dojo", "Finding")
11+
Finding_CWE = apps.get_model("dojo", "Finding_CWE")
12+
batch = []
13+
for finding in Finding.objects.filter(cwe__gt=0).only("id", "cwe").iterator(chunk_size=BATCH_SIZE):
14+
label = cwe_label(finding.cwe)
15+
if label is None:
16+
continue
17+
batch.append(Finding_CWE(finding_id=finding.id, cwe=label))
18+
if len(batch) >= BATCH_SIZE:
19+
Finding_CWE.objects.bulk_create(batch, batch_size=BATCH_SIZE, ignore_conflicts=True)
20+
batch = []
21+
if batch:
22+
Finding_CWE.objects.bulk_create(batch, batch_size=BATCH_SIZE, ignore_conflicts=True)
23+
24+
25+
class Migration(migrations.Migration):
26+
27+
"""Data only (no schema changes): create the initial Finding_CWE rows from the legacy
28+
Finding.cwe field."""
29+
30+
dependencies = [
31+
("dojo", "0283_finding_cwe"),
32+
]
33+
34+
operations = [
35+
migrations.RunPython(create_finding_cwe_records, migrations.RunPython.noop),
36+
]

dojo/finding/api/serializer.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
import dojo.finding.helper as finding_helper
1616
from dojo.authorization.authorization import user_has_permission
1717
from dojo.celery_dispatch import dojo_dispatch_task
18+
from dojo.finding.cwe import cwe_label, cwe_number
1819
from dojo.finding.helper import (
20+
save_cwes,
1921
save_endpoints_template,
2022
save_vulnerability_ids,
2123
save_vulnerability_ids_template,
@@ -32,6 +34,7 @@
3234
Endpoint,
3335
Engagement,
3436
Finding,
37+
Finding_CWE,
3538
Finding_Group,
3639
Finding_Template,
3740
Note_Type,
@@ -298,6 +301,30 @@ class Meta:
298301
fields = ["vulnerability_id"]
299302

300303

304+
@extend_schema_field(serializers.CharField())
305+
class CweField(serializers.Field):
306+
307+
"""Serialize a CWE as the canonical ``CWE-<n>`` string; accept ``"CWE-79"`` or ``"79"`` on write."""
308+
309+
def to_representation(self, value):
310+
return cwe_label(value) or value
311+
312+
def to_internal_value(self, data):
313+
label = cwe_label(data)
314+
if label is None:
315+
msg = "Enter a CWE number, e.g. 89 or CWE-89."
316+
raise serializers.ValidationError(msg)
317+
return label
318+
319+
320+
class FindingCweSerializer(serializers.ModelSerializer):
321+
cwe = CweField()
322+
323+
class Meta:
324+
model = Finding_CWE
325+
fields = ["cwe"]
326+
327+
301328
class FindingSerializer(serializers.ModelSerializer):
302329
mitigated = serializers.DateTimeField(required=False, allow_null=True)
303330
mitigated_by = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=User.objects.all())
@@ -321,6 +348,9 @@ class FindingSerializer(serializers.ModelSerializer):
321348
vulnerability_ids = VulnerabilityIdSerializer(
322349
source="vulnerability_id_set", many=True, required=False,
323350
)
351+
cwes = FindingCweSerializer(
352+
source="finding_cwe_set", many=True, required=False,
353+
)
324354
reporter = serializers.PrimaryKeyRelatedField(
325355
required=False, queryset=User.objects.all(),
326356
)
@@ -417,6 +447,13 @@ def update(self, instance, validated_data):
417447
logger.debug("SETTING CVE FROM VULNERABILITY_ID_SET: %s", parsed_vulnerability_ids[0])
418448
validated_data["cve"] = parsed_vulnerability_ids[0]
419449

450+
# CWEs (mirror vulnerability_ids): the first entry is the primary Finding.cwe; the rest
451+
# become Finding_CWE rows via save_cwes() below.
452+
parsed_cwes = None
453+
if (cwes := validated_data.pop("finding_cwe_set", None)) is not None:
454+
parsed_cwes = [entry["cwe"] for entry in cwes]
455+
validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0
456+
420457
# Save the reporter on the finding
421458
if reporter_id := validated_data.get("reporter"):
422459
instance.reporter = reporter_id
@@ -445,6 +482,14 @@ def update(self, instance, validated_data):
445482
instance, validated_data,
446483
)
447484

485+
# Sync the CWE relation (separate from vulnerability ids) after the new cwe is applied.
486+
# Only touch the CWE rows when the request actually carried `cwes`; calling save_cwes()
487+
# unconditionally would wipe a finding's extra Finding_CWE rows on any partial PATCH that
488+
# omits the field (mirrors the guarded vulnerability-id path above).
489+
if parsed_cwes is not None:
490+
instance.unsaved_cwes = parsed_cwes[1:]
491+
save_cwes(instance)
492+
448493
if settings.V3_FEATURE_LOCATIONS and locations is not None:
449494
for location_ref in instance.locations.all():
450495
location_ref.location.disassociate_from_finding(instance)
@@ -561,6 +606,9 @@ class FindingCreateSerializer(serializers.ModelSerializer):
561606
vulnerability_ids = VulnerabilityIdSerializer(
562607
source="vulnerability_id_set", many=True, required=False,
563608
)
609+
cwes = FindingCweSerializer(
610+
source="finding_cwe_set", many=True, required=False,
611+
)
564612
reporter = serializers.PrimaryKeyRelatedField(
565613
required=False, queryset=User.objects.all(),
566614
)
@@ -601,6 +649,12 @@ def create(self, validated_data):
601649
validated_data["cve"] = parsed_vulnerability_ids[0]
602650
# validated_data["unsaved_vulnerability_ids"] = parsed_vulnerability_ids
603651

652+
# CWEs (mirror vulnerability_ids): first entry is the primary cwe, the rest are extras.
653+
parsed_cwes = None
654+
if (cwes := validated_data.pop("finding_cwe_set", None)) is not None:
655+
parsed_cwes = [entry["cwe"] for entry in cwes]
656+
validated_data["cwe"] = cwe_number(parsed_cwes[0]) if parsed_cwes else 0
657+
604658
# super.create() doesn't accept unsaved_vulnerability_ids or dedupe_option=False, so call save directly.
605659
new_finding = Finding(**validated_data)
606660
new_finding.unsaved_vulnerability_ids = parsed_vulnerability_ids or []
@@ -617,6 +671,9 @@ def create(self, validated_data):
617671
new_finding.reviewers.set(reviewers)
618672
if parsed_vulnerability_ids:
619673
save_vulnerability_ids(new_finding, parsed_vulnerability_ids)
674+
if parsed_cwes is not None:
675+
new_finding.unsaved_cwes = parsed_cwes[1:]
676+
save_cwes(new_finding)
620677

621678
if push_to_jira:
622679
jira_services.push(new_finding)

dojo/finding/cwe.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Pure helpers for CWE identifiers (no model imports, safe to import anywhere)."""
2+
3+
4+
def cwe_number(value) -> int | None:
5+
"""
6+
``"CWE-79"`` / ``"79"`` / ``79`` -> ``79`` (positive int, case-insensitive); anything else -> ``None``.
7+
8+
CWE numbers are positive, so ``0`` (Finding.cwe's "unset" sentinel) and non-numeric input -> ``None``.
9+
"""
10+
if value is None:
11+
return None
12+
token = str(value).strip().upper().removeprefix("CWE-")
13+
if not token.isdigit():
14+
return None
15+
number = int(token)
16+
# Finding_CWE.cwe stores the "CWE-<n>" label in a varchar(11), so n is bounded to 7 digits.
17+
# Reject anything larger here (clean None -> 400/skip) instead of 500-ing at insert.
18+
if number <= 0 or number > 9_999_999:
19+
return None
20+
return number
21+
22+
23+
def cwe_label(value) -> str | None:
24+
"""``79`` / ``"79"`` / ``"CWE-79"`` -> ``"CWE-79"`` (canonical, case-insensitive); invalid -> ``None``."""
25+
number = cwe_number(value)
26+
return f"CWE-{number}" if number is not None else None
27+
28+
29+
def parse_cwes(text: str | None) -> list[str]:
30+
"""
31+
Parse CWEs from user text (one per line or comma-separated) into canonical ``CWE-<n>`` labels.
32+
33+
Accepts ``89`` or ``CWE-89`` (case-insensitive); ignores anything non-numeric; deduplicates.
34+
"""
35+
result: list[str] = []
36+
for token in (text or "").replace(",", "\n").split():
37+
label = cwe_label(token)
38+
if label is not None and label not in result:
39+
result.append(label)
40+
return result
41+
42+
43+
def finding_cwe_labels(cwe, unsaved_cwes=None) -> list[str]:
44+
"""Canonical ``CWE-<n>`` labels for a finding: the primary ``cwe`` first, then extras, deduplicated."""
45+
labels: list[str] = []
46+
primary = cwe_label(cwe)
47+
if primary is not None:
48+
labels.append(primary)
49+
for extra in unsaved_cwes or []:
50+
label = cwe_label(extra)
51+
if label is not None:
52+
labels.append(label)
53+
return list(dict.fromkeys(labels))

dojo/finding/deduplication.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,11 +340,11 @@ def build_candidate_scope_queryset(test, mode="deduplication", service=None):
340340
queryset = Finding.objects.filter(scope_q)
341341

342342
if settings.V3_FEATURE_LOCATIONS:
343-
prefetch_list = ["locations__location__url", "vulnerability_id_set", "found_by"]
343+
prefetch_list = ["locations__location__url", "vulnerability_id_set", "finding_cwe_set", "found_by"]
344344
else:
345345
# TODO: Delete this after the move to Locations
346346
# Base prefetches for both modes
347-
prefetch_list = ["endpoints", "vulnerability_id_set", "found_by"]
347+
prefetch_list = ["endpoints", "vulnerability_id_set", "finding_cwe_set", "found_by"]
348348

349349
# Prefetch all endpoint statuses with their endpoint for reimport mode.
350350
# The non-special filtering (excluding false_positive, out_of_scope, risk_accepted)

dojo/finding/helper.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from dojo.celery import app
2020
from dojo.endpoint.utils import endpoint_get_or_create, save_endpoints_to_add
2121
from dojo.file_uploads.helper import delete_related_files
22+
from dojo.finding.cwe import finding_cwe_labels
2223
from dojo.finding.deduplication import (
2324
dedupe_batch_of_findings,
2425
do_dedupe_finding_task_internal,
@@ -37,6 +38,7 @@
3738
Engagement,
3839
FileUpload,
3940
Finding,
41+
Finding_CWE,
4042
Finding_Group,
4143
JIRA_Instance,
4244
Notes,
@@ -1080,6 +1082,24 @@ def save_vulnerability_ids(finding, vulnerability_ids, *, delete_existing: bool
10801082
finding.cve = None
10811083

10821084

1085+
def save_cwes(finding, *, delete_existing: bool = True):
1086+
"""
1087+
Persist the finding's CWEs as Finding_CWE rows.
1088+
1089+
The primary Finding.cwe plus any parser-supplied unsaved_cwes, stored as canonical CWE-<n>
1090+
strings. CWE is a weakness class, kept separate from vulnerability ids.
1091+
"""
1092+
cwe_values = finding_cwe_labels(finding.cwe, getattr(finding, "unsaved_cwes", None))
1093+
1094+
if delete_existing:
1095+
Finding_CWE.objects.filter(finding=finding).delete()
1096+
1097+
Finding_CWE.objects.bulk_create(
1098+
[Finding_CWE(finding=finding, cwe=cwe) for cwe in cwe_values],
1099+
ignore_conflicts=True,
1100+
)
1101+
1102+
10831103
def save_vulnerability_ids_template(finding_template, vulnerability_ids):
10841104
"""Save vulnerability IDs as newline-separated string in TextField."""
10851105
# Remove duplicates and empty strings

0 commit comments

Comments
 (0)