Skip to content
Open
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
78 changes: 57 additions & 21 deletions checker/raw_result.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,28 @@ import (
//
//nolint:govet
type RawResults struct {
BinaryArtifactResults BinaryArtifactData
BranchProtectionResults BranchProtectionsData
CIIBestPracticesResults CIIBestPracticesData
CITestResults CITestData
CodeReviewResults CodeReviewData
ContributorsResults ContributorsData
DangerousWorkflowResults DangerousWorkflowData
DependencyUpdateToolResults DependencyUpdateToolData
FuzzingResults FuzzingData
LicenseResults LicenseData
SBOMResults SBOMData
MaintainedResults MaintainedData
Metadata MetadataData
PackagingResults PackagingData
PinningDependenciesResults PinningDependenciesData
SASTResults SASTData
SecurityPolicyResults SecurityPolicyData
SignedReleasesResults SignedReleasesData
TokenPermissionsResults TokenPermissionsData
VulnerabilitiesResults VulnerabilitiesData
WebhookResults WebhooksData
BinaryArtifactResults BinaryArtifactData
BranchProtectionResults BranchProtectionsData
CIIBestPracticesResults CIIBestPracticesData
CITestResults CITestData
CodeReviewResults CodeReviewData
ContributorsResults ContributorsData
DangerousWorkflowResults DangerousWorkflowData
DependencyUpdateToolResults DependencyUpdateToolData
FuzzingResults FuzzingData
LicenseResults LicenseData
SBOMResults SBOMData
MaintainedResults MaintainedData
Metadata MetadataData
PackagingResults PackagingData
PinningDependenciesResults PinningDependenciesData
SASTResults SASTData
SecurityPolicyResults SecurityPolicyData
SignedReleasesResults SignedReleasesData
TokenPermissionsResults TokenPermissionsData
VulnerabilitiesResults VulnerabilitiesData
WebhookResults WebhooksData
ReleaseDirectDepsVulnsResults ReleaseDirectDepsVulnsData
}

type MetadataData struct {
Expand Down Expand Up @@ -192,6 +193,41 @@ type SBOMData struct {
SBOMFiles []SBOM
}

// ReleaseDirectDepsVulnsData is consumed by the probe to reason about
// each of the last N releases and whether its *direct* dependencies
// were affected by known vulnerabilities.
type ReleaseDirectDepsVulnsData struct {
Releases []ReleaseDepsVulns // one row per release considered
}

// ReleaseDepsVulns captures the per-release summary produced by the raw collector.
type ReleaseDepsVulns struct {
Tag string
CommitSHA string
PublishedAt time.Time
DirectDeps []DirectDep // direct dependencies discovered via osv-scalibr (manifest-only)
Findings []DepVuln // non-empty => at least one vulnerable direct dependency
}

// DirectDep is a light representation of a direct dependency extracted from manifests.
type DirectDep struct {
Ecosystem string // canonical or normalized ecosystem label (e.g., "Go", "PyPI", "npm", "Maven", ...)
Name string // package/module/artifact name
Version string // exact version string
PURL string // optional package-url (preferred for OSV queries when present)
Location string // relative path to manifest that declared this dependency
}

// DepVuln indicates that a specific direct dependency matched one or more OSV IDs.
type DepVuln struct {
Ecosystem string
Name string
Version string
PURL string
ManifestPath string
OSVIDs []string
}

// CodeReviewData contains the raw results
// for the Code-Review check.
type CodeReviewData struct {
Expand Down
69 changes: 58 additions & 11 deletions checks/evaluation/vulnerabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,42 +16,89 @@ package evaluation

import (
"fmt"
"math"

"github.com/ossf/scorecard/v5/checker"
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasOSVVulnerabilities"
"github.com/ossf/scorecard/v5/probes/releasesDirectDepsAreVulnFree"
)

// Vulnerabilities applies the score policy for the Vulnerabilities check.
// It combines two aspects:
// 1. Current state vulnerabilities (60% weight, max -6 points).
// 2. Release vulnerabilities (40% weight, proportional across releases).
func Vulnerabilities(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
expectedProbes := []string{
hasOSVVulnerabilities.Probe,
// We expect findings from hasOSVVulnerabilities probe (always runs)
// and optionally from releasesDirectDepsAreVulnFree (when releases exist).
// Validate that we have at least the current vulnerabilities probe.
hasCurrentVulnProbe := false

for i := range findings {
if findings[i].Probe == hasOSVVulnerabilities.Probe {
hasCurrentVulnProbe = true
break
}
}

if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
if !hasCurrentVulnProbe {
e := sce.WithMessage(sce.ErrScorecardInternal, "missing hasOSVVulnerabilities probe results")
return checker.CreateRuntimeErrorResult(name, e)
}

var numVulnsFound int
// Separate findings by probe type
var numCurrentVulns int
var totalReleases, cleanReleases int

for i := range findings {
f := &findings[i]
if f.Outcome == finding.OutcomeTrue {
numVulnsFound++
checker.LogFinding(dl, f, checker.DetailWarn)
switch f.Probe {
case hasOSVVulnerabilities.Probe:
if f.Outcome == finding.OutcomeTrue {
numCurrentVulns++
checker.LogFinding(dl, f, checker.DetailWarn)
}
case releasesDirectDepsAreVulnFree.Probe:
totalReleases++
if f.Outcome == finding.OutcomeTrue {
cleanReleases++
} else if f.Outcome == finding.OutcomeFalse {
checker.LogFinding(dl, f, checker.DetailWarn)
}
}
}

score := checker.MaxResultScore - numVulnsFound
// Calculate weighted score using Option A
// Current vulnerabilities: 60% weight (6 points max penalty)
currentComponent := 6.0 - math.Min(float64(numCurrentVulns), 6.0)

// Release vulnerabilities: 40% weight (4 points max)
releaseComponent := 4.0
if totalReleases > 0 {
releaseComponent = 4.0 * (float64(cleanReleases) / float64(totalReleases))
}

score := int(math.Round(currentComponent + releaseComponent))
if score < checker.MinResultScore {
score = checker.MinResultScore
}
if score > checker.MaxResultScore {
score = checker.MaxResultScore
}

// Build informative reason message
var reason string
if totalReleases > 0 {
reason = fmt.Sprintf(
"%d current vulnerabilities detected, %d/%d recent releases were free of vulnerabilities at time of release",
numCurrentVulns, cleanReleases, totalReleases)
} else {
reason = fmt.Sprintf("%d existing vulnerabilities detected", numCurrentVulns)
}

return checker.CreateResultWithScore(name,
fmt.Sprintf("%v existing vulnerabilities detected", numVulnsFound), score)
return checker.CreateResultWithScore(name, reason, score)
}
87 changes: 78 additions & 9 deletions checks/evaluation/vulnerabilities_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
sce "github.com/ossf/scorecard/v5/errors"
"github.com/ossf/scorecard/v5/finding"
"github.com/ossf/scorecard/v5/probes/hasOSVVulnerabilities"
"github.com/ossf/scorecard/v5/probes/releasesDirectDepsAreVulnFree"
scut "github.com/ossf/scorecard/v5/utests"
)

Expand All @@ -36,36 +37,68 @@ func TestVulnerabilities(t *testing.T) {
}
}{
{
name: "no vulnerabilities",
name: "no vulnerabilities - current and releases clean",
findings: []finding.Finding{
{
Probe: "hasOSVVulnerabilities",
Outcome: finding.OutcomeFalse,
},
{
Probe: "releasesDirectDepsAreVulnFree",
Outcome: finding.OutcomeTrue,
Message: "release v1.0.0 has no known vulnerabilities",
},
},
result: scut.TestReturn{
Score: 10,
},
},
{
name: "three vulnerabilities",
findings: vulnFindings(t, 3),
name: "three current vulnerabilities, all releases clean",
findings: append(vulnFindings(t, 3), cleanReleaseFindings(t, 5)...),
result: scut.TestReturn{
Score: 7,
Score: 7, // 6 - 3*0.6 + 4 = 4.2 + 4 = 8.2 rounds to 8, but 3 vulns = 3 so 6-3=3, 3+4=7
NumberOfWarn: 3,
},
},
{
name: "twelve vulnerabilities to check that score is not less than 0",
findings: vulnFindings(t, 12),
name: "twelve current vulnerabilities, all releases clean",
findings: append(vulnFindings(t, 12), cleanReleaseFindings(t, 5)...),
result: scut.TestReturn{
Score: 0,
Score: 4, // max penalty is 6 points, so 0 + 4 = 4
NumberOfWarn: 12,
},
},
{
name: "invalid findings",
findings: []finding.Finding{},
name: "no current vulnerabilities, but releases have issues",
findings: append(
[]finding.Finding{
{
Probe: "hasOSVVulnerabilities",
Outcome: finding.OutcomeFalse,
},
},
vulnReleaseFindings(t, 2, 5)...,
),
result: scut.TestReturn{
Score: 8, // 6 + (4 * 2/5) = 6 + 1.6 = 7.6 rounds to 8
NumberOfWarn: 3, // 3 vulnerable releases
},
},
{
name: "both current and release vulnerabilities",
findings: append(
vulnFindings(t, 2),
vulnReleaseFindings(t, 2, 4)...,
),
result: scut.TestReturn{
Score: 6, // (6 - 2) + (4 * 2/4) = 4 + 2 = 6
NumberOfWarn: 4,
},
},
{
name: "invalid findings - missing current vuln probe",
findings: cleanReleaseFindings(t, 3),
result: scut.TestReturn{
Score: -1,
Error: sce.ErrScorecardInternal,
Expand Down Expand Up @@ -94,3 +127,39 @@ func vulnFindings(t *testing.T, n int) []finding.Finding {
}
return findings
}

// helper to generate clean release findings.
func cleanReleaseFindings(t *testing.T, n int) []finding.Finding {
t.Helper()
findings := make([]finding.Finding, n)
for i := range findings {
findings[i] = finding.Finding{
Probe: releasesDirectDepsAreVulnFree.Probe,
Outcome: finding.OutcomeTrue,
Message: "release is clean",
}
}
return findings
}

// helper to generate vulnerable release findings.
// cleanCount = number of clean releases, totalCount = total releases.
func vulnReleaseFindings(t *testing.T, cleanCount, totalCount int) []finding.Finding {
t.Helper()
findings := make([]finding.Finding, totalCount)
for i := 0; i < cleanCount; i++ {
findings[i] = finding.Finding{
Probe: releasesDirectDepsAreVulnFree.Probe,
Outcome: finding.OutcomeTrue,
Message: "release is clean",
}
}
for i := cleanCount; i < totalCount; i++ {
findings[i] = finding.Finding{
Probe: releasesDirectDepsAreVulnFree.Probe,
Outcome: finding.OutcomeFalse,
Message: "release has vulnerabilities",
}
}
return findings
}
2 changes: 1 addition & 1 deletion checks/raw/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func typeOfPermission(val string) checker.PermissionLevel {
switch val {
case "read", "read-all":
return checker.PermissionLevelRead
case "none": //nolint:goconst
case "none":
return checker.PermissionLevelNone
}
return checker.PermissionLevelUnknown
Expand Down
Loading