diff --git a/clomonitor-core/src/linter/checks/datasource/security_insights.rs b/clomonitor-core/src/linter/checks/datasource/security_insights.rs index c3f3b47c..2aea2234 100644 --- a/clomonitor-core/src/linter/checks/datasource/security_insights.rs +++ b/clomonitor-core/src/linter/checks/datasource/security_insights.rs @@ -1,15 +1,167 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::linter::util; -/// OpenSSF Security Insights manifest file name. -pub(crate) const SECURITY_INSIGHTS_MANIFEST_FILE: &str = "SECURITY-INSIGHTS.yml"; +/// Candidate manifest file paths, searched in order. First match wins. +/// v2 locations are checked first, with v1 as a fallback. +const MANIFEST_CANDIDATES: &[&str] = &[ + "security-insights.yml", + ".github/security-insights.yml", + ".gitlab/security-insights.yml", + "SECURITY-INSIGHTS.yml", +]; + +/// Lightweight struct used to peek at the schema version before full parsing. +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +struct VersionProbe { + header: VersionProbeHeader, +} + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +struct VersionProbeHeader { + schema_version: String, +} /// OpenSSF Security Insights manifest. /// +/// Supports both v1 (SECURITY-INSIGHTS.yml) and v2 (security-insights.yml) +/// of the specification. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SecurityInsights { + /// Relative path (from repository root) where the manifest was found. + pub manifest_path: PathBuf, + /// Parsed manifest content. + pub version: SecurityInsightsVersion, +} + +/// Parsed manifest content, either v1 or v2. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum SecurityInsightsVersion { + V2(SecurityInsightsV2), + V1(SecurityInsightsV1), +} + +impl SecurityInsights { + /// Create a new SecurityInsights instance from the first manifest file + /// found at the root path provided. + pub(crate) fn new(root: &Path) -> Result> { + for candidate in MANIFEST_CANDIDATES { + let full_path = root.join(candidate); + if !full_path.exists() { + continue; + } + let content = util::fs::read_to_string(&full_path) + .context("error reading security insights manifest file")?; + + // Peek at schema-version to decide which struct to deserialize into. + let probe: VersionProbe = serde_yaml::from_str(&content) + .context("invalid security insights manifest (cannot read header)")?; + + let version = if probe.header.schema_version.starts_with("2.") { + let v2: SecurityInsightsV2 = serde_yaml::from_str(&content) + .context("invalid security insights v2 manifest")?; + SecurityInsightsVersion::V2(v2) + } else if probe.header.schema_version.starts_with("1.") { + let v1: SecurityInsightsV1 = serde_yaml::from_str(&content) + .context("invalid security insights v1 manifest")?; + SecurityInsightsVersion::V1(v1) + } else { + return Ok(None); + }; + + return Ok(Some(SecurityInsights { + manifest_path: PathBuf::from(candidate), + version, + })); + } + Ok(None) + } + + /// Return the dependency policy URL, abstracting over v1 and v2 paths. + pub(crate) fn dependency_policy_url(&self) -> Option<&str> { + match &self.version { + SecurityInsightsVersion::V1(v1) => v1 + .dependencies + .as_ref() + .and_then(|d| d.env_dependencies_policy.as_ref()) + .and_then(|p| p.policy_url.as_deref()), + SecurityInsightsVersion::V2(v2) => v2 + .repository + .as_ref() + .and_then(|r| r.documentation.as_ref()) + .and_then(|d| d.dependency_management_policy.as_deref()), + } + } +} + +// --------------------------------------------------------------------------- +// v2 types +// --------------------------------------------------------------------------- + +/// OpenSSF Security Insights v2 manifest. +/// +/// Note: the types defined below do not contain *all* the fields available in +/// the specification, just the ones needed by CLOMonitor. +/// +/// Covers schema versions 2.0.0 through 2.2.0+. Minor versions only add +/// optional fields, so a single set of structs with `Option` handles all. +/// +/// For more details please see the spec documentation: +/// https://github.com/ossf/security-insights/blob/v2.2.0/spec/schema.cue +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct SecurityInsightsV2 { + pub header: HeaderV2, + pub project: Option, + pub repository: Option, +} + +/// High-level metadata about the schema (v2). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct HeaderV2 { + pub schema_version: String, + pub last_updated: String, + pub last_reviewed: String, + pub url: String, + pub comment: Option, + pub project_si_source: Option, +} + +/// Project information (v2). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct ProjectV2 { + pub name: String, +} + +/// Repository information (v2). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct RepositoryV2 { + pub url: String, + pub status: String, + pub documentation: Option, +} + +/// Repository documentation links (v2). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct RepositoryDocumentationV2 { + pub dependency_management_policy: Option, +} + +// --------------------------------------------------------------------------- +// v1 types (legacy) +// --------------------------------------------------------------------------- + +/// OpenSSF Security Insights v1 manifest. +/// /// Note: the types defined below do not contain *all* the fields available in /// the specification, just the ones needed by CLOMonitor. /// @@ -17,29 +169,15 @@ pub(crate) const SECURITY_INSIGHTS_MANIFEST_FILE: &str = "SECURITY-INSIGHTS.yml" /// https://github.com/ossf/security-insights-spec/blob/v1.0.0/specification.md #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) struct SecurityInsights { +pub(crate) struct SecurityInsightsV1 { pub contribution_policy: ContributionPolicy, pub dependencies: Option, pub distribution_points: Vec, - pub header: Header, + pub header: HeaderV1, pub project_lifecycle: ProjectLifecycle, pub security_artifacts: Option, pub security_contacts: Vec, - pub vulnerability_reporting: VulnerabilityReporting, -} - -impl SecurityInsights { - /// Create a new SecurityInsights instance from the manifest file located - /// at the path provided. - pub(crate) fn new(path: &Path) -> Result> { - let manifest_path = path.join(SECURITY_INSIGHTS_MANIFEST_FILE); - if !Path::new(&manifest_path).exists() { - return Ok(None); - } - let content = util::fs::read_to_string(manifest_path) - .context("error reading security insights manifest file")?; - serde_yaml::from_str(&content).context("invalid security insights manifest") - } + pub vulnerability_reporting: VulnerabilityReportingV1, } /// Project's contribution rules, requirements, and policies. @@ -66,10 +204,10 @@ pub(crate) struct EnvDependenciesPolicy { pub policy_url: Option, } -/// High-level information about the project. +/// High-level information about the project (v1). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) struct Header { +pub(crate) struct HeaderV1 { pub expiration_date: String, pub project_url: String, pub schema_version: String, @@ -128,10 +266,10 @@ pub(crate) struct SecurityContact { pub value: String, } -/// Policies and procedures about how to report properly a security issue. +/// Policies and procedures about how to report properly a security issue (v1). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) struct VulnerabilityReporting { +pub(crate) struct VulnerabilityReportingV1 { accepts_vulnerability_reports: bool, } @@ -139,43 +277,127 @@ pub(crate) struct VulnerabilityReporting { mod tests { use super::*; - const TESTDATA_PATH: &str = "src/testdata/security-insights-v1"; + const TESTDATA_PATH_V2: &str = "src/testdata/security-insights-v2"; + const TESTDATA_PATH_V1: &str = "src/testdata/security-insights-v1"; + + // ----------------------------------------------------------------------- + // General discovery tests + // ----------------------------------------------------------------------- #[test] fn new_returns_none_when_file_does_not_exist() { - let result = SecurityInsights::new(&Path::new(TESTDATA_PATH).join("not-found")).unwrap(); + let result = SecurityInsights::new(&Path::new(TESTDATA_PATH_V2).join("not-found")).unwrap(); assert!(result.is_none()); } + // ----------------------------------------------------------------------- + // v1 tests + // ----------------------------------------------------------------------- + #[test] - fn new_returns_error_when_file_is_invalid() { - let result = SecurityInsights::new(&Path::new(TESTDATA_PATH).join("invalid")); + fn new_returns_error_when_v1_file_is_invalid() { + let result = SecurityInsights::new(&Path::new(TESTDATA_PATH_V1).join("invalid")); assert!(result.is_err()); } #[test] - fn new_parses_valid_manifest() { - let result = SecurityInsights::new(Path::new(TESTDATA_PATH)).unwrap(); + fn new_parses_valid_v1_manifest() { + let result = SecurityInsights::new(Path::new(TESTDATA_PATH_V1)).unwrap(); assert!(result.is_some()); - let insights = result.unwrap(); + let si = result.unwrap(); + + assert_eq!(si.manifest_path, PathBuf::from("SECURITY-INSIGHTS.yml")); + let SecurityInsightsVersion::V1(v1) = &si.version else { + panic!("expected V1"); + }; - assert_eq!(insights.header.expiration_date, "2024-09-28T01:00:00.000Z"); + assert_eq!(v1.header.expiration_date, "2024-09-28T01:00:00.000Z"); assert_eq!( - insights.header.project_url, + v1.header.project_url, "https://github.com/ossf/security-insights-spec" ); - assert_eq!(insights.header.schema_version, "1.0.0"); - assert!(insights.contribution_policy.accepts_automated_pull_requests); - assert!(insights.contribution_policy.accepts_pull_requests); - assert!(!insights.project_lifecycle.bug_fixes_only); - assert_eq!(insights.project_lifecycle.status, "active"); - assert!( - insights - .vulnerability_reporting - .accepts_vulnerability_reports + assert_eq!(v1.header.schema_version, "1.0.0"); + assert!(v1.contribution_policy.accepts_automated_pull_requests); + assert!(v1.contribution_policy.accepts_pull_requests); + assert!(!v1.project_lifecycle.bug_fixes_only); + assert_eq!(v1.project_lifecycle.status, "active"); + assert!(v1.vulnerability_reporting.accepts_vulnerability_reports); + assert_eq!(v1.security_contacts.len(), 1); + assert_eq!(v1.security_contacts[0].kind, "email"); + assert_eq!(v1.security_contacts[0].value, "security@openssf.org"); + } + + // ----------------------------------------------------------------------- + // v2 tests + // ----------------------------------------------------------------------- + + #[test] + fn new_returns_error_when_v2_file_is_invalid() { + let result = SecurityInsights::new(&Path::new(TESTDATA_PATH_V2).join("invalid")); + assert!(result.is_err()); + } + + #[test] + fn new_parses_valid_v2_manifest() { + let result = SecurityInsights::new(Path::new(TESTDATA_PATH_V2)).unwrap(); + assert!(result.is_some()); + let si = result.unwrap(); + + assert_eq!(si.manifest_path, PathBuf::from("security-insights.yml")); + let SecurityInsightsVersion::V2(v2) = &si.version else { + panic!("expected V2"); + }; + + assert_eq!(v2.header.schema_version, "2.0.0"); + assert_eq!( + v2.header.url, + "https://example.com/foo/bar/raw/branch/main/security-insights.yml" + ); + assert_eq!(v2.repository.as_ref().unwrap().status, "active"); + } + + #[test] + fn new_finds_v2_in_github_dir() { + let result = + SecurityInsights::new(Path::new("src/testdata/security-insights-v2-github")).unwrap(); + assert!(result.is_some()); + let si = result.unwrap(); + + assert_eq!( + si.manifest_path, + PathBuf::from(".github/security-insights.yml") ); - assert_eq!(insights.security_contacts.len(), 1); - assert_eq!(insights.security_contacts[0].kind, "email"); - assert_eq!(insights.security_contacts[0].value, "security@openssf.org"); + assert!(matches!(si.version, SecurityInsightsVersion::V2(_))); + } + + // ----------------------------------------------------------------------- + // Helper method tests + // ----------------------------------------------------------------------- + + #[test] + fn dependency_policy_url_v1() { + let si = SecurityInsights::new(Path::new(TESTDATA_PATH_V1)) + .unwrap() + .unwrap(); + // The v1 test fixture does not have env-dependencies-policy set. + assert!(si.dependency_policy_url().is_none()); + } + + #[test] + fn dependency_policy_url_v2() { + let si = SecurityInsights::new(Path::new(TESTDATA_PATH_V2)) + .unwrap() + .unwrap(); + assert_eq!( + si.dependency_policy_url(), + Some("https://example.com/dependency-management-policy") + ); + } + + #[test] + fn new_returns_none_for_unsupported_version() { + let result = + SecurityInsights::new(Path::new("src/testdata/security-insights-unsupported")).unwrap(); + assert!(result.is_none()); } } diff --git a/clomonitor-core/src/linter/checks/dependencies_policy.rs b/clomonitor-core/src/linter/checks/dependencies_policy.rs index 4dcb53d9..73370f2e 100644 --- a/clomonitor-core/src/linter/checks/dependencies_policy.rs +++ b/clomonitor-core/src/linter/checks/dependencies_policy.rs @@ -18,11 +18,9 @@ pub(crate) fn check(input: &CheckInput) -> Result { .as_ref() .map_err(|e| format_err!("{e:?}"))? .as_ref() - .and_then(|si| si.dependencies.as_ref()) - .and_then(|de| de.env_dependencies_policy.as_ref()) - .and_then(|dp| dp.policy_url.as_ref()) + .and_then(|si| si.dependency_policy_url()) { - return Ok(CheckOutput::passed().url(Some(policy_url.clone()))); + return Ok(CheckOutput::passed().url(Some(policy_url.to_owned()))); } Ok(CheckOutput::not_passed()) } diff --git a/clomonitor-core/src/linter/checks/security_insights.rs b/clomonitor-core/src/linter/checks/security_insights.rs index 4c20d604..9275f891 100644 --- a/clomonitor-core/src/linter/checks/security_insights.rs +++ b/clomonitor-core/src/linter/checks/security_insights.rs @@ -1,10 +1,8 @@ -use std::path::Path; - use anyhow::{Result, format_err}; use crate::linter::{CheckId, CheckOutput, CheckSet, check::CheckInput}; -use super::datasource::{github, security_insights::SECURITY_INSIGHTS_MANIFEST_FILE}; +use super::datasource::github; /// Check identifier. pub(crate) const ID: CheckId = "security_insights"; @@ -23,9 +21,9 @@ pub(crate) fn check(input: &CheckInput) -> Result { .as_ref() .map_err(|e| format_err!("{e:?}"))? { - Some(_) => { + Some(si) => { let url = github::build_url( - Path::new(SECURITY_INSIGHTS_MANIFEST_FILE), + &si.manifest_path, &input.gh_md.owner.login, &input.gh_md.name, &github::default_branch(input.gh_md.default_branch_ref.as_ref()), diff --git a/clomonitor-core/src/testdata/security-insights-unsupported/security-insights.yml b/clomonitor-core/src/testdata/security-insights-unsupported/security-insights.yml new file mode 100644 index 00000000..bf29dea5 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-unsupported/security-insights.yml @@ -0,0 +1,5 @@ +header: + schema-version: 3.0.0 + last-updated: '2025-03-01' + last-reviewed: '2025-04-01' + url: https://example.com/security-insights.yml diff --git a/clomonitor-core/src/testdata/security-insights-v2-github/.github/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2-github/.github/security-insights.yml new file mode 100644 index 00000000..e96ad25a --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2-github/.github/security-insights.yml @@ -0,0 +1,42 @@ +header: + schema-version: 2.0.0 + last-updated: '2025-03-01' + last-reviewed: '2025-04-01' + url: https://example.com/foo/bar/raw/branch/main/.github/security-insights.yml + +project: + name: FooBar + administrators: + - name: Joe Dohn + affiliation: Foo + email: joe.bob@example.com + social: https://social.example.com/joebob + primary: true + repositories: + - name: Foo + url: https://example.com/foobar/foo + comment: | + Foo is the core repo for FooBar. + vulnerability-reporting: + reports-accepted: true + bug-bounty-available: false + +repository: + url: https://example.com/foobar/foo + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Alice White + affiliation: Foo Bar + email: alicewhite@example.com + social: https://social.example.com/alicewhite + primary: true + license: + url: https://example.com/LICENSE + expression: MIT + security: + assessments: + self: + comment: | + Self assessment has not yet been completed. diff --git a/clomonitor-core/src/testdata/security-insights-v2/invalid/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/invalid/security-insights.yml new file mode 100644 index 00000000..72c7340e --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/invalid/security-insights.yml @@ -0,0 +1,6 @@ +header: + schema-version: 2.0.0 + last-updated: '2025-03-01' +repository: + status: not-a-valid-file + missing-required-fields: true diff --git a/clomonitor-core/src/testdata/security-insights-v2/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/security-insights.yml new file mode 100644 index 00000000..327bf403 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/security-insights.yml @@ -0,0 +1,44 @@ +header: + schema-version: 2.0.0 + last-updated: '2025-03-01' + last-reviewed: '2025-04-01' + url: https://example.com/foo/bar/raw/branch/main/security-insights.yml + +project: + name: FooBar + administrators: + - name: Joe Dohn + affiliation: Foo + email: joe.bob@example.com + social: https://social.example.com/joebob + primary: true + repositories: + - name: Foo + url: https://example.com/foobar/foo + comment: | + Foo is the core repo for FooBar. + vulnerability-reporting: + reports-accepted: true + bug-bounty-available: false + +repository: + url: https://example.com/foobar/foo + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Alice White + affiliation: Foo Bar + email: alicewhite@example.com + social: https://social.example.com/alicewhite + primary: true + license: + url: https://example.com/LICENSE + expression: MIT + documentation: + dependency-management-policy: https://example.com/dependency-management-policy + security: + assessments: + self: + comment: | + Self assessment has not yet been completed. diff --git a/docs/checks.md b/docs/checks.md index 93eb8f89..26e332c8 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -591,7 +591,11 @@ Project should provide a dependencies policy that describes how dependencies are This check passes if: -- The url of the dependencies policy is available in the `dependencies > env-dependencies-policy` section of the [OpenSSF Security Insights](https://github.com/ossf/security-insights-spec/blob/v1.0.0/specification.md) *manifest file* (`SECURITY-INSIGHTS.yml`) that should be located at the root of the repository. +- The url of the dependencies policy is provided in the [OpenSSF Security Insights](https://github.com/ossf/security-insights) *manifest file*. Both v1 and v2 of the specification are supported: + - **v2** (`security-insights.yml`): available in `repository > documentation > dependency-management-policy` + - **v1** (`SECURITY-INSIGHTS.yml`): available in `dependencies > env-dependencies-policy > policy-url` + + The manifest file is searched for in the following locations (first match wins): `security-insights.yml` (root), `.github/security-insights.yml`, `.gitlab/security-insights.yml`, `SECURITY-INSIGHTS.yml` (root). ### Dependency update tool (from OpenSSF Scorecard) @@ -613,11 +617,18 @@ This check determines whether the project is actively maintained. **ID**: `security_insights` -Projects should provide an [OpenSSF Security Insights](https://github.com/ossf/security-insights-spec/blob/v1.0.0/specification.md) manifest file. +Projects should provide an [OpenSSF Security Insights](https://github.com/ossf/security-insights) manifest file. Both v1 and v2 of the specification are supported. This check passes if: -- A valid OpenSSF Security Insights *manifest file* (`SECURITY-INSIGHTS.yml`) is found at the root of the repository. +- A valid OpenSSF Security Insights *manifest file* is found. The following locations are searched in order (first match wins): + +```sh +"security-insights.yml" # v2 (root) +".github/security-insights.yml" # v2 (.github) +".gitlab/security-insights.yml" # v2 (.gitlab) +"SECURITY-INSIGHTS.yml" # v1 (root) +``` ### Security policy