Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce AddSecurityScanWorkflow Recipe #36

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* Copyright 2023 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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.openrewrite.jenkins.github;

import lombok.EqualsAndHashCode;
import lombok.Value;
import org.intellij.lang.annotations.Language;
import org.openrewrite.*;
import org.openrewrite.internal.lang.Nullable;
import org.openrewrite.yaml.ChangePropertyValue;
import org.openrewrite.yaml.JsonPathMatcher;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.YamlParser;
import org.openrewrite.yaml.tree.Yaml;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;

@Value
@EqualsAndHashCode(callSuper = true)
public class AddSecurityScanWorkflow extends ScanningRecipe<AtomicBoolean> {
private static final String FILE_PATH = ".github/workflows/jenkins-security-scan.yml";
private static final String DEFAULT_WORKFLOW_PATH = "/org/openrewrite/jenkins/github/jenkins-security-scan.yml";
private static final JsonPathMatcher BRANCHES_KEY = new JsonPathMatcher("$.on.push.branches");
private static final String JAVA_VERSION_KEY = "jobs.security-scan.with.java-version";
private static final String BUILD_TOOL_KEY = "jobs.security-scan.with.java-cache";

@Option(displayName = "Branches",
description = "Run workflow on push to these branches.",
example = "main",
required = false)
@Nullable
List<String> branches;

@Option(displayName = "Java Version",
description = "Version of Java to set for build.",
example = "11",
required = false)
@Nullable
Integer javaVersion;
Comment on lines +61 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what you might have come to expect by now: we also have a JavaVersion marker that you can use to determine the Java version. You might want to account for projects that use a different version for src/main versus src/test, but other than that it should be convenient not to have to pass this parameter I think.


@Option(displayName = "Build Tool",
description = "Set up dependency cache.",
example = "maven",
valid = {"maven", "gradle"},
required = false)
@Nullable
String buildTool;
Comment on lines +68 to +74
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the GitProvenance marker, we also have markers for the BuildTool and LstProvenance. Both of those contain a Type, such that you likely don't have to pass in an argument buildTool here, as that is already determined.


@Override
public String getDisplayName() {
return "Add Jenkins Security Scan workflow";
}

@Override
public String getDescription() {
return "Adds the Jenkins Security Scan GitHub Actions workflow. " +
"See [docs](https://www.jenkins.io/doc/developer/security/scan/) for details.";
}

@Override
public AtomicBoolean getInitialValue(ExecutionContext ctx) {
return new AtomicBoolean();
}

@Override
public TreeVisitor<?, ExecutionContext> getScanner(AtomicBoolean found) {
Path path = Paths.get(FILE_PATH);
return new TreeVisitor<Tree, ExecutionContext>() {
@Override
public Tree visit(@Nullable Tree tree, ExecutionContext executionContext, Cursor parent) {
SourceFile sourceFile = (SourceFile) requireNonNull(tree);
if (path.toString().equals(sourceFile.getSourcePath().toString())) {
found.set(true);
}
return sourceFile;
}
};
}

@Override
public Collection<? extends SourceFile> generate(AtomicBoolean found, ExecutionContext ctx) {
if (found.get()) {
return Collections.emptyList();
}
YamlParser parser = new YamlParser();
@Language("yml")
timtebeek marked this conversation as resolved.
Show resolved Hide resolved
String workflow = defaultWorkflow();
return parser.parse(workflow)
.map(brandNewFile -> (Yaml.Documents) brandNewFile.withSourcePath(Paths.get(FILE_PATH)))
.collect(Collectors.toList());
}

@Override
public TreeVisitor<?, ExecutionContext> getVisitor(AtomicBoolean found) {
return new YamlIsoVisitor<ExecutionContext>() {
@Override
public Yaml.Documents visitDocuments(Yaml.Documents documents, ExecutionContext executionContext) {
timtebeek marked this conversation as resolved.
Show resolved Hide resolved
if (branches != null) {
doAfterVisit(new ReplaceSequenceVisitor(BRANCHES_KEY, branches));
}
if (javaVersion != null) {
doAfterVisit(new ChangePropertyValue(
JAVA_VERSION_KEY,
String.valueOf(javaVersion),
null,
null,
false
).getVisitor());
}
if (buildTool != null) {
doAfterVisit(new ChangePropertyValue(
BUILD_TOOL_KEY,
buildTool,
null,
null,
false
).getVisitor());
}
return super.visitDocuments(documents, executionContext);
}
};
}

private static String defaultWorkflow() {
try (InputStream is = AddSecurityScanWorkflow.class.getResourceAsStream(DEFAULT_WORKFLOW_PATH)) {
requireNonNull(is);
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, UTF_8))) {
List<String> wanted = new LinkedList<>();
List<String> lines = br.lines().collect(Collectors.toList());
boolean licenseHeaderDone = false;
for (String line : lines) {
if (licenseHeaderDone) {
wanted.add(line);
} else if (line.isEmpty()) {
licenseHeaderDone = true;
}
}
Comment on lines +156 to +163
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to configure the license plugin to the workflow file with something like this?

license {
    exclude "**/*.properties"
    excludes(["**/*.txt", "**/*.conf"])
}

Unless we go for downloading the file on the fly of course, in which case this wouldn't be necessary.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks! Didn't know this was an option

return String.join("\n", wanted);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright 2023 the original author or authors.
* <p>
* 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
* <p>
* https://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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.openrewrite.jenkins.github;

import org.openrewrite.Cursor;
import org.openrewrite.ExecutionContext;
import org.openrewrite.marker.Markers;
import org.openrewrite.yaml.JsonPathMatcher;
import org.openrewrite.yaml.YamlIsoVisitor;
import org.openrewrite.yaml.tree.Yaml;

import java.util.List;

import static org.openrewrite.Tree.randomId;

/**
* Adapted from {@link org.openrewrite.yaml.AppendToSequenceVisitor}
*/
class ReplaceSequenceVisitor extends YamlIsoVisitor<ExecutionContext> {
Comment on lines +30 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Glad to see you were able to make this work; I'm wondering if it would make sense to pull this up, or change either AppendToSequence or ChangePropertyValue to be able to set a new list of values. Just a thought for now, not something I'd ask of you, but curious how you'd see that. Fine to keep this here for now, and know that it's here if we get similar requests.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think that would make sense. I actually checked both those recipes to see if they already did this.

I think it might make sense for AppendToSequence to have a strategy called REPLACE, but it definitely seems like ChangePropertyValue could take a list of values. I attempted to use ChangePropertyValue with a yaml list [ a, b, c ], but the formatting didn't quite work.

private final JsonPathMatcher matcher;
private final List<String> values;

ReplaceSequenceVisitor(JsonPathMatcher matcher, List<String> values) {
this.matcher = matcher;
this.values = values;
}

@Override
public Yaml.Sequence visitSequence(Yaml.Sequence existingSeq, ExecutionContext executionContext) {
Cursor parent = getCursor().getParent();
if (matcher.matches(parent) && !alreadyVisited(existingSeq, executionContext)) {
setVisited(existingSeq, executionContext);
Yaml.Sequence newSeq = replaceSequence(existingSeq, this.values);
setVisited(newSeq, executionContext);
return newSeq;
}
return super.visitSequence(existingSeq, executionContext);
}

private Yaml.Sequence replaceSequence(Yaml.Sequence existingSequence, List<String> values) {
Yaml.Sequence newSequence = existingSequence.copyPaste();
List<Yaml.Sequence.Entry> entries = newSequence.getEntries();
boolean hasDash = false;
Yaml.Scalar.Style style = Yaml.Scalar.Style.PLAIN;
String entryPrefix = "";
String entryTrailingCommaPrefix = null;
String itemPrefix = "";
if (!entries.isEmpty()) {
final int lastEntryIndex = entries.size() - 1;
Yaml.Sequence.Entry existingEntry = entries.get(lastEntryIndex);
hasDash = existingEntry.isDash();
entryPrefix = existingEntry.getPrefix();
entryTrailingCommaPrefix = existingEntry.getTrailingCommaPrefix();
Yaml.Sequence.Block block = existingEntry.getBlock();
if (block instanceof Yaml.Sequence.Mapping) {
Yaml.Sequence.Mapping mapping = (Yaml.Sequence.Mapping) block;
List<Yaml.Mapping.Entry> mappingEntries = mapping.getEntries();
if (!mappingEntries.isEmpty()) {
Yaml.Mapping.Entry entry = mappingEntries.get(0);
itemPrefix = entry.getPrefix();
}
}
else if (block instanceof Yaml.Sequence.Scalar) {
itemPrefix = block.getPrefix();
style = ((Yaml.Sequence.Scalar) block).getStyle();
}
if (!existingEntry.isDash()) {
entries.set(lastEntryIndex, existingEntry.withTrailingCommaPrefix(""));
}
}
entries.clear();
for (String value : values) {
Yaml.Scalar newItem = new Yaml.Scalar(randomId(), itemPrefix, Markers.EMPTY, style, null, value);
Yaml.Sequence.Entry newEntry = new Yaml.Sequence.Entry(randomId(), entryPrefix, Markers.EMPTY, newItem, hasDash, entryTrailingCommaPrefix);
entries.add(newEntry);
}
return newSequence;
}

private static void setVisited(Yaml.Sequence seq, ExecutionContext context) {
context.putMessage(makeAlreadyVisitedKey(seq), Boolean.TRUE);
}

private static boolean alreadyVisited(Yaml.Sequence seq, ExecutionContext context) {
return context.getMessage(makeAlreadyVisitedKey(seq), Boolean.FALSE);
}

private static String makeAlreadyVisitedKey(Yaml.Sequence seq) {
return org.openrewrite.yaml.AppendToSequenceVisitor.class.getName() + ".alreadyVisited." + seq.getId().toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#
# Copyright 2023 the original author or authors.
# <p>
# 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
# <p>
# https://www.apache.org/licenses/LICENSE-2.0
# <p>
# 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.
#

# More information about the Jenkins security scan can be found at the developer docs: https://www.jenkins.io/redirect/jenkins-security-scan/

name: Jenkins Security Scan
on:
push:
branches:
- "master"
- "main"
pull_request:
types: [ opened, synchronize, reopened ]
workflow_dispatch:

permissions:
security-events: write
contents: read
actions: read

jobs:
security-scan:
uses: jenkins-infra/jenkins-security-scan/.github/workflows/jenkins-security-scan.yaml@v2
with:
java-cache: 'maven' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate.
java-version: 11 # What version of Java to set up for the build.
Loading