diff --git a/src/main/java/org/openrewrite/jenkins/github/AddSecurityScanWorkflow.java b/src/main/java/org/openrewrite/jenkins/github/AddSecurityScanWorkflow.java new file mode 100644 index 0000000..3531488 --- /dev/null +++ b/src/main/java/org/openrewrite/jenkins/github/AddSecurityScanWorkflow.java @@ -0,0 +1,170 @@ +/* + * Copyright 2023 the original author or authors. + *

+ * 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.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 { + 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 branches; + + @Option(displayName = "Java Version", + description = "Version of Java to set for build.", + example = "11", + required = false) + @Nullable + Integer javaVersion; + + @Option(displayName = "Build Tool", + description = "Set up dependency cache.", + example = "maven", + valid = {"maven", "gradle"}, + required = false) + @Nullable + String buildTool; + + @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 getScanner(AtomicBoolean found) { + Path path = Paths.get(FILE_PATH); + return new TreeVisitor() { + @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 generate(AtomicBoolean found, ExecutionContext ctx) { + if (found.get()) { + return Collections.emptyList(); + } + YamlParser parser = new YamlParser(); + String workflow = defaultWorkflow(); + return parser.parse(workflow) + .map(brandNewFile -> (Yaml.Documents) brandNewFile.withSourcePath(Paths.get(FILE_PATH))) + .collect(Collectors.toList()); + } + + @Override + public TreeVisitor getVisitor(AtomicBoolean found) { + return new YamlIsoVisitor() { + @Override + public Yaml.Documents visitDocuments(Yaml.Documents documents, ExecutionContext executionContext) { + 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 wanted = new LinkedList<>(); + List lines = br.lines().collect(Collectors.toList()); + boolean licenseHeaderDone = false; + for (String line : lines) { + if (licenseHeaderDone) { + wanted.add(line); + } else if (line.isEmpty()) { + licenseHeaderDone = true; + } + } + return String.join("\n", wanted); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/openrewrite/jenkins/github/ReplaceSequenceVisitor.java b/src/main/java/org/openrewrite/jenkins/github/ReplaceSequenceVisitor.java new file mode 100644 index 0000000..ee02849 --- /dev/null +++ b/src/main/java/org/openrewrite/jenkins/github/ReplaceSequenceVisitor.java @@ -0,0 +1,104 @@ +/* + * Copyright 2023 the original author or authors. + *

+ * 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.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 { + private final JsonPathMatcher matcher; + private final List values; + + ReplaceSequenceVisitor(JsonPathMatcher matcher, List 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 values) { + Yaml.Sequence newSequence = existingSequence.copyPaste(); + List 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 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(); + } +} diff --git a/src/main/resources/org/openrewrite/jenkins/github/jenkins-security-scan.yml b/src/main/resources/org/openrewrite/jenkins/github/jenkins-security-scan.yml new file mode 100644 index 0000000..08f05ad --- /dev/null +++ b/src/main/resources/org/openrewrite/jenkins/github/jenkins-security-scan.yml @@ -0,0 +1,39 @@ +# +# Copyright 2023 the original author or authors. +#

+# 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. +# + +# 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. diff --git a/src/test/java/org/openrewrite/jenkins/github/AddSecurityScanWorkflowTest.java b/src/test/java/org/openrewrite/jenkins/github/AddSecurityScanWorkflowTest.java new file mode 100644 index 0000000..6515158 --- /dev/null +++ b/src/test/java/org/openrewrite/jenkins/github/AddSecurityScanWorkflowTest.java @@ -0,0 +1,266 @@ +/* + * Copyright 2023 the original author or authors. + *

+ * 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.openrewrite.jenkins.github; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RewriteTest; + +import java.util.List; + +import static org.openrewrite.yaml.Assertions.yaml; + +class AddSecurityScanWorkflowTest implements RewriteTest { + + @Test + void shouldNoOp() { + // language=yaml + rewriteRun( + s -> s.recipe(new AddSecurityScanWorkflow(null, null, null)), + yaml(""" + # 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. + """, + s -> s.path(".github/workflows/jenkins-security-scan.yml") + ) + ); + } + + @Test + @DocumentExample + void shouldAddFileIfMissing() { + // language=yaml + rewriteRun( + s -> s.recipe(new AddSecurityScanWorkflow(null, null, null)), + yaml(null, + """ + # 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. + """, + s -> s.path(".github/workflows/jenkins-security-scan.yml") + ) + ); + } + + @Test + void shouldAddFileWithDeclaredBranch() { + rewriteRun( + s -> s.recipe(new AddSecurityScanWorkflow(List.of("release/1.x"), null, null)), + yaml(null, + // language=yaml + """ + # 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: + - "release/1.x" + 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. + """, + s -> s.path(".github/workflows/jenkins-security-scan.yml") + ) + ); + } + + @Test + void shouldAddFileWithDeclaredJavaVersion() { + rewriteRun( + s -> s.recipe(new AddSecurityScanWorkflow(null, 17, null)), + yaml(null, + // language=yaml + """ + # 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: 17 # What version of Java to set up for the build. + """, + s -> s.path(".github/workflows/jenkins-security-scan.yml") + ) + ); + } + + @Test + void shouldAddFileWithDeclaredBuildTool() { + rewriteRun( + s -> s.recipe(new AddSecurityScanWorkflow(null, null, "gradle")), + yaml(null, + // language=yaml + """ + # 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: 'gradle' # 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. + """, + s -> s.path(".github/workflows/jenkins-security-scan.yml") + ) + ); + } + + @Test + void shouldChangeFile() { + rewriteRun( + s -> s.recipe(new AddSecurityScanWorkflow(List.of("release/1.x"), 21, "gradle")), + // language=yaml + yaml(""" + # 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: 17 # What version of Java to set up for the build. + """, + """ + # 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: + - "release/1.x" + 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: 'gradle' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate. + java-version: 21 # What version of Java to set up for the build. + """, + s -> s.path(".github/workflows/jenkins-security-scan.yml") + ) + ); + } + +}