Skip to content

Commit

Permalink
feat: add support for Ruby Gems
Browse files Browse the repository at this point in the history
  • Loading branch information
jacek-rzrz committed Nov 22, 2024
1 parent 67b705c commit 930d590
Show file tree
Hide file tree
Showing 20 changed files with 302 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ snyk.api.organization=
# Default: https://api.snyk.io/
#snyk.api.url=https://api.snyk.io/


# Path to an SSL Certificate for Snyk API in PEM format.
#snyk.api.sslCertificatePath=

Expand Down Expand Up @@ -95,3 +96,8 @@ snyk.api.organization=
# Accepts: "true", "false"
# Default: "false"
#snyk.scanner.packageType.pypi=false

# Scan Ruby Gems repositories.
# Accepts: "true", "false"
# Default: "false"
#snyk.scanner.packageType.gems=false
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public enum PluginConfiguration implements Configuration {
SCANNER_PACKAGE_TYPE_MAVEN("snyk.scanner.packageType.maven", "true"),
SCANNER_PACKAGE_TYPE_NPM("snyk.scanner.packageType.npm", "true"),
SCANNER_PACKAGE_TYPE_PYPI("snyk.scanner.packageType.pypi", "false"),
SCANNER_PACKAGE_TYPE_RUBYGEMS("snyk.scanner.packageType.gems", "false"),
TEST_CONTINUOUSLY("snyk.scanner.test.continuously","false"),
TEST_FREQUENCY_HOURS("snyk.scanner.frequency.hours", "168"),
EXTEND_TEST_DEADLINE_HOURS("snyk.scanner.extendTestDeadline.hours", "24");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

public enum Ecosystem {


MAVEN(PluginConfiguration.SCANNER_PACKAGE_TYPE_MAVEN),
NPM(PluginConfiguration.SCANNER_PACKAGE_TYPE_NPM),
PYPI(PluginConfiguration.SCANNER_PACKAGE_TYPE_PYPI),
RUBYGEMS(PluginConfiguration.SCANNER_PACKAGE_TYPE_RUBYGEMS),
;

private static final Logger LOG = LoggerFactory.getLogger(Ecosystem.class);
Expand All @@ -26,14 +26,15 @@ public PluginConfiguration getConfigProperty() {
return configProperty;
}

public static Optional<Ecosystem> fromPackageType(String artifactoryPackageType) {
public static Optional<Ecosystem> match(String artifactoryPackageType, String artifactPath) {
switch (artifactoryPackageType.toLowerCase()) {
case "maven": return Optional.of(MAVEN);
case "npm": return Optional.of(NPM);
case "pypi": return Optional.of(PYPI);
case "gems": return artifactPath.endsWith(".gem") ? Optional.of(RUBYGEMS) : Optional.empty();
}

LOG.error("Unknown package type: {}", artifactoryPackageType);
LOG.info("Unknown package type: {}", artifactoryPackageType);
return Optional.empty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ public Optional<Ecosystem> getFor(RepoPath repoPath) {
return Optional.empty();
}

return Ecosystem.fromPackageType(packageType);
return Ecosystem.match(packageType, repoPath.getPath());
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package io.snyk.plugins.artifactory.exception;

import io.snyk.sdk.api.SnykResult;
import io.snyk.sdk.model.TestResult;

public class SnykAPIFailureException extends RuntimeException {
public SnykAPIFailureException(SnykResult<TestResult> result) {
public SnykAPIFailureException(SnykResult<?> result) {
super("Snyk API request was not successful. (" + result.statusCode + ")");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.snyk.sdk.model.Issue;
import io.snyk.sdk.model.Severity;
import io.snyk.sdk.model.purl.PurlIssue;

import java.util.*;
import java.util.regex.Matcher;
Expand All @@ -22,6 +23,10 @@ private IssueSummary(Map<Severity, Integer> countBySeverity) {
this.countBySeverity = countBySeverity;
}

public static IssueSummary fromPurlIssues(List<PurlIssue> issues) {
return IssueSummary.from(issues.stream().map(i -> i.attribute.severity));
}

public static IssueSummary from(List<? extends Issue> issues) {
return IssueSummary.from(issues.stream().map(i -> i.severity));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public ArtifactCache(Duration testFrequency, Duration extendTestDeadline) {
public Optional<MonitoredArtifact> get(ArtifactProperties properties, Supplier<Optional<MonitoredArtifact>> fetch) {
Optional<MonitoredArtifact> artifact = MonitoredArtifact.read(properties);
if (artifact.isEmpty()) {
LOG.info("Previous Snyk Test result not available - testing {}", properties.getArtifactPath());
LOG.info("Previous Snyk Test result not available for {}", properties.getArtifactPath());
return fetchAndStore(properties, fetch);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ private void validateIssues(IssueSummary summary, Optional<Severity> threshold,
}

LOG.debug("Package has {} with severity {} or higher: {}", issueType, threshold, artifact.getPath());
throw new CancelException(format("Artifact has %s with severity %s or higher: %s. Details: %s",
issueType, threshold, artifact.getPath(), artifact.getTestResult().getDetailsUrl()
throw new CancelException(format("Artifact has %s with %s severity or higher: %s. Details: %s",
issueType, threshold.get(), artifact.getPath(), artifact.getTestResult().getDetailsUrl()
), 403);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,19 @@ public ScannerModule(ConfigurationModule configurationModule, @Nonnull Repositor
}

public Optional<MonitoredArtifact> testArtifact(@Nonnull RepoPath repoPath) {
if(skip(repoPath)) {
LOG.debug("No ecosystem matching for {}, skipping.", repoPath);
return Optional.empty();
}
return runTest(repoPath).map(artifact -> artifact.write(properties(repoPath)));
}

public void filterAccess(@Nonnull RepoPath repoPath) {
if(skip(repoPath)) {
LOG.debug("No ecosystem matching for {}, skipping.", repoPath);
return;
}

resolveArtifact(repoPath)
.ifPresentOrElse(
this::filter,
Expand Down Expand Up @@ -95,4 +104,8 @@ private boolean shouldTestContinuously() {
private Duration durationHoursProperty(PluginConfiguration property, ConfigurationModule configurationModule) {
return Duration.ofHours(Integer.parseInt(configurationModule.getPropertyOrDefault(property)));
}

private boolean skip(RepoPath repoPath) {
return ecosystemResolver.getFor(repoPath).isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.snyk.plugins.artifactory.configuration.ConfigurationModule;
import io.snyk.plugins.artifactory.configuration.PluginConfiguration;
import io.snyk.plugins.artifactory.ecosystem.Ecosystem;
import io.snyk.plugins.artifactory.scanner.rubygems.RubyGemsScanner;
import io.snyk.sdk.api.SnykClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand All @@ -12,6 +13,8 @@
import java.util.Optional;
import java.util.function.Function;

import static io.snyk.plugins.artifactory.configuration.PluginConfiguration.API_ORGANIZATION;

public class ScannerResolver {
private static final Logger LOG = LoggerFactory.getLogger(ScannerResolver.class);
private final Function<PluginConfiguration, String> getConfig;
Expand Down Expand Up @@ -44,9 +47,12 @@ public Optional<PackageScanner> getFor(Ecosystem ecosystem) {
}

public static ScannerResolver setup(ConfigurationModule configurationModule, SnykClient snykClient) {
String orgId = configurationModule.getProperty(API_ORGANIZATION);
return new ScannerResolver(configurationModule::getPropertyOrDefault)
.register(Ecosystem.MAVEN, new MavenScanner(configurationModule, snykClient))
.register(Ecosystem.NPM, new NpmScanner(configurationModule, snykClient))
.register(Ecosystem.PYPI, new PythonScanner(configurationModule, snykClient));
.register(Ecosystem.PYPI, new PythonScanner(configurationModule, snykClient))
.register(Ecosystem.RUBYGEMS, new RubyGemsScanner(snykClient, orgId))
;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

public class SnykDetailsUrl {

static URI create(String ecosystem, String packageName, String version) {
public static URI create(String ecosystem, String packageName, String version) {
return URI.create(
String.format("https://security.snyk.io/package/%s/%s/%s", ecosystem,
URLEncoder.encode(packageName, UTF_8),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import io.snyk.plugins.artifactory.model.IssueSummary;
import io.snyk.plugins.artifactory.model.TestResult;
import io.snyk.sdk.model.purl.PurlIssues;

import java.net.URI;
import java.util.stream.Stream;

public class TestResultConverter {

Expand All @@ -14,4 +16,12 @@ public static TestResult convert(io.snyk.sdk.model.TestResult result) {
URI.create(result.packageDetailsURL)
);
}

public static TestResult convert(PurlIssues issues) {
return new TestResult(
IssueSummary.fromPurlIssues(issues.purlIssues),
IssueSummary.from(Stream.empty()),
URI.create(issues.packageDetailsUrl)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.snyk.plugins.artifactory.scanner.rubygems;

import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RubyGemsPackage {
private final String name;
private final String version;

public RubyGemsPackage(String name, String version) {
this.name = name;
this.version = version;
}

public String getName() {
return name;
}

public String getVersion() {
return version;
}

public static Optional<RubyGemsPackage> parse(String artifactoryPackageName) {
if(artifactoryPackageName == null) {
return Optional.empty();
}
Pattern pattern = Pattern.compile("(.*)-([^-]+)\\.gem", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(artifactoryPackageName);
if(!matcher.matches()) {
return Optional.empty();
}
return Optional.of(new RubyGemsPackage(matcher.group(1), matcher.group(2)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package io.snyk.plugins.artifactory.scanner.rubygems;

import io.snyk.plugins.artifactory.exception.CannotScanException;
import io.snyk.plugins.artifactory.exception.SnykAPIFailureException;
import io.snyk.plugins.artifactory.model.TestResult;
import io.snyk.plugins.artifactory.scanner.PackageScanner;
import io.snyk.plugins.artifactory.scanner.SnykDetailsUrl;
import io.snyk.plugins.artifactory.scanner.TestResultConverter;
import io.snyk.sdk.api.SnykClient;
import io.snyk.sdk.api.SnykResult;
import io.snyk.sdk.model.purl.PurlIssues;
import org.artifactory.fs.FileLayoutInfo;
import org.artifactory.repo.RepoPath;
import org.slf4j.Logger;

import java.net.URLEncoder;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.slf4j.LoggerFactory.getLogger;

public class RubyGemsScanner implements PackageScanner {

private static final Logger LOG = getLogger(RubyGemsScanner.class);
private final SnykClient snykClient;
private final String orgId;

public RubyGemsScanner(SnykClient snykClient, String orgId) {
this.snykClient = snykClient;
this.orgId = orgId;
}

@Override
public TestResult scan(FileLayoutInfo fileLayoutInfo, RepoPath repoPath) {
RubyGemsPackage pckg = RubyGemsPackage.parse(repoPath.getName())
.orElseThrow(() -> new CannotScanException("Unexpected Ruby Gems package name: " + repoPath.getName()));

String purl = "pkg:gem/" + pckg.getName() + "@" + pckg.getVersion();

SnykResult<PurlIssues> result;
try {
LOG.debug("Running Snyk test: {}, name: {}, version: {}", repoPath, pckg.getName(), pckg.getVersion());
result = snykClient.get(PurlIssues.class, request ->
request
.withPath(String.format("rest/orgs/%s/packages/%s/issues",
URLEncoder.encode(orgId, UTF_8),
URLEncoder.encode(purl, UTF_8))
)
.withQueryParam("version", "2024-10-15")
);
} catch (Exception e) {
throw new SnykAPIFailureException(e);
}

PurlIssues testResult = result.get().orElseThrow(() -> new SnykAPIFailureException(result));
testResult.packageDetailsUrl = getModuleDetailsURL(pckg.getName(), pckg.getVersion());

return TestResultConverter.convert(testResult);
}

public static String getModuleDetailsURL(String name, String version) {
return SnykDetailsUrl.create("rubygems", name, version).toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@

import org.junit.jupiter.api.Test;

import static io.snyk.plugins.artifactory.ecosystem.Ecosystem.match;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

class EcosystemTest {

@Test
void ecosystemByPackageType() {
assertThat(Ecosystem.fromPackageType("maven")).contains(Ecosystem.MAVEN);
assertThat(Ecosystem.fromPackageType("npm")).contains(Ecosystem.NPM);
assertThat(Ecosystem.fromPackageType("pypi")).contains(Ecosystem.PYPI);
assertThat(Ecosystem.fromPackageType("nuget")).isEmpty();
assertThat(match("maven", "")).contains(Ecosystem.MAVEN);
assertThat(match("npm", "")).contains(Ecosystem.NPM);
assertThat(match("pypi", "")).contains(Ecosystem.PYPI);
assertThat(match("gems", "gems/rack-protection-4.1.1.gem")).contains(Ecosystem.RUBYGEMS);
assertThat(match("nuget", "")).isEmpty();
}

@Test
void gems_noMatchWhenNoExtension() {
assertThat(match("gems", "gems/rack-protection")).isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.snyk.plugins.artifactory.scanner.rubygems;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class RubyGemsPackageTest {
@Test
void parse() {
assertThat(RubyGemsPackage.parse("mustermann-3.0.3.gem")).isPresent();
assertThat(RubyGemsPackage.parse("mustermann-3.0.3.gem").get().getName()).isEqualTo("mustermann");
assertThat(RubyGemsPackage.parse("mustermann-3.0.3.gem").get().getVersion()).isEqualTo("3.0.3");

assertThat(RubyGemsPackage.parse("rack-protection-4.1.1.gem")).isPresent();
assertThat(RubyGemsPackage.parse("rack-protection-4.1.1.gem").get().getName()).isEqualTo("rack-protection");
assertThat(RubyGemsPackage.parse("rack-protection-4.1.1.gem").get().getVersion()).isEqualTo("4.1.1");
}

@Test
void parse_null() {
assertThat(RubyGemsPackage.parse(null)).isEmpty();
}

@Test
void parse_invalidName() {
assertThat(RubyGemsPackage.parse("mustermann")).isEmpty();
}
}
Loading

0 comments on commit 930d590

Please sign in to comment.