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..4b658403bea --- /dev/null +++ b/docs/content/supported_tools/parsers/file/seal.md @@ -0,0 +1,55 @@ +--- +title: "Seal Security" +toc_hide: true +--- +CSV report of the [Seal Security](https://www.seal.security) CLI. + +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: + +``` +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)