Skip to content

Commit

Permalink
feat: add continuous testing mode
Browse files Browse the repository at this point in the history
Introducing a new lifecycle method `beforeRemoteDownload` which applies
when a package is being fetched from external repositories instead of on
every download.

Adding a new configuration property `snyk.scanner.test.continuously`
which allowes users to switch between applying the plugin on every
download (continuous mode) or just once during fetch from remote
(non-continuous mode).
  • Loading branch information
jacek-rzrz committed Nov 17, 2024
1 parent b3cdaf6 commit 1f6fedf
Show file tree
Hide file tree
Showing 13 changed files with 196 additions and 82 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ After making changes to the plugin, repeat `mvn install` and extract the jar fil

```shell
unzip -p distribution/target/artifactory-snyk-security-plugin-LOCAL-SNAPSHOT.zip plugins/lib/artifactory-snyk-security-core.jar > distribution/docker/etc/artifactory/plugins/lib/artifactory-snyk-security-core.jar
unzip -p distribution/target/artifactory-snyk-security-plugin-LOCAL-SNAPSHOT.zip plugins/snykSecurityPlugin.groovy > distribution/docker/etc/artifactory/plugins/snykSecurityPlugin.groovy
```

## Inspecting plugin logs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ executions {
}

download {

afterRemoteDownload { Request request, RepoPath repoPath ->
try {
snykPlugin.handleAfterRemoteDownloadEvent(repoPath)
} catch (Exception e) {
log.error("An exception occurred during afterRemoteDownload, re-throwing it for Artifactory to handle. Message was: ${e.message}")
throw e
}
}

beforeDownload { Request request, RepoPath repoPath ->
try {
snykPlugin.handleBeforeDownloadEvent(repoPath)
Expand All @@ -33,6 +43,7 @@ download {
throw e
}
}

}

storage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,21 @@ snyk.api.organization=
# Scanner Configuration
# =====================

# Scan result expiry. When the most recent scan was made within this time frame,
# Decides whether the plugin should periodically refresh vulnerability data from Snyk
# or filter access according to results obtained while the package was first requested.
# Without the continuous mode, new vulnerabilities aren't reported for a package that has already been
# allowed through the gatekeeper.
# Accepts: "true", "false"
# Default: "false"
#snyk.scanner.test.continuously=false

# Scan result expiry (continuous mode only). When the most recent scan was made within this time frame,
# filtering respects the previous result. Beyond that time, a new Snyk Test request is made.
# When this property is set to 0, the plugin triggers a test each time an artifact is accessed.
# Default: 168 (1 week)
#snyk.scanner.frequency.hours=168

# How much to extend the scan result expiry when a Snyk Test request fails.
# How much to extend the scan result expiry when a Snyk Test request fails (continuous mode only).
# In case there is a Snyk request error when the next test is due,
# this parameter allows the plugin to use the previous test result when deciding whether to block access.
# Beyond this extended deadline, the result of filtering will depend on the snyk.scanner.block-on-api-failure param.
Expand Down
42 changes: 36 additions & 6 deletions core/src/main/java/io/snyk/plugins/artifactory/SnykPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,23 +88,42 @@ public void handleAfterPropertyCreateEvent(User user, ItemInfo itemInfo, String
}

/**
* Scans an artifact for issues (vulnerability or license).
* Invoked once when an artifact is first fetched from an external repository.
* Runs Snyk test and persists the result in properties.
* <p>
* Extension point: {@code download.afterRemoteDownload}.
*/
public void handleAfterRemoteDownloadEvent(RepoPath repoPath) {
LOG.debug("Handle 'afterRemoteDownload' event for: {}", repoPath);

try {
scannerModule.testArtifact(repoPath);
} catch (CannotScanException e) {
LOG.debug("Artifact cannot be scanned. {} {}", e.getMessage(), repoPath);
} catch(SnykAPIFailureException e) {
String causeMessage = getCauseMessage(e);
String message = format("Snyk test failed. %s %s", causeMessage, repoPath);
LOG.error(message);
}
}

/**
* Filters access based on Snyk properties stored on the artifact.
* When in continuous mode, may run an extra Snyk test to refresh the results.
* <p>
* Extension point: {@code download.beforeDownload}.
*/
public void handleBeforeDownloadEvent(RepoPath repoPath) {
LOG.debug("Handle 'beforeDownload' event for: {}", repoPath);

try {
scannerModule.scanArtifact(repoPath);
scannerModule.filterAccess(repoPath);
} catch (CannotScanException e) {
LOG.debug("Artifact cannot be scanned. {} {}", e.getMessage(), repoPath);
} catch (SnykAPIFailureException e) {
final String blockOnApiFailurePropertyKey = SCANNER_BLOCK_ON_API_FAILURE.propertyKey();
final String blockOnApiFailure = configurationModule.getPropertyOrDefault(SCANNER_BLOCK_ON_API_FAILURE);
final String causeMessage = Optional.ofNullable(e.getCause())
.map(Throwable::getMessage)
.map(m -> e.getMessage() + " " + m)
.orElseGet(e::getMessage);
final String causeMessage = getCauseMessage(e);

String message = format("Artifact scan failed due to an API error on Snyk's side. %s %s", causeMessage, repoPath);
LOG.debug(message);
Expand All @@ -115,6 +134,13 @@ public void handleBeforeDownloadEvent(RepoPath repoPath) {
}
}

private String getCauseMessage(Throwable e) {
return Optional.ofNullable(e.getCause())
.map(Throwable::getMessage)
.map(m -> e.getMessage() + " " + m)
.orElseGet(e::getMessage);
}

private void validateConfiguration() {
try {
configurationModule.validate();
Expand All @@ -131,6 +157,10 @@ private void validateConfiguration() {
.forEach(LOG::debug);
}

private boolean shouldTestContinuously() {
return configurationModule.getPropertyOrDefault(TEST_CONTINUOUSLY).equals("true");
}

private SnykClient createSnykClient(@Nonnull ConfigurationModule configurationModule, String pluginVersion) throws Exception {
final String token = configurationModule.getPropertyOrDefault(API_TOKEN);
String baseUrl = configurationModule.getPropertyOrDefault(API_URL);
Expand Down
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"),
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 @@ -49,8 +49,8 @@ public ZonedDateTime getTimestamp() {
public void write(ArtifactProperties properties) {
LOG.info("Writing Snyk properties for package {}", detailsUrl);
properties.set(TEST_TIMESTAMP, timestamp.toString());
properties.set(ISSUE_VULNERABILITIES, getVulnSummary().toString());
properties.set(ISSUE_LICENSES, getLicenseSummary().toString());
properties.set(ISSUE_VULNERABILITIES, vulnSummary.toString());
properties.set(ISSUE_LICENSES, licenseSummary.toString());
properties.set(ISSUE_URL, detailsUrl.toString());
properties.set(ISSUE_URL_PLAINTEXT, " " + detailsUrl);
}
Expand All @@ -63,7 +63,7 @@ public static Optional<TestResult> read(ArtifactProperties properties) {
.map(String::trim)
.map(URI::create);

if(timestamp.isEmpty() || vulns.isEmpty() || licenses.isEmpty() || detailsUrl.isEmpty()) {
if (timestamp.isEmpty() || vulns.isEmpty() || licenses.isEmpty() || detailsUrl.isEmpty()) {
return Optional.empty();
}
return Optional.of(new TestResult(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import static org.slf4j.LoggerFactory.getLogger;

public class ArtifactCache {
public class ArtifactCache implements ArtifactResolver {

private static final Logger LOG = getLogger(ArtifactCache.class);

Expand All @@ -24,7 +24,8 @@ public ArtifactCache(Duration testFrequency, Duration extendTestDeadline) {
this.extendTestDeadline = extendTestDeadline;
}

public MonitoredArtifact getArtifact(ArtifactProperties properties, Supplier<MonitoredArtifact> fetch) {
@Override
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());
Expand All @@ -33,7 +34,7 @@ public MonitoredArtifact getArtifact(ArtifactProperties properties, Supplier<Mon

if (withinTtl(artifact.get())) {
LOG.info("Using recent Snyk Test result until {} - {}", nextTestDue(artifact.get()), properties.getArtifactPath());
return artifact.get();
return artifact;
}

LOG.info("Snyk Test due for {}", properties.getArtifactPath());
Expand All @@ -43,15 +44,15 @@ public MonitoredArtifact getArtifact(ArtifactProperties properties, Supplier<Mon
return fetchAndStore(properties, fetch);
} catch (RuntimeException e) {
LOG.info("Snyk Test was due but failed for package {}. Using previous Test result until {}. Error was {}", properties.getArtifactPath(), nextTestHardDeadline(artifact.get()), e.getMessage());
return artifact.get();
return artifact;
}
}

return fetchAndStore(properties, fetch);
}

private MonitoredArtifact fetchAndStore(ArtifactProperties properties, Supplier<MonitoredArtifact> fetch) {
return fetch.get().write(properties);
private Optional<MonitoredArtifact> fetchAndStore(ArtifactProperties properties, Supplier<Optional<MonitoredArtifact>> fetch) {
return fetch.get().map(artifact -> artifact.write(properties));
}

private boolean withinTtl(MonitoredArtifact artifact) {
Expand All @@ -69,5 +70,4 @@ private ZonedDateTime nextTestDue(MonitoredArtifact artifact) {
private ZonedDateTime nextTestHardDeadline(MonitoredArtifact artifact) {
return nextTestDue(artifact).plus(extendTestDeadline);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.snyk.plugins.artifactory.scanner;

import io.snyk.plugins.artifactory.configuration.properties.ArtifactProperties;
import io.snyk.plugins.artifactory.model.MonitoredArtifact;

import java.util.Optional;
import java.util.function.Supplier;

public interface ArtifactResolver {

Optional<MonitoredArtifact> get(ArtifactProperties properties, Supplier<Optional<MonitoredArtifact>> fetch);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.snyk.plugins.artifactory.scanner;

import io.snyk.plugins.artifactory.configuration.properties.ArtifactProperties;
import io.snyk.plugins.artifactory.model.MonitoredArtifact;

import java.util.Optional;
import java.util.function.Supplier;

public class ReadOnlyArtifactResolver implements ArtifactResolver {

@Override
public Optional<MonitoredArtifact> get(ArtifactProperties properties, Supplier<Optional<MonitoredArtifact>> fetch) {
return MonitoredArtifact.read(properties);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@
import io.snyk.plugins.artifactory.configuration.properties.ArtifactProperties;
import io.snyk.plugins.artifactory.configuration.properties.RepositoryArtifactProperties;
import io.snyk.plugins.artifactory.exception.CannotScanException;
import io.snyk.plugins.artifactory.model.*;
import io.snyk.plugins.artifactory.model.Ignores;
import io.snyk.plugins.artifactory.model.MonitoredArtifact;
import io.snyk.plugins.artifactory.model.TestResult;
import io.snyk.plugins.artifactory.model.ValidationSettings;
import io.snyk.sdk.api.v1.SnykClient;
import org.artifactory.fs.FileLayoutInfo;
import org.artifactory.repo.RepoPath;
import org.artifactory.repo.Repositories;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.time.Duration;
Expand All @@ -20,13 +25,13 @@
import static java.util.Objects.requireNonNull;

public class ScannerModule {

private static final Logger LOG = LoggerFactory.getLogger(ScannerModule.class);
private final ConfigurationModule configurationModule;
private final Repositories repositories;
private final MavenScanner mavenScanner;
private final NpmScanner npmScanner;
private final PythonScanner pythonScanner;
private final ArtifactCache cache;
private final ArtifactResolver artifactResolver;

public ScannerModule(@Nonnull ConfigurationModule configurationModule, @Nonnull Repositories repositories, @Nonnull SnykClient snykClient) {
this.configurationModule = requireNonNull(configurationModule);
Expand All @@ -36,23 +41,37 @@ public ScannerModule(@Nonnull ConfigurationModule configurationModule, @Nonnull
npmScanner = new NpmScanner(configurationModule, snykClient);
pythonScanner = new PythonScanner(configurationModule, snykClient);

cache = new ArtifactCache(
artifactResolver = shouldTestContinuously() ? new ArtifactCache(
durationHoursProperty(PluginConfiguration.TEST_FREQUENCY_HOURS, configurationModule),
durationHoursProperty(PluginConfiguration.EXTEND_TEST_DEADLINE_HOURS, configurationModule)
);
) : new ReadOnlyArtifactResolver();
}

public Optional<MonitoredArtifact> testArtifact(@Nonnull RepoPath repoPath) {
return runTest(repoPath).map(artifact -> artifact.write(properties(repoPath)));
}

public void scanArtifact(@Nonnull RepoPath repoPath) {
filter(resolveArtifact(repoPath));
public void filterAccess(@Nonnull RepoPath repoPath) {
resolveArtifact(repoPath)
.ifPresentOrElse(
this::filter,
() -> LOG.info("No vulnerability info found for {}", repoPath)
);
}

public MonitoredArtifact resolveArtifact(RepoPath repoPath) {
ArtifactProperties properties = new RepositoryArtifactProperties(repoPath, repositories);
return cache.getArtifact(properties, () -> testArtifact(repoPath));
private Optional<MonitoredArtifact> resolveArtifact(RepoPath repoPath) {
return artifactResolver.get(properties(repoPath), () -> runTest(repoPath));
}

private @NotNull MonitoredArtifact testArtifact(RepoPath repoPath) {
PackageScanner scanner = getScannerForPackageType(repoPath);
private ArtifactProperties properties(RepoPath repoPath) {
return new RepositoryArtifactProperties(repoPath, repositories);
}

private @NotNull Optional<MonitoredArtifact> runTest(RepoPath repoPath) {
return getScannerForPackageType(repoPath).map(scanner -> runTestWith(scanner, repoPath));
}

private MonitoredArtifact runTestWith(PackageScanner scanner, RepoPath repoPath) {
FileLayoutInfo fileLayoutInfo = repositories.getLayoutInfo(repoPath);
TestResult testResult = scanner.scan(fileLayoutInfo, repoPath);
return toMonitoredArtifact(testResult, repoPath);
Expand All @@ -69,14 +88,17 @@ private void filter(MonitoredArtifact artifact) {
return new MonitoredArtifact(repoPath.toString(), testResult, ignores);
}

protected PackageScanner getScannerForPackageType(RepoPath repoPath) {
protected Optional<PackageScanner> getScannerForPackageType(RepoPath repoPath) {
String path = Optional.ofNullable(repoPath.getPath())
.orElseThrow(() -> new CannotScanException("Path not provided."));
return getScannerForPackageType(path);
}

protected PackageScanner getScannerForPackageType(String path) {
Ecosystem ecosystem = Ecosystem.fromPackagePath(path).orElseThrow(() -> new CannotScanException("Artifact is not supported."));
protected Optional<PackageScanner> getScannerForPackageType(String path) {
return Ecosystem.fromPackagePath(path).map(this::getScanner);
}

private PackageScanner getScanner(Ecosystem ecosystem) {
if (!configurationModule.getPropertyOrDefault(ecosystem.getConfigProperty()).equals("true")) {
throw new CannotScanException(format("Plugin Property \"%s\" is not \"true\".", ecosystem.getConfigProperty().propertyKey()));
}
Expand All @@ -93,6 +115,10 @@ protected PackageScanner getScannerForPackageType(String path) {
}
}

private boolean shouldTestContinuously() {
return configurationModule.getPropertyOrDefault(PluginConfiguration.TEST_CONTINUOUSLY).equals("true");
}

private Duration durationHoursProperty(PluginConfiguration property, ConfigurationModule configurationModule) {
return Duration.ofHours(Integer.parseInt(configurationModule.getPropertyOrDefault(property)));
}
Expand Down
Loading

0 comments on commit 1f6fedf

Please sign in to comment.