|
| 1 | +/* |
| 2 | + * Copyright (C) 2023 Slack Technologies, LLC |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * https://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | +package slack.cli.sarif |
| 17 | + |
| 18 | +import com.github.ajalt.clikt.core.CliktCommand |
| 19 | +import com.github.ajalt.clikt.parameters.options.flag |
| 20 | +import com.github.ajalt.clikt.parameters.options.option |
| 21 | +import com.github.ajalt.clikt.parameters.options.required |
| 22 | +import com.github.ajalt.clikt.parameters.types.enum |
| 23 | +import com.github.ajalt.clikt.parameters.types.path |
| 24 | +import com.google.auto.service.AutoService |
| 25 | +import io.github.detekt.sarif4k.BaselineState |
| 26 | +import io.github.detekt.sarif4k.SarifSchema210 |
| 27 | +import io.github.detekt.sarif4k.SarifSerializer |
| 28 | +import kotlin.io.path.readText |
| 29 | +import kotlin.io.path.writeText |
| 30 | +import kotlin.system.exitProcess |
| 31 | +import slack.cli.CommandFactory |
| 32 | + |
| 33 | +/** A CLI that applies baselines data to a SARIF file. See the docs on [Mode] for more details. */ |
| 34 | +public class ApplyBaselinesToSarifs : CliktCommand(help = DESCRIPTION) { |
| 35 | + |
| 36 | + @AutoService(CommandFactory::class) |
| 37 | + public class Factory : CommandFactory { |
| 38 | + override val key: String = "apply-baselines-to-sarifs" |
| 39 | + override val description: String = DESCRIPTION |
| 40 | + |
| 41 | + override fun create(): CliktCommand = ApplyBaselinesToSarifs() |
| 42 | + } |
| 43 | + |
| 44 | + private companion object { |
| 45 | + const val DESCRIPTION = "A CLI that applies baselines data to a SARIF file." |
| 46 | + } |
| 47 | + |
| 48 | + private val baseline by |
| 49 | + option("--baseline", "-b", help = "The baseline SARIF file to use.") |
| 50 | + .path(mustExist = true, canBeDir = false) |
| 51 | + .required() |
| 52 | + |
| 53 | + private val current by |
| 54 | + option("--current", "-c", help = "The SARIF file to apply the baseline to.") |
| 55 | + .path(mustExist = true, canBeDir = false) |
| 56 | + .required() |
| 57 | + |
| 58 | + private val output by |
| 59 | + option("--output", "-o", help = "The output SARIF file to write.") |
| 60 | + .path(canBeDir = false) |
| 61 | + .required() |
| 62 | + |
| 63 | + private val removeUriPrefixes by |
| 64 | + option( |
| 65 | + "--remove-uri-prefixes", |
| 66 | + help = |
| 67 | + "When enabled, removes the root project directory from location uris such that they are only " + |
| 68 | + "relative to the root project dir." |
| 69 | + ) |
| 70 | + .flag() |
| 71 | + |
| 72 | + private val mode by |
| 73 | + option("--mode", "-m", help = "The mode to run in.").enum<Mode>(ignoreCase = true).required() |
| 74 | + |
| 75 | + private val includeAbsent by |
| 76 | + option("--include-absent", "-a", help = "Include absent results in updating.").flag() |
| 77 | + |
| 78 | + override fun run() { |
| 79 | + if (includeAbsent && mode != Mode.UPDATE) { |
| 80 | + echo("--include-absent can only be used with --mode=update", err = true) |
| 81 | + exitProcess(1) |
| 82 | + } |
| 83 | + val baseline = SarifSerializer.fromJson(baseline.readText()) |
| 84 | + val sarifToUpdate = SarifSerializer.fromJson(current.readText()) |
| 85 | + |
| 86 | + val updatedSarif = sarifToUpdate.applyBaseline(baseline) |
| 87 | + |
| 88 | + output.writeText(SarifSerializer.toJson(updatedSarif)) |
| 89 | + } |
| 90 | + |
| 91 | + @Suppress("LongMethod") |
| 92 | + private fun SarifSchema210.applyBaseline(baseline: SarifSchema210): SarifSchema210 { |
| 93 | + // Assume a single run for now |
| 94 | + val results = runs.first().results!! |
| 95 | + val baselineResults = baseline.runs.first().results!! |
| 96 | + |
| 97 | + val suppressions = listOf(BASELINE_SUPPRESSION) |
| 98 | + |
| 99 | + return when (mode) { |
| 100 | + Mode.MERGE -> { |
| 101 | + // Mark baselines as suppressed and no baseline state |
| 102 | + val suppressedBaselineSchema = |
| 103 | + baseline.copy( |
| 104 | + runs = |
| 105 | + baseline.runs.map { run -> |
| 106 | + run.copy( |
| 107 | + results = |
| 108 | + baselineResults.map { |
| 109 | + it.copy(baselineState = null, suppressions = suppressions) |
| 110 | + } |
| 111 | + ) |
| 112 | + } |
| 113 | + ) |
| 114 | + // Mark new results as new and not suppressed |
| 115 | + val newSchema = |
| 116 | + copy( |
| 117 | + runs = |
| 118 | + runs.map { run -> |
| 119 | + run.copy( |
| 120 | + results = |
| 121 | + results.map { |
| 122 | + it.copy(baselineState = BaselineState.New, suppressions = emptyList()) |
| 123 | + } |
| 124 | + ) |
| 125 | + } |
| 126 | + ) |
| 127 | + // Merge the two |
| 128 | + listOf(suppressedBaselineSchema, newSchema) |
| 129 | + .merge(removeUriPrefixes = removeUriPrefixes, log = ::echo) |
| 130 | + } |
| 131 | + Mode.UPDATE -> { |
| 132 | + val baselineResultsByHash = baselineResults.associateBy { it.identityHash } |
| 133 | + val resultsByHash = results.associateBy { it.identityHash } |
| 134 | + // New -> No match in the baseline |
| 135 | + // Unchanged -> Exact match in the baseline. |
| 136 | + // Updated -> Partial match is found. Not sure if we could realistically detect this well |
| 137 | + // based on just ID and location though. May be that the only change we could |
| 138 | + // match here would be if the severity changes |
| 139 | + // Absent -> Nothing to report, means this issue was fixed presumably. Not sure how this |
| 140 | + // would show up in a baseline state tbh |
| 141 | + val baselinedResults = |
| 142 | + results.map { result -> |
| 143 | + val baselineResult = baselineResultsByHash[result.identityHash] |
| 144 | + when { |
| 145 | + baselineResult == null -> { |
| 146 | + // No baseline result, so it's new! |
| 147 | + result.copy(baselineState = BaselineState.New) |
| 148 | + } |
| 149 | + baselineResult.shallowHash == result.shallowHash -> { |
| 150 | + // They're they same, so it's unchanged |
| 151 | + result.copy(baselineState = BaselineState.Unchanged, suppressions = suppressions) |
| 152 | + } |
| 153 | + else -> { |
| 154 | + // They're different, so it's updated |
| 155 | + result.copy(baselineState = BaselineState.Updated, suppressions = suppressions) |
| 156 | + } |
| 157 | + } |
| 158 | + } |
| 159 | + val absentResults = |
| 160 | + if (includeAbsent) { |
| 161 | + // Create a copy of the baseline results that are absent with a suppression |
| 162 | + baselineResults |
| 163 | + .filter { result -> result.identityHash !in resultsByHash } |
| 164 | + .map { it.copy(baselineState = BaselineState.Absent, suppressions = suppressions) } |
| 165 | + } else { |
| 166 | + emptyList() |
| 167 | + } |
| 168 | + val absentResultsSchema = |
| 169 | + baseline.copy(runs = runs.map { run -> run.copy(results = absentResults) }) |
| 170 | + val newCurrentSchema = copy(runs = runs.map { run -> run.copy(results = baselinedResults) }) |
| 171 | + |
| 172 | + newCurrentSchema.mergeWith( |
| 173 | + absentResultsSchema, |
| 174 | + removeUriPrefixes = removeUriPrefixes, |
| 175 | + log = ::echo |
| 176 | + ) |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + internal enum class Mode { |
| 182 | + /** |
| 183 | + * Merge two SARIFs, this does the following: |
| 184 | + * - Marks the baseline results as "suppressed". |
| 185 | + * - Marks the new results as "new". |
| 186 | + * |
| 187 | + * The two SARIFs are deemed to be distinct results and have no overlaps. |
| 188 | + */ |
| 189 | + MERGE, |
| 190 | + /** |
| 191 | + * Update the input SARIF based on a previous baseline: |
| 192 | + * - Marks the new results as "new". |
| 193 | + * - Marks the absent results as "absent" (aka "fixed"). |
| 194 | + * - Mark remaining as updated or unchanged. |
| 195 | + * - No changes are made to suppressions. |
| 196 | + */ |
| 197 | + UPDATE, |
| 198 | + } |
| 199 | +} |
0 commit comments