Skip to content

Commit

Permalink
Merge pull request #269 from jenkinsci/feature/support-policy-violations
Browse files Browse the repository at this point in the history
Support policy violations
  • Loading branch information
sephiroth-j authored Aug 27, 2024
2 parents 98aca3e + 8ed4fe2 commit 55aec7e
Show file tree
Hide file tree
Showing 64 changed files with 1,854 additions and 80 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### ⭐ New Features
- Support for specifying the parent project using its name and version as an alternative to its ID ([#261](https://github.com/jenkinsci/dependency-track-plugin/issues/261))
- Include artifact name in Publishing Logline ([#264](https://github.com/jenkinsci/dependency-track-plugin/issues/264))
- Support for Policy Violations ([#130](https://github.com/jenkinsci/dependency-track-plugin/issues/130))

### 🐞 Bugs Fixed

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ Asynchronous publishing simply uploads the SBOM to Dependency-Track and the job

![job trend](docs/images/jenkins-job-trend.png)

![findings](docs/images/jenkins-job-findings.png)
![build summary](docs/images/jenkins-build-summary.png)
![findings](docs/images/jenkins-build-findings.png) ![policy violations](docs/images/jenkins-build-policy-violations.png)

## Global Configuration
To setup, navigate to Jenkins > System Configuration and complete the Dependency-Track section.

![global configuration](docs/images/jenkins-global-odt.png)
![global configuration](docs/images/jenkins-global-settings.png)

**Dependency-Track Backend URL**: URL to the Backend of your Dependency-Track instance.

Expand All @@ -50,7 +51,8 @@ BOM_UPLOAD | :ballot_box_with_check: | needed for BOM upload
VIEW_PORTFOLIO | :ballot_box_with_check: | needed to retrieve list of projects
VULNERABILITY_ANALYSIS | :ballot_box_with_check: | needed to perform dependency analysis
PROJECT_CREATION_UPLOAD | :grey_question: | needed to create non-existing projects during BOM upload
VIEW_VULNERABILITY | :grey_question: | needed in synchronous publishing mode to retrieve analysis results
VIEW_VULNERABILITY | :grey_question: | needed in synchronous publishing mode to retrieve results for vulnerabilities
VIEW_POLICY_VIOLATION | :grey_question: | needed in synchronous publishing mode to retrieve results for policy violations
PORTFOLIO_MANAGEMENT | :grey_question: | needed for updating project properties such as tags

## Job Configuration
Expand All @@ -66,7 +68,7 @@ Once configured with a valid URL and API key, simply configure a job to publish

**Artifact:** Specifies the file to upload. Paths are relative from the Jenkins workspace. The use of environment variables in the form `${VARIABLE}` is supported here.

**Enable synchronous publishing mode**: Uploads a SBOM to Dependency-Track and waits for Dependency-Track to process and return results. The results returned are identical to the auditable findings but exclude findings that have previously been suppressed. Analysis decisions and vulnerability details are included in the response. Synchronous mode is possible with Dependency-Track v3.3.1 and higher. The provided API key requires the `VIEW_VULNERABILITY` permission to use this feature with Dependency-Track v4.4 and newer!
**Enable synchronous publishing mode**: Uploads a SBOM to Dependency-Track and waits for Dependency-Track to process and return results. The results returned are identical to the auditable findings but exclude findings that have previously been suppressed. Analysis decisions and vulnerability details are included in the response. Synchronous mode is possible with Dependency-Track v3.3.1 and higher. The provided API key requires the `VIEW_VULNERABILITY` permission to use this feature with Dependency-Track v4.4 and newer! If the provided API key has the permission `VIEW_POLICY_VIOLATION`, then the results of policy violations are returned as well.

**Update project properties**: Allows updating of some project properties after uploading the BOM. The provided API key requires the `PORTFOLIO_MANAGEMENT` permission to use this feature! These properties are:
- tags
Expand All @@ -87,6 +89,10 @@ When synchronous mode is enabled, thresholds can be defined which can optionally

**New Findings:** Sets the threshold for the number of new critical, high, medium, low or unassigned severity findings allowed. If the number of new findings equals or is greater than the previous builds finding for any one of the severities, the job status will be changed to UNSTABLE or FAILURE. The previous build is the one that is successful and has an analysis result of Dependency-Track, which does not necessarily have to be the immediately previous build.

### Policy Violations

If synchronous mode is enabled, it is possible to set the job to the UNSTABLE or FAILURE state depending on the state of the policy violation. Policy violations are evaluated after the threshold values for vulnerability findings.

## Examples
### Declarative Pipeline

Expand Down
Binary file added docs/images/jenkins-build-findings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/jenkins-build-policy-violations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/jenkins-build-summary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/images/jenkins-global-odt.png
Binary file not shown.
Binary file added docs/images/jenkins-global-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/images/jenkins-job-findings.png
Binary file not shown.
Binary file modified docs/images/jenkins-job-publish.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/jenkins-job-thresholds.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/jenkins-job-trend.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions src/main/java/org/jenkinsci/plugins/DependencyTrack/ApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import org.jenkinsci.plugins.DependencyTrack.model.Project;
import org.jenkinsci.plugins.DependencyTrack.model.Team;
import org.jenkinsci.plugins.DependencyTrack.model.UploadResult;
import org.jenkinsci.plugins.DependencyTrack.model.Violation;
import org.springframework.http.HttpStatus;
import org.springframework.retry.RetryPolicy;
import org.springframework.retry.backoff.UniformRandomBackOffPolicy;
Expand All @@ -62,6 +63,7 @@ public class ApiClient {
private static final String API_URL = "/api/v1";
static final String API_KEY_HEADER = "X-Api-Key";
static final String PROJECT_FINDINGS_URL = API_URL + "/finding/project";
static final String PROJECT_VIOLATIONS_URL = API_URL + "/violation/project";
static final String BOM_URL = API_URL + "/bom";
static final String BOM_TOKEN_URL = BOM_URL + "/token";
static final String PROJECT_URL = API_URL + "/project";
Expand Down Expand Up @@ -266,6 +268,28 @@ public List<Finding> getFindings(@NonNull final String projectUuid) throws ApiCl
});
}

@NonNull
@SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
public List<Violation> getViolations(@NonNull final String projectUuid) throws ApiClientException {
final var uri = UriComponentsBuilder.fromUriString(PROJECT_VIOLATIONS_URL).pathSegment("{uuid}").build(projectUuid);
final var request = createRequest(uri);
return executeWithRetry(() -> {
try (var response = httpClient.newCall(request).execute()) {
final var body = response.body().string();
if (!response.isSuccessful()) {
final int status = response.code();
logger.log(body);
throw new ApiClientException(Messages.ApiClient_Error_RetrieveViolations(status, HttpStatus.valueOf(status).getReasonPhrase()));
}
return ViolationParser.parse(body);
} catch (ApiClientException e) {
throw e;
} catch (IOException e) {
throw new ApiClientException(Messages.ApiClient_Error_Connection(StringUtils.EMPTY, StringUtils.EMPTY), e);
}
});
}

@NonNull
@SuppressFBWarnings("NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE")
public UploadResult upload(@Nullable final String projectId, @Nullable final String projectName, @Nullable final String projectVersion, @NonNull final FilePath artifact,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2024 OWASP.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jenkinsci.plugins.DependencyTrack;

import lombok.experimental.UtilityClass;
import net.sf.json.JSONObject;
import org.jenkinsci.plugins.DependencyTrack.model.Component;

/**
*
* @author Ronny "Sephiroth" Perinke <[email protected]>
*/
@UtilityClass
class ComponentParser extends ModelParser {

Component parseComponent(JSONObject json) {
final String uuid = getKeyOrNull(json, "uuid");
final String name = getKeyOrNull(json, "name");
final String group = getKeyOrNull(json, "group");
final String version = getKeyOrNull(json, "version");
final String purl = getKeyOrNull(json, "purl");
return new Component(uuid, name, group, version, purl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import hudson.tasks.Recorder;
import hudson.util.Secret;
import java.util.Optional;
import jenkins.model.RunAction2;
import jenkins.tasks.SimpleBuildStep;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
Expand All @@ -43,11 +44,15 @@
import org.jenkinsci.plugins.DependencyTrack.model.SeverityDistribution;
import org.jenkinsci.plugins.DependencyTrack.model.Thresholds;
import org.jenkinsci.plugins.DependencyTrack.model.UploadResult;
import org.jenkinsci.plugins.DependencyTrack.model.Violation;
import org.jenkinsci.plugins.DependencyTrack.model.ViolationState;
import org.jenkinsci.plugins.DependencyTrack.model.Vulnerability;
import org.jenkinsci.plugins.plaincredentials.StringCredentials;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;

import static org.jenkinsci.plugins.DependencyTrack.model.Permissions.VIEW_POLICY_VIOLATION;

@Getter
@Setter(onMethod_ = {@DataBoundSetter})
@EqualsAndHashCode(callSuper = true)
Expand Down Expand Up @@ -235,6 +240,18 @@ public final class DependencyTrackPublisher extends Recorder implements SimpleBu
*/
private Integer failedNewUnassigned;

/**
* mark job as UNSTABLE if there is at least one policy violation of severity
* warning
*/
private boolean warnOnViolationWarn;

/**
* mark job as FAILED if there is at least one policy violation of severity
* fail
*/
private boolean failOnViolationFail;

@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
private transient ApiClientFactory clientFactory;
Expand Down Expand Up @@ -324,17 +341,22 @@ public void perform(@NonNull final Run<?, ?> run, @NonNull final FilePath worksp

final var thresholds = getThresholds();
if (synchronous && StringUtils.isNotBlank(uploadResult.getToken())) {
final var severityDistribution = publishAnalysisResult(logger, apiClient, uploadResult.getToken(), run, effectiveProjectName, effectiveProjectVersion);
final var resultActions = publishAnalysisResult(logger, apiClient, uploadResult.getToken(), run, effectiveProjectName, effectiveProjectVersion);
if (thresholds.hasValues()) {
evaluateRiskGates(run, logger, severityDistribution, thresholds);
final var resultAction = resultActions.get(0).map(ResultAction.class::cast).get();
evaluateRiskGates(run, logger, resultAction.getSeverityDistribution(), thresholds);
}
if (resultActions.get(1).isPresent()) {
final var violationsAction = resultActions.get(1).map(ViolationsRunAction.class::cast).get();
evaluateViolations(run, logger, violationsAction.getViolations());
}
}
if (!synchronous && thresholds.hasValues()) {
logger.log(Messages.Builder_Threshold_NoSync());
}
}

private SeverityDistribution publishAnalysisResult(final ConsoleLogger logger, final ApiClient apiClient, final String token, final Run<?, ?> build, final String effectiveProjectName, final String effectiveProjectVersion) throws InterruptedException, ApiClientException, AbortException {
private List<Optional<RunAction2>> publishAnalysisResult(final ConsoleLogger logger, final ApiClient apiClient, final String token, final Run<?, ?> build, final String effectiveProjectName, final String effectiveProjectVersion) throws InterruptedException, ApiClientException, AbortException {
final long timeout = System.currentTimeMillis() + (60000L * getEffectivePollingTimeout());
final long interval = 1000L * getEffectivePollingInterval();
logger.log(Messages.Builder_Polling());
Expand All @@ -352,18 +374,33 @@ private SeverityDistribution publishAnalysisResult(final ConsoleLogger logger, f
final List<Finding> findings = apiClient.getFindings(effectiveProjectId);
final SeverityDistribution severityDistribution = new SeverityDistribution(build.getNumber());
findings.stream().map(Finding::getVulnerability).map(Vulnerability::getSeverity).forEach(severityDistribution::add);
final ResultAction projectAction = new ResultAction(findings, severityDistribution);
projectAction.setDependencyTrackUrl(getEffectiveFrontendUrl());
projectAction.setProjectId(effectiveProjectId);
build.addOrReplaceAction(projectAction);
final var findingsAction = new ResultAction(findings, severityDistribution);
findingsAction.setDependencyTrackUrl(getEffectiveFrontendUrl());
findingsAction.setProjectId(effectiveProjectId);
build.addOrReplaceAction(findingsAction);

final var team = apiClient.getTeamPermissions();
ViolationsRunAction violationsAction = null;
// for compatibility reasons: the permission may not be present so we check if it is. otherwise an exception would be thrown.
if (team.getPermissions().contains(VIEW_POLICY_VIOLATION.toString())) {
logger.log(Messages.Builder_Violations_Processing());
final var violations = apiClient.getViolations(effectiveProjectId);
violationsAction = new ViolationsRunAction(violations);
violationsAction.setDependencyTrackUrl(getEffectiveFrontendUrl());
violationsAction.setProjectId(effectiveProjectId);
build.addOrReplaceAction(violationsAction);
} else {
logger.log(Messages.Builder_Violations_Skipped(VIEW_POLICY_VIOLATION, team.getName()));
}

// update ResultLinkAction with one that surely contains a projectId
final ResultLinkAction linkAction = new ResultLinkAction(getEffectiveFrontendUrl(), effectiveProjectId);
linkAction.setProjectName(effectiveProjectName);
linkAction.setProjectVersion(effectiveProjectVersion);
build.addOrReplaceAction(linkAction);

return severityDistribution;
// replace with record when using Java 17
return List.of(Optional.of(findingsAction), Optional.ofNullable(violationsAction));
}

private void evaluateRiskGates(final Run<?, ?> build, final ConsoleLogger logger, final SeverityDistribution currentDistribution, final Thresholds thresholds) throws AbortException {
Expand All @@ -390,6 +427,16 @@ private void evaluateRiskGates(final Run<?, ?> build, final ConsoleLogger logger
}
}

private void evaluateViolations(final Run<?, ?> build, final ConsoleLogger logger, final List<Violation> violations) throws AbortException {
if (warnOnViolationWarn && violations.stream().anyMatch(violation -> violation.getState() == ViolationState.WARN)) {

Check warning on line 431 in src/main/java/org/jenkinsci/plugins/DependencyTrack/DependencyTrackPublisher.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 431 is only partially covered, 2 branches are missing
logger.log(Messages.Builder_Violations_Exceed());
build.setResult(Result.UNSTABLE);
}
if (failOnViolationFail && violations.stream().anyMatch(violation -> violation.getState() == ViolationState.FAIL)) {

Check warning on line 435 in src/main/java/org/jenkinsci/plugins/DependencyTrack/DependencyTrackPublisher.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 435 is only partially covered, 2 branches are missing
throw new AbortException(Messages.Builder_Violations_Exceed());
}
}

/**
*
* @return A Descriptor Implementation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,10 @@ private FormValidation checkTeamPermissions(final ApiClient apiClient, final Str
}
if (synchronous) {
requiredPermissions.add(VIEW_VULNERABILITY.toString());
requiredPermissions.add(VIEW_POLICY_VIOLATION.toString());
} else {
optionalPermissions.add(VIEW_VULNERABILITY.toString());
optionalPermissions.add(VIEW_POLICY_VIOLATION.toString());
}
if (projectProperties) {
requiredPermissions.add(PORTFOLIO_MANAGEMENT.toString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,14 @@
import net.sf.json.JSONArray;
import net.sf.json.JSONNull;
import net.sf.json.JSONObject;
import org.apache.commons.lang.StringUtils;
import org.jenkinsci.plugins.DependencyTrack.model.Analysis;
import org.jenkinsci.plugins.DependencyTrack.model.Component;
import org.jenkinsci.plugins.DependencyTrack.model.Finding;
import org.jenkinsci.plugins.DependencyTrack.model.Severity;
import org.jenkinsci.plugins.DependencyTrack.model.Vulnerability;

@UtilityClass
class FindingParser {
class FindingParser extends ModelParser {

List<Finding> parse(final String jsonResponse) {
final JSONArray jsonArray = JSONArray.fromObject(jsonResponse);
Expand All @@ -49,22 +48,13 @@ List<Finding> parse(final String jsonResponse) {
}

private Finding parseFinding(JSONObject json) {
final Component component = parseComponent(json.getJSONObject("component"));
final Component component = ComponentParser.parseComponent(json.getJSONObject("component"));
final Vulnerability vulnerability = parseVulnerability(json.getJSONObject("vulnerability"));
final Analysis analysis = parseAnalysis(json.optJSONObject("analysis"));
final String matrix = getKeyOrNull(json, "matrix");
return new Finding(component, vulnerability, analysis, matrix);
}

private Component parseComponent(JSONObject json) {
final String uuid = getKeyOrNull(json, "uuid");
final String name = getKeyOrNull(json, "name");
final String group = getKeyOrNull(json, "group");
final String version = getKeyOrNull(json, "version");
final String purl = getKeyOrNull(json, "purl");
return new Component(uuid, name, group, version, purl);
}

private Vulnerability parseVulnerability(JSONObject json) {
final String uuid = getKeyOrNull(json, "uuid");
final String source = getKeyOrNull(json, "source");
Expand All @@ -73,7 +63,7 @@ private Vulnerability parseVulnerability(JSONObject json) {
final String subtitle = getKeyOrNull(json, "subtitle");
final String description = getKeyOrNull(json, "description");
final String recommendation = getKeyOrNull(json, "recommendation");
final Severity severity = json.has("severity") ? Severity.valueOf(json.getString("severity")) : null;
final Severity severity = getEnum(json, "severity", Severity.class);
final Integer severityRank = json.optInt("severityRank");
final var cwe = Optional.ofNullable(json.optJSONArray("cwes")).map(a -> a.optJSONObject(0)).filter(Predicate.not(JSONNull.class::isInstance));
final Integer cweId = cwe.map(o -> o.optInt("cweId")).orElse(null);
Expand All @@ -100,14 +90,4 @@ private List<String> parseAliases(JSONObject json, String vulnId) {
.collect(Collectors.toList())
: null;
}

private String getKeyOrNull(JSONObject json, String key) {
// key can be null. but it may also be JSONNull!
// optString and getString do not check if v is JSONNull. instead they return just v.toString() which will be "null"!
return Optional.ofNullable(json.opt(key))
.filter(Predicate.not(JSONNull.class::isInstance))
.map(Object::toString)
.map(StringUtils::trimToNull)
.orElse(null);
}
}
Loading

0 comments on commit 55aec7e

Please sign in to comment.