Skip to content
Open
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
55 changes: 55 additions & 0 deletions docs/content/supported_tools/parsers/file/seal.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions dojo/settings/settings.dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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,
Expand Down
Empty file added dojo/tools/seal/__init__.py
Empty file.
152 changes: 152 additions & 0 deletions dojo/tools/seal/parser.py
Original file line number Diff line number Diff line change
@@ -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<vulnerability_id>.+?)\(via shaded (?P<packages>.+)\)$")

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 <file>`."

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
5 changes: 5 additions & 0 deletions unittests/scans/seal/many_vulns.csv
Original file line number Diff line number Diff line change
@@ -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
Empty file.
1 change: 1 addition & 0 deletions unittests/scans/seal/no_vuln_header_only.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Library,Version,Ecosystem,Vulnerabilities,Can Seal,Sealed Version
2 changes: 2 additions & 0 deletions unittests/scans/seal/one_vuln.csv
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions unittests/scans/seal/shaded.csv
Original file line number Diff line number Diff line change
@@ -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,
5 changes: 5 additions & 0 deletions unittests/scans/seal/with_score.csv
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions unittests/tools/test_seal_parser.py
Original file line number Diff line number Diff line change
@@ -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)
Loading