Skip to content

Add report generation and optimize snippet findings for SCANOSS #10287

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

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions buildSrc/src/main/kotlin/LicenseUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ object CopyrightableFiles {
"plugins/reporters/asciidoc/src/main/resources/pdf-theme/pdf-theme.yml",
"plugins/reporters/asciidoc/src/main/resources/templates/freemarker_implicit.ftl",
"plugins/reporters/fossid/src/main/resources/templates/freemarker_implicit.ftl",
"plugins/reporters/scanoss/src/main/resources/templates/freemarker_implicit.ftl",
"plugins/reporters/freemarker/src/main/resources/templates/freemarker_implicit.ftl",
"plugins/reporters/static-html/src/main/resources/prismjs/",
"plugins/reporters/web-app-template/yarn.lock",
Expand Down
2 changes: 1 addition & 1 deletion integrations/completions/ort-completion.fish
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ complete -c ort -f -n __fish_use_subcommand -a report -d 'Present Analyzer, Scan
## Options for report
complete -c ort -n "__fish_seen_subcommand_from report" -l ort-file -s i -r -F -d 'The ORT result file to use.'
complete -c ort -n "__fish_seen_subcommand_from report" -l output-dir -s o -r -F -d 'The output directory to store the generated reports in.'
complete -c ort -n "__fish_seen_subcommand_from report" -l report-formats -s f -r -d 'A comma-separated list of report formats to generate, any of [AOSD2.0, AOSD2.1, CtrlXAutomation, CycloneDX, DocBookTemplate, EvaluatedModel, FossID, FossIdSnippet, HtmlTemplate, ManPageTemplate, Opossum, PdfTemplate, PlainTextTemplate, SpdxDocument, StaticHTML, TrustSource, WebApp].'
complete -c ort -n "__fish_seen_subcommand_from report" -l report-formats -s f -r -d 'A comma-separated list of report formats to generate, any of [AOSD2.0, AOSD2.1, CtrlXAutomation, CycloneDX, DocBookTemplate, EvaluatedModel, FossID, FossIdSnippet, HtmlTemplate, ManPageTemplate, Opossum, PdfTemplate, PlainTextTemplate, ScanossSnippet, SpdxDocument, StaticHTML, TrustSource, WebApp].'
complete -c ort -n "__fish_seen_subcommand_from report" -l copyright-garbage-file -r -F -d 'A file containing copyright statements which are marked as garbage. This can make the output inconsistent with the evaluator output but is useful when testing copyright garbage.'
complete -c ort -n "__fish_seen_subcommand_from report" -l custom-license-texts-dir -r -F -d 'A directory which maps custom license IDs to license texts. It should contain one text file per license with the license ID as the filename. A custom license text is used only if its ID has a \'LicenseRef-\' prefix and if the respective license text is not known by ORT.'
complete -c ort -n "__fish_seen_subcommand_from report" -l how-to-fix-text-provider-script -r -F -d 'The path to a Kotlin script which returns an instance of a \'HowToFixTextProvider\'. That provider injects how-to-fix texts in Markdown format for ORT issues.'
Expand Down
39 changes: 39 additions & 0 deletions plugins/reporters/scanoss/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2025 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
* License-Filename: LICENSE
*/

plugins {
// Apply precompiled plugins.
id("ort-plugin-conventions")
}

dependencies {
api(projects.reporter)

ksp(projects.reporter)

implementation(projects.model)
implementation(projects.plugins.reporters.asciidocReporter)
implementation(projects.plugins.reporters.freemarkerReporter)
implementation(projects.utils.commonUtils)
implementation(projects.utils.ortUtils)

implementation(libs.kotlinx.coroutines)

testImplementation(libs.mockk)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright (C) 2025 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
*
* 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.
*
* SPDX-License-Identifier: Apache-2.0
* License-Filename: LICENSE
*/

package org.ossreviewtoolkit.plugins.reporters.scanoss

import java.io.File

import org.ossreviewtoolkit.plugins.api.OrtPlugin
import org.ossreviewtoolkit.plugins.api.PluginDescriptor
import org.ossreviewtoolkit.plugins.reporters.asciidoc.AsciiDocTemplateReporterConfig
import org.ossreviewtoolkit.plugins.reporters.asciidoc.HtmlTemplateReporter
import org.ossreviewtoolkit.reporter.Reporter
import org.ossreviewtoolkit.reporter.ReporterFactory
import org.ossreviewtoolkit.reporter.ReporterInput

@OrtPlugin(
displayName = "SCANOSS Snippet Reporter",
description = "Generates a detailed report of the SCANOSS snippet findings.",
factory = ReporterFactory::class
)
class ScanossSnippetReporter(override val descriptor: PluginDescriptor = ScanossSnippetReporterFactory.descriptor) :
Reporter by delegateReporter {
companion object {
private val delegateReporter = HtmlTemplateReporter(
ScanossSnippetReporterFactory.descriptor,
AsciiDocTemplateReporterConfig(templateIds = listOf("scanoss_snippet"), templatePaths = null)
)
}

override fun generateReport(input: ReporterInput, outputDir: File): List<Result<File>> {
val hasScanossResults = input.ortResult.scanner?.scanResults?.any { it.scanner.name == "SCANOSS" } == true
require(hasScanossResults) { "No SCANOSS scan results have been found." }

return delegateReporter.generateReport(input, outputDir)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Use Unix line endings for Freemarker templates for consistency across platforms.
**/*.ftl text eol=lf
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
[#--
Copyright (C) 2025 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)

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.

SPDX-License-Identifier: Apache-2.0
License-Filename: LICENSE
--]

:publisher: OSS Review Toolkit
[#assign now = .now]
:revdate: ${now?date?iso_local}

:title-page:
:sectnums:
:toc:

= SCANOSS Snippets
List of all the provenances with their files and snippets.
[#list ortResult.scanner.scanResults as scanResult]

[#assign snippetsLimitIssue = helper.getSnippetsLimitIssue()]

[#if snippetsLimitIssue?has_content]
[WARNING]
====
${snippetsLimitIssue}
====
[/#if]

[#if scanResult.provenance.vcsInfo??]
[#assign url = scanResult.provenance.vcsInfo.url]
[#else]
[#assign url = scanResult.provenance.sourceArtifact.url]
[/#if]
== Provenance '${url}'

[#assign summary = scanResult.summary]

Scan start time : ${summary.startTime} +
End time : ${summary.startTime} +
[#if scanResult.provenance.vcsInfo??]
[#assign gitRepoUrl = url]
[#assign gitRevision = scanResult.provenance.vcsInfo.revision]
Git repo URL: ${gitRepoUrl} +
Git revision: ${gitRevision}

[#if gitRepoUrl?contains("github.com")]
[#assign githubBaseURL = '${gitRepoUrl?remove_ending(".git")}/blob/${gitRevision}']
[/#if]
[/#if]

[#list helper.groupSnippetsByFile(summary.snippetFindings) as filePath, snippetFindings ]

[#if gitRepoUrl?? && gitRepoUrl?contains("github.com")]
[#assign localFileURL = '${githubBaseURL}/${filePath}[${filePath}]']
[#else]
[#assign localFileURL = "${filePath}"]
[/#if]
[#assign licenses = helper.collectLicenses(snippetFindings)]

*${localFileURL}* +
License(s):
[#list licenses as license]
${license}[#sep],
[/#list]

[#list helper.groupSnippetsBySourceLines(snippetFindings) as sourceLocation, snippetFinding]
[#assign snippetCount = snippetFinding.snippets?size]

[width=100%]
[cols="1,3,4,1,2"]
|===
| Source Location | pURL | License | Score | Release Date

.${snippetCount*2}+|
Partial match +
${sourceLocation.startLine?c}-${sourceLocation.endLine?c}


[#list snippetFinding.snippets as snippet ]

| ${snippet.purl!""}
| ${snippet.license!""}
| ${snippet.score!""}
| ${snippet.additionalData['release_date']}

4+a|
.Create a snippet choice for this snippet or mark it as false positive
[%collapsible]
====
Add the following lines to the *.ort.yml* file.

To **choose** this snippet:
[source,yaml]
--
snippet_choices:
- provenance:
url: "${scanResult.provenance.vcsInfo.url}"
choices:
- given:
source_location:
path: "${filePath}"
start_line: ${snippetFinding.sourceLocation.startLine?c}
end_line: ${snippetFinding.sourceLocation.endLine?c}
choice:
purl: "${snippet.purl!""}"
reason: "ORIGINAL_FINDING"
comment: "Explain why this snippet choice was made"
--
Or to mark this location has having ONLY **false positives snippets**:
[source,yaml]
--
snippet_choices:
- provenance:
url: "${scanResult.provenance.vcsInfo.url}"
choices:
- given:
source_location:
path: "${filePath}"
start_line: ${snippetFinding.sourceLocation.startLine?c}
end_line: ${snippetFinding.sourceLocation.endLine?c}
choice:
reason: "NO_RELEVANT_FINDING"
comment: "Explain why this location has only false positives snippets"
--
====
[/#list]
|===
[/#list]
[/#list]
[/#list]
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[#ftl]
[#-- @implicitly included --]

[#-- @ftlvariable name="projects" type="kotlin.collections.Set<org.ossreviewtoolkit.reporter.utils.FreemarkerTemplateProcessor.PackageModel>" --]
[#-- @ftlvariable name="packages" type="kotlin.collections.Set<org.ossreviewtoolkit.reporter.utils.FreemarkerTemplateProcessor.PackageModel>" --]
[#-- @ftlvariable name="ortResult" type="org.ossreviewtoolkit.model.OrtResult" --]
[#-- @ftlvariable name="licenseTextProvider" type="org.ossreviewtoolkit.reporter.LicenseTextProvider" --]
[#-- @ftlvariable name="LicenseView" type="org.ossreviewtoolkit.model.licenses.LicenseView" --]
[#-- @ftlvariable name="helper" type="org.ossreviewtoolkit.plugins.reporters.freemarker.FreemarkerTemplateProcessor.TemplateHelper" --]
[#-- @ftlvariable name="projectsAsPackages" type="kotlin.collections.Set<org.ossreviewtoolkit.model.Identifier>" --]
77 changes: 53 additions & 24 deletions plugins/scanners/scanoss/src/main/kotlin/ScanOssResultParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@
*/

package org.ossreviewtoolkit.plugins.scanners.scanoss

import com.scanoss.dto.LicenseDetails
import com.scanoss.dto.ScanFileDetails
import com.scanoss.dto.ScanFileResult
import com.scanoss.dto.enums.MatchType
import com.scanoss.dto.enums.StatusType

import java.lang.invoke.MethodHandles
import java.time.Instant

import org.apache.logging.log4j.kotlin.loggerOf

import org.ossreviewtoolkit.downloader.VcsHost
import org.ossreviewtoolkit.model.CopyrightFinding
import org.ossreviewtoolkit.model.LicenseFinding
Expand All @@ -36,7 +40,8 @@ import org.ossreviewtoolkit.model.TextLocation
import org.ossreviewtoolkit.utils.spdx.SpdxConstants
import org.ossreviewtoolkit.utils.spdx.SpdxExpression
import org.ossreviewtoolkit.utils.spdx.SpdxLicenseIdExpression
import org.ossreviewtoolkit.utils.spdx.toExpression

private val logger = loggerOf(MethodHandles.lookup().lookupClass())

/**
* Generate a summary from the given SCANOSS [result], using [startTime], [endTime] as metadata. This variant can be
Expand All @@ -56,16 +61,29 @@ internal fun generateSummary(startTime: Instant, endTime: Instant, results: List
}

MatchType.snippet -> {
val file = requireNotNull(details.file)
val lines = requireNotNull(details.lines)
val sourceLocations = convertLines(file, lines)
val snippets = getSnippets(details)

snippets.forEach { snippet ->
sourceLocations.forEach { sourceLocation ->
// TODO: Aggregate the snippet by source file location.
snippetFindings += SnippetFinding(sourceLocation, setOf(snippet))
val file = requireNotNull(result.filePath)
if (details.status == StatusType.pending) {
val lines = requireNotNull(details.lines)
val sourceLocations = convertLines(file, lines)
val snippets = getSnippets(details)

// The number of snippets should match the number of source locations.
if (sourceLocations.size != snippets.size) {
logger.warn {
"Unexpected mismatch in '$file': " +
"${sourceLocations.size} source locations vs ${snippets.size} snippets. " +
"This indicates a potential issue with line range conversion."
}
}

// Associate each source location with its corresponding snippet.
sourceLocations.zip(snippets).forEach { (location, snippet) ->
snippetFindings += SnippetFinding(location, setOf(snippet))
}
} else {
logger.warn { "File '$file' is identified, not including on snippet findings" }
licenseFindings += getLicenseFindings(details)
copyrightFindings += getCopyrightFindings(details)
}
}

Expand Down Expand Up @@ -134,34 +152,34 @@ private fun getCopyrightFindings(details: ScanFileDetails): List<CopyrightFindin
}

/**
* Get the snippet findings from the given [details]. If a snippet returned by ScanOSS contains several Purls,
* several snippets are created in ORT each containing a single Purl.
* Get the snippet findings from the given [details]. If a snippet returned by SCANOSS contains several Purls,
* the function uses the first PURL as the primary identifier while storing all PURLs in additionalData
* to preserve the complete information.
*/
private fun getSnippets(details: ScanFileDetails): Set<Snippet> {
private fun getSnippets(details: ScanFileDetails): List<Snippet> {
val matched = requireNotNull(details.matched)
val fileUrl = requireNotNull(details.fileUrl)
val ossLines = requireNotNull(details.ossLines)
val url = requireNotNull(details.url)
val purls = requireNotNull(details.purls)

val licenses = details.licenseDetails.orEmpty().mapTo(mutableSetOf()) { license ->
SpdxExpression.parse(license.name)
}
val license = getUniqueLicenseExpression(details.licenseDetails.toList())

val score = matched.substringBeforeLast("%").toFloat()
val locations = convertLines(fileUrl, ossLines)
// TODO: No resolved revision is available. Should a ArtifactProvenance be created instead ?
val vcsInfo = VcsHost.parseUrl(url.takeUnless { it == "none" }.orEmpty())
val provenance = RepositoryProvenance(vcsInfo, ".")

return buildSet {
purls.forEach { purl ->
locations.forEach { snippetLocation ->
val license = licenses.toExpression()?.sorted() ?: SpdxLicenseIdExpression(SpdxConstants.NOASSERTION)
// Store all PURLs in additionalData to preserve the complete information.
val additionalData = mapOf(
"release_date" to details.releaseDate,
"all_purls" to purls.joinToString(" ")
)

add(Snippet(score, snippetLocation, provenance, purl, license))
}
}
// Create one snippet per location, using the first PURL as the primary identifier.
return locations.map { snippetLocation ->
Snippet(score, snippetLocation, provenance, purls.firstOrNull().orEmpty(), license, additionalData)
}
}

Expand All @@ -178,3 +196,14 @@ private fun convertLines(file: String, lineRanges: String): List<TextLocation> =
else -> throw IllegalArgumentException("Unsupported line range '$lineRange'.")
}
}

fun getUniqueLicenseExpression(licensesDetails: List<LicenseDetails>): SpdxExpression {
if (licensesDetails.isEmpty()) {
return SpdxLicenseIdExpression(SpdxConstants.NOASSERTION)
}

return licensesDetails
.map { license -> SpdxExpression.parse(license.name) }
.reduce { acc, expr -> acc and expr }
.simplify()
}
Loading
Loading