mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 23:16:30 +01:00
Add java/npm to inline comparison (#235)
* Adds java and npm package comparison * Adds probable matching of extra packages syft found and missing packages that syft did not find (but inline did). This way there is a section of output that fuzzy-matches the package names to get a better sense of "real" problems (actual missing packages) vs slightly mismatched metadata during troubleshooting. * Adds a set or probable missing packages to the report based on the probable matches (again, to aid in troubleshooting) * Fixes image reference clean function to support references with registries * Only shows metadata differences when the package was found by both inline and syft * Splits the inline-compare code into more manageable pieces Signed-off-by: Alex Goodman <alex.goodman@anchore.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
class Colors:
|
||||
reset = "\033[0m"
|
||||
bold = "\033[01m"
|
||||
disable = "\033[02m"
|
||||
underline = "\033[04m"
|
||||
reverse = "\033[07m"
|
||||
strikethrough = "\033[09m"
|
||||
invisible = "\033[08m"
|
||||
|
||||
class FG:
|
||||
black = "\033[30m"
|
||||
red = "\033[31m"
|
||||
green = "\033[32m"
|
||||
orange = "\033[33m"
|
||||
blue = "\033[34m"
|
||||
purple = "\033[35m"
|
||||
cyan = "\033[36m"
|
||||
lightgrey = "\033[37m"
|
||||
darkgrey = "\033[90m"
|
||||
lightred = "\033[91m"
|
||||
lightgreen = "\033[92m"
|
||||
yellow = "\033[93m"
|
||||
lightblue = "\033[94m"
|
||||
pink = "\033[95m"
|
||||
lightcyan = "\033[96m"
|
||||
|
||||
class BG:
|
||||
black = "\033[40m"
|
||||
red = "\033[41m"
|
||||
green = "\033[42m"
|
||||
orange = "\033[43m"
|
||||
blue = "\033[44m"
|
||||
purple = "\033[45m"
|
||||
cyan = "\033[46m"
|
||||
lightgrey = "\033[47m"
|
||||
|
||||
|
||||
def print_rows(rows):
|
||||
if not rows:
|
||||
return
|
||||
widths = []
|
||||
for col, _ in enumerate(rows[0]):
|
||||
width = max(len(row[col]) for row in rows) + 2 # padding
|
||||
widths.append(width)
|
||||
for row in rows:
|
||||
print("".join(word.ljust(widths[col_idx]) for col_idx, word in enumerate(row)))
|
||||
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
|
||||
|
||||
def clean(image: str) -> str:
|
||||
return os.path.basename(image.replace(":", "_"))
|
||||
@@ -0,0 +1,131 @@
|
||||
import os
|
||||
import json
|
||||
import collections
|
||||
|
||||
import utils.package
|
||||
import utils.image
|
||||
|
||||
|
||||
class InlineScan:
|
||||
"""
|
||||
Class for parsing inlnie-scan output files into a set of packages and package metadata.
|
||||
"""
|
||||
report_tmpl = "{image}-{report}.json"
|
||||
|
||||
def __init__(self, image, report_dir):
|
||||
self.report_dir = report_dir
|
||||
self.image = image
|
||||
|
||||
def packages(self):
|
||||
python_packages, python_metadata = self._python_packages()
|
||||
gem_packages, gem_metadata = self._gem_packages()
|
||||
java_packages, java_metadata = self._java_packages()
|
||||
npm_packages, npm_metadata = self._npm_packages()
|
||||
os_packages, os_metadata = self._os_packages()
|
||||
|
||||
packages = (
|
||||
python_packages | os_packages | gem_packages | java_packages | npm_packages
|
||||
)
|
||||
metadata = {
|
||||
**python_metadata,
|
||||
**os_metadata,
|
||||
**gem_metadata,
|
||||
**java_metadata,
|
||||
**npm_metadata,
|
||||
}
|
||||
|
||||
return utils.package.Info(packages=frozenset(packages), metadata=metadata)
|
||||
|
||||
def _report_path(self, report):
|
||||
return os.path.join(
|
||||
self.report_dir,
|
||||
self.report_tmpl.format(image=utils.image.clean(self.image), report=report),
|
||||
)
|
||||
|
||||
def _enumerate_section(self, report, section):
|
||||
report_path = self._report_path(report=report)
|
||||
os_report_path = self._report_path(report="content-os")
|
||||
|
||||
if os.path.exists(os_report_path) and not os.path.exists(report_path):
|
||||
# if the OS report is there but the target report is not, that is engine's way of saying "no findings"
|
||||
return
|
||||
|
||||
with open(report_path) as json_file:
|
||||
data = json.load(json_file)
|
||||
for entry in data[section]:
|
||||
yield entry
|
||||
|
||||
def _java_packages(self):
|
||||
packages = set()
|
||||
metadata = collections.defaultdict(dict)
|
||||
for entry in self._enumerate_section(report="content-java", section="content"):
|
||||
# normalize to pseudo-inline
|
||||
pkg_type = entry["type"].lower()
|
||||
if pkg_type in ("java-jar", "java-war", "java-ear"):
|
||||
pkg_type = "java-?ar"
|
||||
elif pkg_type in ("java-jpi", "java-hpi"):
|
||||
pkg_type = "java-?pi"
|
||||
|
||||
pkg = utils.package.Package(
|
||||
name=entry["package"],
|
||||
type=pkg_type,
|
||||
)
|
||||
packages.add(pkg)
|
||||
metadata[pkg.type][pkg] = utils.package.Metadata(
|
||||
version=entry["maven-version"]
|
||||
)
|
||||
|
||||
return packages, metadata
|
||||
|
||||
def _npm_packages(self):
|
||||
packages = set()
|
||||
metadata = collections.defaultdict(dict)
|
||||
for entry in self._enumerate_section(report="content-npm", section="content"):
|
||||
pkg = utils.package.Package(
|
||||
name=entry["package"],
|
||||
type=entry["type"].lower(),
|
||||
)
|
||||
packages.add(pkg)
|
||||
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
|
||||
|
||||
return packages, metadata
|
||||
|
||||
def _python_packages(self):
|
||||
packages = set()
|
||||
metadata = collections.defaultdict(dict)
|
||||
for entry in self._enumerate_section(
|
||||
report="content-python", section="content"
|
||||
):
|
||||
pkg = utils.package.Package(
|
||||
name=entry["package"],
|
||||
type=entry["type"].lower(),
|
||||
)
|
||||
packages.add(pkg)
|
||||
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
|
||||
|
||||
return packages, metadata
|
||||
|
||||
def _gem_packages(self):
|
||||
packages = set()
|
||||
metadata = collections.defaultdict(dict)
|
||||
for entry in self._enumerate_section(report="content-gem", section="content"):
|
||||
pkg = utils.package.Package(
|
||||
name=entry["package"],
|
||||
type=entry["type"].lower(),
|
||||
)
|
||||
packages.add(pkg)
|
||||
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
|
||||
|
||||
return packages, metadata
|
||||
|
||||
def _os_packages(self):
|
||||
packages = set()
|
||||
metadata = collections.defaultdict(dict)
|
||||
for entry in self._enumerate_section(report="content-os", section="content"):
|
||||
pkg = utils.package.Package(
|
||||
name=entry["package"], type=entry["type"].lower()
|
||||
)
|
||||
packages.add(pkg)
|
||||
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
|
||||
|
||||
return packages, metadata
|
||||
@@ -0,0 +1,156 @@
|
||||
import difflib
|
||||
import collections
|
||||
import dataclasses
|
||||
from typing import Set, FrozenSet, Tuple, Any, List
|
||||
|
||||
Metadata = collections.namedtuple("Metadata", "version")
|
||||
Package = collections.namedtuple("Package", "name type")
|
||||
Info = collections.namedtuple("Info", "packages metadata")
|
||||
|
||||
SimilarPackages = collections.namedtuple("SimilarPackages", "pkg missed")
|
||||
ProbableMatch = collections.namedtuple("ProbableMatch", "pkg ratio")
|
||||
|
||||
|
||||
@dataclasses.dataclass()
|
||||
class Analysis:
|
||||
"""
|
||||
A package metadata analysis class. When given the raw syft and inline data, all necessary derivative information
|
||||
needed to do a comparison of package and metadata is performed, allowing callers to interpret the results
|
||||
"""
|
||||
|
||||
# all raw data from the inline scan and syft reports
|
||||
syft_data: Info
|
||||
inline_data: Info
|
||||
|
||||
# all derivative information (derived from the raw data above)
|
||||
overlapping_packages: FrozenSet[Package] = dataclasses.field(init=False)
|
||||
extra_packages: FrozenSet[Package] = dataclasses.field(init=False)
|
||||
missing_packages: FrozenSet[Package] = dataclasses.field(init=False)
|
||||
|
||||
inline_metadata: Set[Tuple[Any, Any]] = dataclasses.field(init=False)
|
||||
missing_metadata: Set[Tuple[Any, Any]] = dataclasses.field(init=False)
|
||||
overlapping_metadata: Set[Tuple[Any, Any]] = dataclasses.field(init=False)
|
||||
|
||||
similar_missing_packages: List[Package] = dataclasses.field(init=False)
|
||||
unmatched_missing_packages: List[Package] = dataclasses.field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.valid():
|
||||
raise RuntimeError("invalid data given")
|
||||
|
||||
# basic sets derived from package information
|
||||
self.overlapping_packages = self.syft_data.packages & self.inline_data.packages
|
||||
self.extra_packages = self.syft_data.packages - self.inline_data.packages
|
||||
self.missing_packages = self.inline_data.packages - self.syft_data.packages
|
||||
|
||||
# basic sets derived from metadata information
|
||||
self.inline_metadata = self._inline_metadata()
|
||||
self.overlapping_metadata = self._overlapping_metadata()
|
||||
self.missing_metadata = self.inline_metadata - self.overlapping_metadata
|
||||
|
||||
# try to account for potential false negatives by pairing extra packages discovered only by syft with missing
|
||||
# packages discovered only by inline scan.
|
||||
(
|
||||
similar_missing_packages,
|
||||
unmatched_missing_packages,
|
||||
) = self._pair_similar_packages(self.extra_packages, self.missing_packages)
|
||||
self.similar_missing_packages = similar_missing_packages
|
||||
self.unmatched_missing_packages = unmatched_missing_packages
|
||||
|
||||
def valid(self) -> bool:
|
||||
# we are purposefully selecting test images that are guaranteed to have packages (this should never happen).
|
||||
# ... if it does, then this analysis is not valid!
|
||||
return bool(self.inline_data.packages)
|
||||
|
||||
def _inline_metadata(self):
|
||||
"""
|
||||
Returns the set of inline scan metadata paired with the corresponding package info.
|
||||
"""
|
||||
inline_metadata_set = set()
|
||||
for package in self.inline_data.packages:
|
||||
metadata = self.inline_data.metadata[package.type][package]
|
||||
inline_metadata_set.add((package, metadata))
|
||||
return inline_metadata_set
|
||||
|
||||
def _overlapping_metadata(self):
|
||||
"""
|
||||
Returns the metadata which has been found similar between both syft and inline scan.
|
||||
"""
|
||||
syft_overlap_metadata_set = set()
|
||||
for package in self.syft_data.packages:
|
||||
metadata = self.syft_data.metadata[package.type][package]
|
||||
# we only want to really count mismatched metadata for packages that are at least found by inline
|
||||
if package in self.inline_data.metadata.get(package.type, []):
|
||||
syft_overlap_metadata_set.add((package, metadata))
|
||||
|
||||
return syft_overlap_metadata_set & self.inline_metadata
|
||||
|
||||
@staticmethod
|
||||
def _pair_similar_packages(extra_packages, missing_packages, similar_threshold=0.7):
|
||||
"""
|
||||
Try to account for potential false negatives by pairing extra packages discovered only by syft with missing
|
||||
packages discovered only by inline scan.
|
||||
"""
|
||||
matches = collections.defaultdict(set)
|
||||
found = {}
|
||||
for s in extra_packages:
|
||||
for i in missing_packages:
|
||||
ratio = difflib.SequenceMatcher(None, s.name, i.name).ratio()
|
||||
if ratio >= similar_threshold:
|
||||
if i in found:
|
||||
# only allow for an inline package to be paired once
|
||||
if ratio < found[i]:
|
||||
continue
|
||||
else:
|
||||
matches[s].discard(i)
|
||||
|
||||
# persist the result
|
||||
found[i] = ratio
|
||||
matches[s].add(i)
|
||||
|
||||
results = []
|
||||
for s, i_set in matches.items():
|
||||
missed = tuple([ProbableMatch(pkg=i, ratio=found[i]) for i in i_set])
|
||||
results.append(SimilarPackages(pkg=s, missed=missed))
|
||||
|
||||
not_found = [i for i in missing_packages if i not in found]
|
||||
|
||||
return sorted(results, key=lambda x: x.pkg), sorted(
|
||||
not_found, key=lambda x: x.name
|
||||
)
|
||||
|
||||
@property
|
||||
def percent_overlapping_packages(self):
|
||||
"""Returns a percentage representing how many packages that were found relative to the number of expected"""
|
||||
return (
|
||||
float(len(self.overlapping_packages))
|
||||
/ float(len(self.inline_data.packages))
|
||||
) * 100.0
|
||||
|
||||
@property
|
||||
def percent_overlapping_metadata(self):
|
||||
"""Returns a percentage representing how many matching metdata that were found relative to the number of expected"""
|
||||
return (
|
||||
float(len(self.overlapping_metadata)) / float(len(self.inline_metadata))
|
||||
) * 100.0
|
||||
|
||||
@property
|
||||
def percent_probable_overlapping_packages(self):
|
||||
"""
|
||||
Returns a percentage representing how many packages that were found relative to the number of expected after
|
||||
considering pairing of missing packages with extra packages in a fuzzy match.
|
||||
"""
|
||||
return (
|
||||
float(len(self.overlapping_packages) + len(self.similar_missing_packages))
|
||||
/ float(len(self.inline_data.packages))
|
||||
) * 100.0
|
||||
|
||||
@property
|
||||
def quality_gate_score(self):
|
||||
"""
|
||||
The result of the analysis in the form of an aggregated percentage; it is up to the caller to use this value
|
||||
and enforce a quality gate.
|
||||
"""
|
||||
return (
|
||||
self.percent_overlapping_packages + self.percent_overlapping_metadata
|
||||
) / 2.0
|
||||
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
import json
|
||||
import collections
|
||||
|
||||
import utils.package
|
||||
import utils.image
|
||||
|
||||
|
||||
class Syft:
|
||||
"""
|
||||
Class for parsing syft output into a set of packages and package metadata.
|
||||
"""
|
||||
report_tmpl = "{image}.json"
|
||||
|
||||
def __init__(self, image, report_dir):
|
||||
self.report_path = os.path.join(
|
||||
report_dir, self.report_tmpl.format(image=utils.image.clean(image))
|
||||
)
|
||||
|
||||
def _enumerate_section(self, section):
|
||||
with open(self.report_path) as json_file:
|
||||
data = json.load(json_file)
|
||||
for entry in data[section]:
|
||||
yield entry
|
||||
|
||||
def packages(self):
|
||||
packages = set()
|
||||
metadata = collections.defaultdict(dict)
|
||||
for entry in self._enumerate_section(section="artifacts"):
|
||||
|
||||
# normalize to inline
|
||||
pkg_type = entry["type"].lower()
|
||||
if pkg_type in ("wheel", "egg", "python"):
|
||||
pkg_type = "python"
|
||||
elif pkg_type in ("deb",):
|
||||
pkg_type = "dpkg"
|
||||
elif pkg_type in ("java-archive",):
|
||||
# normalize to pseudo-inline
|
||||
pkg_type = "java-?ar"
|
||||
elif pkg_type in ("jenkins-plugin",):
|
||||
# normalize to pseudo-inline
|
||||
pkg_type = "java-?pi"
|
||||
elif pkg_type in ("apk",):
|
||||
pkg_type = "apkg"
|
||||
|
||||
pkg = utils.package.Package(
|
||||
name=entry["name"],
|
||||
type=pkg_type,
|
||||
)
|
||||
|
||||
packages.add(pkg)
|
||||
metadata[pkg.type][pkg] = utils.package.Metadata(version=entry["version"])
|
||||
|
||||
return utils.package.Info(packages=frozenset(packages), metadata=metadata)
|
||||
Reference in New Issue
Block a user