From 7546aeee9700bb10e14f69b7f35b2e5c02ad05fc Mon Sep 17 00:00:00 2001 From: Amit Agam Date: Sun, 9 Aug 2026 16:14:29 +0300 Subject: [PATCH 1/3] Add Seal Security CSV parser Adds a parser for the CSV export of the Seal Security CLI, produced by `seal scan --csv `. The export has one row per vulnerable package, with a pipe-separated list of vulnerability identifiers. Each identifier becomes its own Finding so that it can be triaged independently. Identifiers are not always CVEs: Seal reports the most specific identifier it has, falling back to a GitHub advisory or Snyk identifier when no CVE is assigned. Two properties of the export are worth noting: - A scan without findings leaves the file empty rather than writing a header, so an empty file is a valid report and yields no findings. - A vulnerability reaching the project through an embedded (shaded) package is reported as `CVE-2021-1234(via shaded lib1&lib2)`. The identifier is used for the Finding and the embedding packages are named in the description. The export has no severity column, so findings default to Medium. The parser reads an optional Score column when present and maps it onto the standard severity bands. Severity is deliberately left out of the deduplication hashcode so that findings imported before and after a report gains that column are not treated as distinct. Co-Authored-By: Claude Opus 5 (1M context) --- .../supported_tools/parsers/file/seal.md | 45 ++++++ dojo/settings/settings.dist.py | 5 + dojo/tools/seal/__init__.py | 0 dojo/tools/seal/parser.py | 152 ++++++++++++++++++ unittests/scans/seal/many_vulns.csv | 5 + unittests/scans/seal/no_vuln.csv | 0 unittests/scans/seal/no_vuln_header_only.csv | 1 + unittests/scans/seal/one_vuln.csv | 2 + unittests/scans/seal/shaded.csv | 3 + unittests/scans/seal/with_score.csv | 5 + unittests/tools/test_seal_parser.py | 95 +++++++++++ 11 files changed, 313 insertions(+) create mode 100644 docs/content/supported_tools/parsers/file/seal.md create mode 100644 dojo/tools/seal/__init__.py create mode 100644 dojo/tools/seal/parser.py create mode 100644 unittests/scans/seal/many_vulns.csv create mode 100644 unittests/scans/seal/no_vuln.csv create mode 100644 unittests/scans/seal/no_vuln_header_only.csv create mode 100644 unittests/scans/seal/one_vuln.csv create mode 100644 unittests/scans/seal/shaded.csv create mode 100644 unittests/scans/seal/with_score.csv create mode 100644 unittests/tools/test_seal_parser.py diff --git a/docs/content/supported_tools/parsers/file/seal.md b/docs/content/supported_tools/parsers/file/seal.md new file mode 100644 index 00000000000..7b75f7b66dc --- /dev/null +++ b/docs/content/supported_tools/parsers/file/seal.md @@ -0,0 +1,45 @@ +--- +title: "Seal Security" +toc_hide: true +--- +CSV report of the [Seal Security](https://www.seal.security) CLI. + +Seal Security backports security fixes into "sealed" versions of open-source packages, +so a vulnerable dependency can be remediated without a major-version upgrade. + +Generate the report with the `--csv` flag: + +``` +seal scan --csv results.csv +``` + +The export contains one row per vulnerable package. A row that lists several +vulnerability identifiers is imported as one Finding per identifier, so that each one +can be triaged and risk-accepted independently. + +Identifiers are not always CVEs. Seal reports the most specific identifier it has for a +vulnerability, falling back to a GitHub advisory or Snyk identifier when no CVE is +assigned. + +When Seal has a sealed version available for the package, `fix_available` is set on the +Finding and the mitigation names the sealed version to update to. + +Findings for a vulnerability that reaches the project through an embedded (shaded) +package name the embedding package in the description. The Finding's component remains +the package that is actually present in the project. + +### Severity + +The CSV export has no severity column, so all Findings are imported as Medium unless +the report contains a `Score` column, in which case the score is mapped onto the +standard CVSS severity bands. + +### Sample Scan Data +Sample Seal Security scans can be found [here](https://github.com/DefectDojo/django-DefectDojo/tree/master/unittests/scans/seal). + +### Default Deduplication Hashcode Fields +By default, DefectDojo identifies duplicate Findings using these [hashcode fields](https://docs.defectdojo.com/en/working_with_findings/finding_deduplication/about_deduplication/): + +- vulnerability ids +- component name +- component version diff --git a/dojo/settings/settings.dist.py b/dojo/settings/settings.dist.py index 074d0007fea..eee2d755f72 100644 --- a/dojo/settings/settings.dist.py +++ b/dojo/settings/settings.dist.py @@ -1134,6 +1134,10 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param "JFrog Xray On Demand Binary Scan": ["title", "component_name", "component_version"], "JFrog Xray API Summary Artifact Scan": ["title", "description", "component_name", "component_version"], "Scout Suite Scan": ["file_path", "vuln_id_from_tool"], # for now we use file_path as there is no attribute for "service" + # severity is deliberately excluded: the Seal CSV has no severity column today, so + # every finding gets the same default, and including it would fork all existing + # findings into duplicates once the CLI starts exporting a score + "Seal Security Scan": ["vulnerability_ids", "component_name", "component_version"], "Meterian Scan": ["cwe", "component_name", "component_version", "description", "severity"], "Github SAST Scan": ["vuln_id_from_tool", "severity", "file_path", "line"], "Github Vulnerability Scan": ["title", "severity", "component_name", "vulnerability_ids", "file_path"], @@ -1595,6 +1599,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param # findings mitigated and re-created in one reimport of otherwise unchanged data). "JFrog Xray API Summary Artifact Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, "Scout Suite Scan": DEDUPE_ALGO_HASH_CODE, + "Seal Security Scan": DEDUPE_ALGO_HASH_CODE, "AWS Security Hub Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL, "Meterian Scan": DEDUPE_ALGO_HASH_CODE, "Github SAST Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL_OR_HASH_CODE, diff --git a/dojo/tools/seal/__init__.py b/dojo/tools/seal/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dojo/tools/seal/parser.py b/dojo/tools/seal/parser.py new file mode 100644 index 00000000000..2b65120c75a --- /dev/null +++ b/dojo/tools/seal/parser.py @@ -0,0 +1,152 @@ +"""Parser for the CSV export of the Seal Security CLI (https://www.seal.security)""" + +import csv +import io +import re + +from dojo.models import Finding + +VULNERABILITY_SEPARATOR = "|" +EMBEDDED_PACKAGE_SEPARATOR = "&" + +# A vulnerability reached through an embedded (shaded) package is reported as +# "CVE-2021-1234(via shaded lib1&lib2)" rather than as a bare identifier. +EMBEDDED_VIA_PATTERN = re.compile(r"^(?P.+?)\(via shaded (?P.+)\)$") + +DESCRIPTION_TEMPLATE = """**Package:** {package} +**Ecosystem:** {ecosystem} +**Vulnerability:** {vulnerability_id} +""" + +EMBEDDED_VIA_TEMPLATE = """ +The vulnerability is not in {package} itself. It reaches the project through the \ +following package(s) embedded (shaded) into it: {packages}. +""" + +SEALED_MITIGATION_TEMPLATE = """Update {package} to the sealed version {sealed_version}. \ +Sealed versions backport the security fix without changing the major version, so they \ +are drop-in replacements. +""" + +NO_FIX_MITIGATION = "Seal has no sealed version for this package version yet." + + +def convert_severity(score): + """ + Map a Seal unified score onto a DefectDojo severity. + + The score column is absent from the CSV written by current CLI versions, in which + case no severity can be derived from the report and Medium is used. + """ + if not score: + return "Medium" + try: + score = float(score) + except ValueError: + return "Medium" + if score >= 9.0: + return "Critical" + if score >= 7.0: + return "High" + if score >= 4.0: + return "Medium" + if score > 0.0: + return "Low" + return "Info" + + +def split_vulnerabilities(vulnerabilities): + """Split a Vulnerabilities cell into (vulnerability_id, embedding packages) pairs.""" + results = [] + for raw_entry in vulnerabilities.split(VULNERABILITY_SEPARATOR): + entry = raw_entry.strip() + if not entry: + continue + match = EMBEDDED_VIA_PATTERN.match(entry) + if match: + packages = [ + package.strip() + for package in match.group("packages").split(EMBEDDED_PACKAGE_SEPARATOR) + if package.strip() + ] + results.append((match.group("vulnerability_id").strip(), packages)) + else: + results.append((entry, [])) + return results + + +class SealParser: + def get_scan_types(self): + return ["Seal Security Scan"] + + def get_label_for_scan_types(self, scan_type): + return "Seal Security Scan" + + def get_description_for_scan_types(self, scan_type): + return "Import the CSV export of the Seal Security CLI, produced by `seal scan --csv `." + + def get_findings(self, file, test): + content = file.read() + if isinstance(content, bytes): + content = content.decode("utf-8-sig") + # A byte order mark survives when the report is handed over already decoded, and + # would otherwise end up glued to the first column name + content = content.lstrip("\ufeff") + # A scan that finds nothing leaves the file empty rather than writing a header. + if not content.strip(): + return [] + + findings = [] + for row in csv.DictReader(io.StringIO(content)): + findings.extend(self.get_findings_for_package(row, test)) + return findings + + def get_findings_for_package(self, row, test): + package_name = (row.get("Library") or "").strip() + package_version = (row.get("Version") or "").strip() + ecosystem = (row.get("Ecosystem") or "").strip() + sealed_version = (row.get("Sealed Version") or "").strip() + can_seal = (row.get("Can Seal") or "").strip().upper() == "TRUE" + severity = convert_severity((row.get("Score") or "").strip()) + + if not package_name: + return [] + + package = f"{package_name} {package_version}".strip() + if can_seal and sealed_version: + mitigation = SEALED_MITIGATION_TEMPLATE.format( + package=package_name, sealed_version=sealed_version, + ) + else: + mitigation = NO_FIX_MITIGATION + + findings = [] + for vulnerability_id, embedded_via in split_vulnerabilities(row.get("Vulnerabilities") or ""): + description = DESCRIPTION_TEMPLATE.format( + package=package, + ecosystem=ecosystem, + vulnerability_id=vulnerability_id, + ) + if embedded_via: + description += EMBEDDED_VIA_TEMPLATE.format( + package=package_name, + packages=", ".join(embedded_via), + ) + + finding = Finding( + test=test, + title=f"{package} - {vulnerability_id}", + severity=severity, + description=description, + mitigation=mitigation, + component_name=package_name, + component_version=package_version, + vuln_id_from_tool=vulnerability_id, + fix_available=can_seal, + static_finding=True, + dynamic_finding=False, + ) + finding.unsaved_vulnerability_ids = [vulnerability_id] + findings.append(finding) + + return findings diff --git a/unittests/scans/seal/many_vulns.csv b/unittests/scans/seal/many_vulns.csv new file mode 100644 index 00000000000..212a3fc6fb9 --- /dev/null +++ b/unittests/scans/seal/many_vulns.csv @@ -0,0 +1,5 @@ +Library,Version,Ecosystem,Vulnerabilities,Can Seal,Sealed Version +lodash,4.17.15,NPM,CVE-2021-23337|CVE-2020-8203,TRUE,4.17.15-sp1 +django,3.2.4,PyPI,CVE-2021-33203|CVE-2021-33571|GHSA-jrh2-hc4r-7jrq,TRUE,3.2.4+sp1 +org.apache.commons:commons-lang3,3.9,Maven,CVE-2025-48924,FALSE, +github.com/gin-gonic/gin,1.7.7,GO,CVE-2023-26125,TRUE,1.7.7+sp1 diff --git a/unittests/scans/seal/no_vuln.csv b/unittests/scans/seal/no_vuln.csv new file mode 100644 index 00000000000..e69de29bb2d diff --git a/unittests/scans/seal/no_vuln_header_only.csv b/unittests/scans/seal/no_vuln_header_only.csv new file mode 100644 index 00000000000..9d6fa9a36ec --- /dev/null +++ b/unittests/scans/seal/no_vuln_header_only.csv @@ -0,0 +1 @@ +Library,Version,Ecosystem,Vulnerabilities,Can Seal,Sealed Version diff --git a/unittests/scans/seal/one_vuln.csv b/unittests/scans/seal/one_vuln.csv new file mode 100644 index 00000000000..ade5eb0e710 --- /dev/null +++ b/unittests/scans/seal/one_vuln.csv @@ -0,0 +1,2 @@ +Library,Version,Ecosystem,Vulnerabilities,Can Seal,Sealed Version +lodash,4.17.15,NPM,CVE-2021-23337,TRUE,4.17.15-sp1 diff --git a/unittests/scans/seal/shaded.csv b/unittests/scans/seal/shaded.csv new file mode 100644 index 00000000000..688a3a47f77 --- /dev/null +++ b/unittests/scans/seal/shaded.csv @@ -0,0 +1,3 @@ +Library,Version,Ecosystem,Vulnerabilities,Can Seal,Sealed Version +com.example:fat-jar,1.0.0,Maven,CVE-2021-44228(via shaded log4j-core),TRUE,1.0.0+sp1 +com.example:other-jar,2.0.0,Maven,CVE-2022-42003(via shaded jackson-databind&jackson-core)|CVE-2020-8908,FALSE, diff --git a/unittests/scans/seal/with_score.csv b/unittests/scans/seal/with_score.csv new file mode 100644 index 00000000000..b7feb9060b1 --- /dev/null +++ b/unittests/scans/seal/with_score.csv @@ -0,0 +1,5 @@ +Library,Version,Ecosystem,Vulnerabilities,Can Seal,Sealed Version,Score +log4j-core,2.14.1,Maven,CVE-2021-44228,TRUE,2.14.1+sp1,10.0 +requests,2.19.1,PyPI,CVE-2018-18074,TRUE,2.19.1+sp1,7.5 +lodash,4.17.15,NPM,CVE-2020-8203,TRUE,4.17.15-sp1,5.6 +tar,4.4.10,NPM,CVE-2021-32803,FALSE,,3.1 diff --git a/unittests/tools/test_seal_parser.py b/unittests/tools/test_seal_parser.py new file mode 100644 index 00000000000..cbf84f05043 --- /dev/null +++ b/unittests/tools/test_seal_parser.py @@ -0,0 +1,95 @@ +from dojo.models import Finding, Test +from dojo.tools.seal.parser import SealParser +from unittests.dojo_test_case import DojoTestCase, get_unit_tests_scans_path + + +class TestSealParser(DojoTestCase): + def parse(self, file_name): + with (get_unit_tests_scans_path("seal") / file_name).open(encoding="utf-8") as testfile: + return SealParser().get_findings(testfile, Test()) + + def test_parse_file_with_no_vuln(self): + # A scan without findings leaves the export file empty, without even a header + self.assertEqual(0, len(self.parse("no_vuln.csv"))) + + def test_parse_file_with_header_only(self): + self.assertEqual(0, len(self.parse("no_vuln_header_only.csv"))) + + def test_parse_file_with_one_vuln(self): + findings = self.parse("one_vuln.csv") + self.assertEqual(1, len(findings)) + finding = findings[0] + self.assertEqual("lodash 4.17.15 - CVE-2021-23337", finding.title) + self.assertIn(finding.severity, Finding.SEVERITIES) + self.assertEqual("Medium", finding.severity) + self.assertEqual("lodash", finding.component_name) + self.assertEqual("4.17.15", finding.component_version) + self.assertEqual("CVE-2021-23337", finding.vuln_id_from_tool) + self.assertEqual(["CVE-2021-23337"], finding.unsaved_vulnerability_ids) + self.assertTrue(finding.fix_available) + self.assertTrue(finding.static_finding) + self.assertFalse(finding.dynamic_finding) + self.assertIn("NPM", finding.description) + self.assertIn("4.17.15-sp1", finding.mitigation) + + def test_parse_file_with_many_vulns(self): + findings = self.parse("many_vulns.csv") + self.assertEqual(7, len(findings)) + + with self.subTest(i=0): + finding = findings[0] + self.assertEqual("lodash 4.17.15 - CVE-2021-23337", finding.title) + self.assertEqual("lodash", finding.component_name) + + with self.subTest(i=1): + # The second identifier of the same package becomes its own finding + finding = findings[1] + self.assertEqual("lodash 4.17.15 - CVE-2020-8203", finding.title) + self.assertEqual("lodash", finding.component_name) + self.assertEqual("4.17.15", finding.component_version) + + with self.subTest(i=4): + # Seal reports GitHub advisory identifiers when a CVE is not assigned + finding = findings[4] + self.assertEqual("django 3.2.4 - GHSA-jrh2-hc4r-7jrq", finding.title) + self.assertEqual(["GHSA-jrh2-hc4r-7jrq"], finding.unsaved_vulnerability_ids) + + with self.subTest(i=5): + finding = findings[5] + self.assertEqual("org.apache.commons:commons-lang3", finding.component_name) + self.assertFalse(finding.fix_available) + self.assertEqual("Seal has no sealed version for this package version yet.", finding.mitigation) + + with self.subTest(i=6): + finding = findings[6] + self.assertEqual("github.com/gin-gonic/gin", finding.component_name) + self.assertIn("1.7.7+sp1", finding.mitigation) + + def test_parse_file_with_shaded_packages(self): + findings = self.parse("shaded.csv") + self.assertEqual(3, len(findings)) + + with self.subTest(i=0): + finding = findings[0] + self.assertEqual("com.example:fat-jar 1.0.0 - CVE-2021-44228", finding.title) + self.assertEqual("CVE-2021-44228", finding.vuln_id_from_tool) + self.assertIn("log4j-core", finding.description) + + with self.subTest(i=1): + finding = findings[1] + self.assertEqual("com.example:other-jar 2.0.0 - CVE-2022-42003", finding.title) + self.assertIn("jackson-databind, jackson-core", finding.description) + + with self.subTest(i=2): + # Same package, but this identifier carries no embedding chain + finding = findings[2] + self.assertEqual("com.example:other-jar 2.0.0 - CVE-2020-8908", finding.title) + self.assertNotIn("shaded", finding.description) + + def test_parse_file_with_score_column(self): + findings = self.parse("with_score.csv") + self.assertEqual(4, len(findings)) + self.assertEqual("Critical", findings[0].severity) + self.assertEqual("High", findings[1].severity) + self.assertEqual("Medium", findings[2].severity) + self.assertEqual("Low", findings[3].severity) From 908848d53a4fe0b7f2f4b2122aeddc9de988678f Mon Sep 17 00:00:00 2001 From: Amit Agam Date: Sun, 9 Aug 2026 16:35:58 +0300 Subject: [PATCH 2/3] Describe the sealed-version model in the Seal parser docs Co-Authored-By: Claude Opus 5 (1M context) --- docs/content/supported_tools/parsers/file/seal.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/content/supported_tools/parsers/file/seal.md b/docs/content/supported_tools/parsers/file/seal.md index 7b75f7b66dc..4b658403bea 100644 --- a/docs/content/supported_tools/parsers/file/seal.md +++ b/docs/content/supported_tools/parsers/file/seal.md @@ -4,8 +4,18 @@ toc_hide: true --- CSV report of the [Seal Security](https://www.seal.security) CLI. -Seal Security backports security fixes into "sealed" versions of open-source packages, -so a vulnerable dependency can be remediated without a major-version upgrade. +Seal Security remediates vulnerable open-source dependencies without upgrading them to a +new major version. Rather than pointing at the next fixed release, it backports the +security fix onto the version already in use and publishes the result as a "sealed" +version of the same package, such as `lodash@4.17.15-sp1` for npm or `requests@2.19.1+sp1` +for PyPI. Because only the patch content changes, a sealed version is a drop-in +replacement, which is what makes it useful for dependencies where the fixed release +carries breaking changes. + +Sealed packages are served through registry proxies, so consuming them is a package +manager configuration change rather than a code change. The CLI scans a project against +Seal's vulnerability data and reports, per vulnerable package, whether a sealed version +exists for the exact version in use. Generate the report with the `--csv` flag: From ba46da99f705e022823b7a81a9d1dd4a9bf4630d Mon Sep 17 00:00:00 2001 From: Amit Agam Date: Sun, 16 Aug 2026 11:32:42 +0300 Subject: [PATCH 3/3] Keep Seal parser fix_available consistent with the mitigation text A row with Can Seal=TRUE but no Sealed Version yielded a finding marked fix_available while its mitigation said no sealed version exists. Derive both from the same condition. --- dojo/tools/seal/parser.py | 5 +++-- unittests/scans/seal/many_vulns.csv | 1 + unittests/tools/test_seal_parser.py | 9 ++++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/dojo/tools/seal/parser.py b/dojo/tools/seal/parser.py index 2b65120c75a..db1e9070f09 100644 --- a/dojo/tools/seal/parser.py +++ b/dojo/tools/seal/parser.py @@ -113,7 +113,8 @@ def get_findings_for_package(self, row, test): return [] package = f"{package_name} {package_version}".strip() - if can_seal and sealed_version: + has_fix = can_seal and bool(sealed_version) + if has_fix: mitigation = SEALED_MITIGATION_TEMPLATE.format( package=package_name, sealed_version=sealed_version, ) @@ -142,7 +143,7 @@ def get_findings_for_package(self, row, test): component_name=package_name, component_version=package_version, vuln_id_from_tool=vulnerability_id, - fix_available=can_seal, + fix_available=has_fix, static_finding=True, dynamic_finding=False, ) diff --git a/unittests/scans/seal/many_vulns.csv b/unittests/scans/seal/many_vulns.csv index 212a3fc6fb9..a471688f8b3 100644 --- a/unittests/scans/seal/many_vulns.csv +++ b/unittests/scans/seal/many_vulns.csv @@ -3,3 +3,4 @@ lodash,4.17.15,NPM,CVE-2021-23337|CVE-2020-8203,TRUE,4.17.15-sp1 django,3.2.4,PyPI,CVE-2021-33203|CVE-2021-33571|GHSA-jrh2-hc4r-7jrq,TRUE,3.2.4+sp1 org.apache.commons:commons-lang3,3.9,Maven,CVE-2025-48924,FALSE, github.com/gin-gonic/gin,1.7.7,GO,CVE-2023-26125,TRUE,1.7.7+sp1 +requests,2.19.1,PyPI,CVE-2018-18074,TRUE, diff --git a/unittests/tools/test_seal_parser.py b/unittests/tools/test_seal_parser.py index cbf84f05043..1ae8bf5068f 100644 --- a/unittests/tools/test_seal_parser.py +++ b/unittests/tools/test_seal_parser.py @@ -34,7 +34,7 @@ def test_parse_file_with_one_vuln(self): def test_parse_file_with_many_vulns(self): findings = self.parse("many_vulns.csv") - self.assertEqual(7, len(findings)) + self.assertEqual(8, len(findings)) with self.subTest(i=0): finding = findings[0] @@ -65,6 +65,13 @@ def test_parse_file_with_many_vulns(self): self.assertEqual("github.com/gin-gonic/gin", finding.component_name) self.assertIn("1.7.7+sp1", finding.mitigation) + with self.subTest(i=7): + # Sealable, but no sealed version published yet: nothing to update to + finding = findings[7] + self.assertEqual("requests", finding.component_name) + self.assertFalse(finding.fix_available) + self.assertEqual("Seal has no sealed version for this package version yet.", finding.mitigation) + def test_parse_file_with_shaded_packages(self): findings = self.parse("shaded.csv") self.assertEqual(3, len(findings))