diff --git a/Cargo.lock b/Cargo.lock index eb7fa79d..0d955511 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -627,6 +627,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "time", "tokio", "tracing", diff --git a/clomonitor-core/Cargo.toml b/clomonitor-core/Cargo.toml index 65ec9a85..4103b2e4 100644 --- a/clomonitor-core/Cargo.toml +++ b/clomonitor-core/Cargo.toml @@ -33,4 +33,5 @@ tracing = { workspace = true } which = { workspace = true } [dev-dependencies] +tempfile = { workspace = true } wiremock = { workspace = true } diff --git a/clomonitor-core/src/linter/checks/datasource/github/mod.rs b/clomonitor-core/src/linter/checks/datasource/github/mod.rs index edbad4c2..8d1b01eb 100644 --- a/clomonitor-core/src/linter/checks/datasource/github/mod.rs +++ b/clomonitor-core/src/linter/checks/datasource/github/mod.rs @@ -95,7 +95,7 @@ pub(crate) fn build_url(path: &Path, owner: &str, repo: &str, branch: &str) -> S owner, repo, branch, - path.to_string_lossy(), + path_to_url(path), ) } @@ -257,6 +257,17 @@ fn get_owner_and_repo(repo_url: &str) -> Result<(String, String)> { Ok((c["org"].to_string(), c["repo"].to_string())) } +/// Serialize a repository path using URL separators. +fn path_to_url(path: &Path) -> String { + path.components() + .filter_map(|component| match component { + std::path::Component::Normal(part) => Some(part.to_string_lossy()), + _ => None, + }) + .collect::>() + .join("/") +} + #[cfg(test)] mod tests { use super::*; @@ -275,6 +286,19 @@ mod tests { ); } + #[test] + fn build_url_works_with_nested_paths() { + assert_eq!( + build_url( + Path::new(".github").join("security-insights.yml").as_path(), + "owner", + "repo", + "main" + ), + "https://github.com/owner/repo/blob/main/.github/security-insights.yml".to_string() + ); + } + #[test] fn default_branch_some() { let r = MdRepositoryDefaultBranchRef { diff --git a/clomonitor-core/src/linter/checks/datasource/security_insights/mod.rs b/clomonitor-core/src/linter/checks/datasource/security_insights/mod.rs new file mode 100644 index 00000000..1b8fe7db --- /dev/null +++ b/clomonitor-core/src/linter/checks/datasource/security_insights/mod.rs @@ -0,0 +1,522 @@ +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; + +use anyhow::{Context, Result, format_err}; +use serde::Deserialize; + +use crate::linter::util; + +pub(crate) mod v1; +pub(crate) mod v2; + +/// Primary Security Insights manifest file name. +const PRIMARY_MANIFEST_FILE: &str = "security-insights.yml"; + +/// Legacy Security Insights manifest file name. +const LEGACY_MANIFEST_FILE: &str = "SECURITY-INSIGHTS.yml"; + +/// Version-specific Security Insights manifest representation. +#[derive(Debug, Clone, PartialEq)] +enum Manifest { + V1(Box), + V2(Box), +} + +/// OpenSSF Security Insights manifest. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SecurityInsights { + manifest: Manifest, + manifest_rel_path: PathBuf, +} + +impl SecurityInsights { + /// Create a new SecurityInsights instance by scanning the supported + /// manifest locations under the path provided, stopping at the first v2 + /// manifest found and otherwise falling back to the first v1 manifest + /// found. + pub(crate) fn new(path: &Path) -> Result> { + let mut first_v1_manifest = None; + + // Check supported manifest locations in order of preference + for manifest_rel_path in [ + Path::new(PRIMARY_MANIFEST_FILE), + Path::new(".github").join(PRIMARY_MANIFEST_FILE).as_path(), + Path::new(LEGACY_MANIFEST_FILE), + ] { + if let Some(manifest_path) = resolve_manifest_path(path, manifest_rel_path)? { + let content = util::fs::read_to_string(&manifest_path) + .context("error reading security insights manifest file")?; + + match detect_manifest_version(&content)? { + ManifestVersion::V1 => { + // Store the first v1 manifest found in case no v2 manifest is found + if first_v1_manifest.is_none() { + first_v1_manifest = Some((content, manifest_rel_path.to_path_buf())); + } + } + ManifestVersion::V2 => { + // Stop at the first v2 manifest found + let manifest = v2::SecurityInsights::parse_content(&content)?; + return Ok(Some(Self { + manifest: Manifest::V2(Box::new(manifest)), + manifest_rel_path: manifest_rel_path.to_path_buf(), + })); + } + } + } + } + + // No v2 manifest found, fallback to v1 if available + if let Some((content, manifest_rel_path)) = first_v1_manifest { + let manifest = v1::SecurityInsights::parse_content(&content)?; + return Ok(Some(Self { + manifest: Manifest::V1(Box::new(manifest)), + manifest_rel_path, + })); + } + + Ok(None) + } + + /// Return the dependencies policy url when it is available. + pub(crate) fn dependencies_policy_url(&self) -> Option<&str> { + match &self.manifest { + Manifest::V1(manifest) => manifest + .dependencies + .as_ref() + .and_then(|dependencies| dependencies.env_dependencies_policy.as_ref()) + .and_then(|policy| policy.policy_url.as_deref()), + Manifest::V2(manifest) => manifest + .repository + .as_ref() + .and_then(|repository| repository.documentation.as_ref()) + .and_then(|documentation| documentation.dependency_management_policy.as_deref()), + } + } + + /// Return the relative path of the matched manifest. + pub(crate) fn manifest_rel_path(&self) -> &Path { + &self.manifest_rel_path + } +} + +/// Find the manifest path matching the relative path provided exactly. +fn resolve_manifest_path(root: &Path, manifest_rel_path: &Path) -> Result> { + // Walk each path component ensuring the on-disk names match exactly + let mut current = root.to_path_buf(); + for component in manifest_rel_path.components() { + let Component::Normal(name) = component else { + return Ok(None); + }; + + // Stop if any parent component is not a directory + if !current.is_dir() { + return Ok(None); + } + + // Compare directory entries directly to avoid filesystem case folding + let mut next = None; + for entry in fs::read_dir(¤t)? { + let entry = entry?; + if entry.file_name() == name { + if entry.file_type()?.is_symlink() { + return Ok(None); + } + + next = Some(entry.path()); + break; + } + } + + let Some(path) = next else { + return Ok(None); + }; + current = path; + } + + Ok(Some(current)) +} + +// Manifest version detection + +/// Minimal Security Insights manifest header used for version detection. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct Header { + schema_version: String, +} + +/// Version declared by the manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManifestVersion { + V1, + V2, +} + +/// Minimal Security Insights manifest used for version detection. +#[derive(Debug, Deserialize)] +struct VersionedManifest { + header: Header, +} + +/// Detect the manifest version using the `header.schema-version` field. +fn detect_manifest_version(content: &str) -> Result { + let manifest: VersionedManifest = + serde_yaml::from_str(content).context("invalid security insights manifest")?; + + if manifest.header.schema_version.starts_with("1.") { + Ok(ManifestVersion::V1) + } else if manifest.header.schema_version.starts_with("2.") { + Ok(ManifestVersion::V2) + } else { + Err(format_err!("invalid security insights manifest")) + } +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use std::os::unix::fs::symlink; + + use tempfile::tempdir; + + use super::*; + + const TESTDATA_PATH: &str = "src/testdata"; + + #[test] + fn new_falls_back_to_v1_manifest() { + let result = + SecurityInsights::new(&Path::new(TESTDATA_PATH).join("security-insights-v1/root")) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v1/dependencies-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(LEGACY_MANIFEST_FILE) + ); + } + + #[cfg(unix)] + #[test] + fn new_ignores_only_symlinked_github_path() { + let repository_root = tempdir().unwrap(); + let external_manifest_root = Path::new(TESTDATA_PATH) + .join("security-insights-v2/github/.github") + .canonicalize() + .unwrap(); + + symlink( + &external_manifest_root, + repository_root.path().join(".github"), + ) + .unwrap(); + + let result = SecurityInsights::new(repository_root.path()); + + assert!(result.unwrap().is_none()); + } + + #[cfg(unix)] + #[test] + fn new_ignores_symlinked_lower_priority_github_path() { + let repository_root = tempdir().unwrap(); + let external_manifest_root = Path::new(TESTDATA_PATH) + .join("security-insights-v2/github/.github") + .canonicalize() + .unwrap(); + + fs::copy( + Path::new(TESTDATA_PATH) + .join("security-insights-v2/root") + .join(PRIMARY_MANIFEST_FILE), + repository_root.path().join(PRIMARY_MANIFEST_FILE), + ) + .unwrap(); + symlink( + &external_manifest_root, + repository_root.path().join(".github"), + ) + .unwrap(); + + let result = SecurityInsights::new(repository_root.path()).unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(PRIMARY_MANIFEST_FILE) + ); + } + + #[cfg(unix)] + #[test] + fn new_ignores_symlinked_root_manifest_and_uses_github_v2() { + let repository_root = tempdir().unwrap(); + let external_manifest_path = Path::new(TESTDATA_PATH) + .join("security-insights-v2/root") + .join(PRIMARY_MANIFEST_FILE) + .canonicalize() + .unwrap(); + + symlink( + &external_manifest_path, + repository_root.path().join(PRIMARY_MANIFEST_FILE), + ) + .unwrap(); + fs::create_dir(repository_root.path().join(".github")).unwrap(); + fs::copy( + Path::new(TESTDATA_PATH) + .join("security-insights-v2/github/.github") + .join(PRIMARY_MANIFEST_FILE), + repository_root + .path() + .join(".github") + .join(PRIMARY_MANIFEST_FILE), + ) + .unwrap(); + + let result = SecurityInsights::new(repository_root.path()).unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/github/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(".github").join(PRIMARY_MANIFEST_FILE).as_path() + ); + } + + #[test] + fn new_prefers_later_v2_manifest_over_earlier_invalid_v1_manifest() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH) + .join("security-insights-v1-and-v2/prefer-legacy-v2-over-invalid-github-v1"), + ) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/legacy-invalid-v1/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(LEGACY_MANIFEST_FILE) + ); + } + + #[test] + fn new_prefers_later_v2_manifest_over_earlier_v1_manifest() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH) + .join("security-insights-v1-and-v2/prefer-legacy-v2-over-github-v1"), + ) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/legacy/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(LEGACY_MANIFEST_FILE) + ); + } + + #[test] + fn new_returns_error_when_v1_manifest_is_invalid() { + let result = + SecurityInsights::new(&Path::new(TESTDATA_PATH).join("security-insights-v1/invalid")); + + assert_eq!( + result.unwrap_err().to_string(), + "invalid security insights manifest" + ); + } + + #[test] + fn new_returns_error_when_v2_manifest_is_invalid() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH).join("security-insights-v1-and-v2/invalid-github-v2"), + ); + + assert_eq!( + result.unwrap_err().to_string(), + "invalid security insights manifest" + ); + } + + #[test] + fn new_returns_none_when_file_does_not_exist() { + let result = + SecurityInsights::new(&Path::new(TESTDATA_PATH).join("security-insights-not-found")) + .unwrap(); + assert!(result.is_none()); + } + + #[test] + fn new_stops_at_first_v2_manifest_found() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH).join("security-insights-v1-and-v2/prefer-github-v2"), + ) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/github/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(".github").join(PRIMARY_MANIFEST_FILE).as_path() + ); + } + + #[test] + fn new_uses_github_v2_manifest_when_available() { + let result = + SecurityInsights::new(&Path::new(TESTDATA_PATH).join("security-insights-v2/github")) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/github/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(".github").join(PRIMARY_MANIFEST_FILE).as_path() + ); + } + + #[test] + fn new_uses_root_v2_manifest_when_available() { + let result = + SecurityInsights::new(&Path::new(TESTDATA_PATH).join("security-insights-v2/root")) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(PRIMARY_MANIFEST_FILE) + ); + } + + #[test] + fn new_uses_root_v2_manifest_when_github_v2_is_also_available() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH).join("security-insights-v2/prefer-root"), + ) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/root/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(PRIMARY_MANIFEST_FILE) + ); + } + + #[test] + fn new_uses_schema_version_for_legacy_manifest_path() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH).join("security-insights-v1-and-v2/v2-in-legacy-path"), + ) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v2/dependency-management-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(LEGACY_MANIFEST_FILE) + ); + } + + #[test] + fn new_uses_schema_version_for_root_security_insights_manifest() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH).join("security-insights-v1-and-v2/v1-in-primary-path"), + ) + .unwrap(); + assert!(result.is_some()); + + let insights = result.unwrap(); + assert_eq!( + insights.dependencies_policy_url(), + Some("https://example.com/v1/dependencies-policy") + ); + assert_eq!( + insights.manifest_rel_path(), + Path::new(PRIMARY_MANIFEST_FILE) + ); + } + + #[test] + fn new_validates_v2_required_fields() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH).join("security-insights-v2/invalid-header"), + ); + + assert_eq!( + result.unwrap_err().to_string(), + "invalid security insights manifest" + ); + } + + #[test] + fn new_validates_v2_required_repository_fields() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH).join("security-insights-v2/invalid-repository"), + ); + + assert_eq!( + result.unwrap_err().to_string(), + "invalid security insights manifest" + ); + } + + #[test] + fn new_validates_v2_required_sections() { + let result = SecurityInsights::new( + &Path::new(TESTDATA_PATH).join("security-insights-v2/invalid-missing-sections"), + ); + + assert_eq!( + result.unwrap_err().to_string(), + "invalid security insights manifest" + ); + } +} diff --git a/clomonitor-core/src/linter/checks/datasource/security_insights.rs b/clomonitor-core/src/linter/checks/datasource/security_insights/v1.rs similarity index 61% rename from clomonitor-core/src/linter/checks/datasource/security_insights.rs rename to clomonitor-core/src/linter/checks/datasource/security_insights/v1.rs index c3f3b47c..0efd5cb4 100644 --- a/clomonitor-core/src/linter/checks/datasource/security_insights.rs +++ b/clomonitor-core/src/linter/checks/datasource/security_insights/v1.rs @@ -1,14 +1,7 @@ -use std::path::Path; - 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"; - -/// OpenSSF Security Insights manifest. +/// 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. @@ -29,16 +22,9 @@ pub(crate) struct SecurityInsights { } 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") + /// Parse a v1 Security Insights manifest from the content provided. + pub(super) fn parse_content(content: &str) -> Result { + serde_yaml::from_str(content).context("invalid security insights manifest") } } @@ -132,50 +118,5 @@ pub(crate) struct SecurityContact { #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) struct VulnerabilityReporting { - accepts_vulnerability_reports: bool, -} - -#[cfg(test)] -mod tests { - use super::*; - - const TESTDATA_PATH: &str = "src/testdata/security-insights-v1"; - - #[test] - fn new_returns_none_when_file_does_not_exist() { - let result = SecurityInsights::new(&Path::new(TESTDATA_PATH).join("not-found")).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn new_returns_error_when_file_is_invalid() { - let result = SecurityInsights::new(&Path::new(TESTDATA_PATH).join("invalid")); - assert!(result.is_err()); - } - - #[test] - fn new_parses_valid_manifest() { - let result = SecurityInsights::new(Path::new(TESTDATA_PATH)).unwrap(); - assert!(result.is_some()); - let insights = result.unwrap(); - - assert_eq!(insights.header.expiration_date, "2024-09-28T01:00:00.000Z"); - assert_eq!( - insights.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!(insights.security_contacts.len(), 1); - assert_eq!(insights.security_contacts[0].kind, "email"); - assert_eq!(insights.security_contacts[0].value, "security@openssf.org"); - } + pub accepts_vulnerability_reports: bool, } diff --git a/clomonitor-core/src/linter/checks/datasource/security_insights/v2.rs b/clomonitor-core/src/linter/checks/datasource/security_insights/v2.rs new file mode 100644 index 00000000..5cfb7828 --- /dev/null +++ b/clomonitor-core/src/linter/checks/datasource/security_insights/v2.rs @@ -0,0 +1,193 @@ +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; + +/// 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 to validate the +/// minimum required manifest shape and extract fields used by checks. +/// +/// For more details please see the spec documentation: +/// https://security-insights.openssf.org/ +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct SecurityInsights { + pub header: Header, + pub project: Option, + pub repository: Option, +} + +impl SecurityInsights { + /// Parse a v2 Security Insights manifest from the content provided. + pub(super) fn parse_content(content: &str) -> Result { + let manifest: Self = + serde_yaml::from_str(content).context("invalid security insights manifest")?; + + manifest.validate()?; + + Ok(manifest) + } + + /// Ensure the manifest contains the minimum required v2 structure. + fn validate(&self) -> Result<()> { + // Validate required header fields + ensure!( + self.header.schema_version.starts_with("2."), + "invalid security insights manifest" + ); + ensure_non_empty(&self.header.last_reviewed)?; + ensure_non_empty(&self.header.last_updated)?; + ensure_non_empty(&self.header.url)?; + + // A valid manifest must describe at least a project or a repository + ensure!( + self.project.is_some() || self.repository.is_some(), + "invalid security insights manifest" + ); + + if let Some(project) = &self.project { + ensure_non_empty(&project.name)?; + ensure!( + !project.administrators.is_empty(), + "invalid security insights manifest" + ); + for administrator in &project.administrators { + ensure_non_empty(&administrator.name)?; + } + ensure!( + !project.repositories.is_empty(), + "invalid security insights manifest" + ); + for repository in &project.repositories { + ensure_non_empty(&repository.comment)?; + ensure_non_empty(&repository.name)?; + ensure_non_empty(&repository.url)?; + } + } + + if let Some(repository) = &self.repository { + ensure_non_empty(&repository.license.expression)?; + ensure_non_empty(&repository.license.url)?; + ensure_non_empty(&repository.security.assessments.self_assessment.comment)?; + ensure_non_empty(&repository.status)?; + ensure_non_empty(&repository.url)?; + ensure!( + !repository.core_team.is_empty(), + "invalid security insights manifest" + ); + for member in &repository.core_team { + ensure_non_empty(&member.name)?; + } + if let Some(documentation) = &repository.documentation + && let Some(policy) = &documentation.dependency_management_policy + { + ensure_non_empty(policy)?; + } + } + + Ok(()) + } +} + +/// Assessment information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Assessment { + pub comment: String, +} + +/// Assessments information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Assessments { + #[serde(rename = "self")] + pub self_assessment: Assessment, +} + +/// Contact information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Contact { + pub name: String, + pub primary: bool, +} + +/// High-level information about the manifest. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Header { + pub last_reviewed: String, + pub last_updated: String, + pub schema_version: String, + pub url: String, +} + +/// License information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct License { + pub expression: String, + pub url: String, +} + +/// High-level information about the project. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Project { + pub administrators: Vec, + pub name: String, + pub repositories: Vec, + pub vulnerability_reporting: VulnerabilityReporting, +} + +/// Project repository information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct ProjectRepository { + pub comment: String, + pub name: String, + pub url: String, +} + +/// Repository information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Repository { + pub accepts_automated_change_request: bool, + pub accepts_change_request: bool, + pub core_team: Vec, + pub license: License, + pub security: SecurityPosture, + pub status: String, + pub url: String, + + pub documentation: Option, +} + +/// Repository documentation information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct RepositoryDocumentation { + pub dependency_management_policy: Option, +} + +/// Security posture information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct SecurityPosture { + pub assessments: Assessments, +} + +/// Vulnerability reporting information. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct VulnerabilityReporting { + pub bug_bounty_available: bool, + pub reports_accepted: bool, +} + +/// Ensure a required string field is not empty. +fn ensure_non_empty(value: &str) -> Result<()> { + ensure!(!value.is_empty(), "invalid security insights manifest"); + Ok(()) +} diff --git a/clomonitor-core/src/linter/checks/dependencies_policy.rs b/clomonitor-core/src/linter/checks/dependencies_policy.rs index 4dcb53d9..399536e1 100644 --- a/clomonitor-core/src/linter/checks/dependencies_policy.rs +++ b/clomonitor-core/src/linter/checks/dependencies_policy.rs @@ -18,11 +18,87 @@ 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(|manifest| manifest.dependencies_policy_url()) { - return Ok(CheckOutput::passed().url(Some(policy_url.clone()))); + return Ok(CheckOutput::passed().url(Some(policy_url.to_string()))); } Ok(CheckOutput::not_passed()) } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use anyhow::format_err; + + use crate::linter::{ + LinterInput, + datasource::{github::md::MdRepository, security_insights::SecurityInsights}, + }; + + use super::*; + + #[test] + fn not_passed_when_policy_url_is_missing() { + let output = check(&CheckInput { + li: &LinterInput::default(), + cm_md: None, + gh_md: MdRepository::default(), + scorecard: Err(format_err!("no scorecard available")), + security_insights: SecurityInsights::new( + &Path::new("src/testdata/security-insights-v2/invalid-no-policy") + .canonicalize() + .unwrap(), + ), + }) + .unwrap(); + + assert_eq!(output, CheckOutput::not_passed()); + } + + #[test] + fn passed_when_policy_url_is_available_in_v1() { + let output = check(&CheckInput { + li: &LinterInput::default(), + cm_md: None, + gh_md: MdRepository::default(), + scorecard: Err(format_err!("no scorecard available")), + security_insights: SecurityInsights::new( + &Path::new("src/testdata/security-insights-v1/root") + .canonicalize() + .unwrap(), + ), + }) + .unwrap(); + + assert_eq!( + output, + CheckOutput::passed().url(Some( + "https://example.com/v1/dependencies-policy".to_string() + )) + ); + } + + #[test] + fn passed_when_policy_url_is_available_in_v2() { + let output = check(&CheckInput { + li: &LinterInput::default(), + cm_md: None, + gh_md: MdRepository::default(), + scorecard: Err(format_err!("no scorecard available")), + security_insights: SecurityInsights::new( + &Path::new("src/testdata/security-insights-v2/root") + .canonicalize() + .unwrap(), + ), + }) + .unwrap(); + + assert_eq!( + output, + CheckOutput::passed().url(Some( + "https://example.com/v2/dependency-management-policy".to_string(), + )) + ); + } +} diff --git a/clomonitor-core/src/linter/checks/security_insights.rs b/clomonitor-core/src/linter/checks/security_insights.rs index 4c20d604..5bf0c269 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(manifest) => { let url = github::build_url( - Path::new(SECURITY_INSIGHTS_MANIFEST_FILE), + manifest.manifest_rel_path(), &input.gh_md.owner.login, &input.gh_md.name, &github::default_branch(input.gh_md.default_branch_ref.as_ref()), @@ -36,3 +34,102 @@ pub(crate) fn check(input: &CheckInput) -> Result { }; Ok(output) } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use anyhow::format_err; + + use crate::linter::{ + LinterInput, + datasource::{github::md::MdRepository, security_insights::SecurityInsights}, + }; + + use super::*; + + #[test] + fn check_passes_with_v1_manifest() { + let output = check(&CheckInput { + li: &LinterInput::default(), + cm_md: None, + gh_md: github_metadata(), + scorecard: Err(format_err!("no scorecard available")), + security_insights: SecurityInsights::new( + &Path::new("src/testdata/security-insights-v1/root") + .canonicalize() + .unwrap(), + ), + }) + .unwrap(); + + assert_eq!( + output, + CheckOutput::passed().url(Some( + "https://github.com/org/repo/blob/main/SECURITY-INSIGHTS.yml".to_string(), + )) + ); + } + + #[test] + fn check_passes_with_v2_github_manifest() { + let output = check(&CheckInput { + li: &LinterInput::default(), + cm_md: None, + gh_md: github_metadata(), + scorecard: Err(format_err!("no scorecard available")), + security_insights: SecurityInsights::new( + &Path::new("src/testdata/security-insights-v2/github") + .canonicalize() + .unwrap(), + ), + }) + .unwrap(); + + assert_eq!( + output, + CheckOutput::passed().url(Some( + "https://github.com/org/repo/blob/main/.github/security-insights.yml".to_string(), + )) + ); + } + + #[test] + fn check_passes_with_v2_root_manifest() { + let output = check(&CheckInput { + li: &LinterInput::default(), + cm_md: None, + gh_md: github_metadata(), + scorecard: Err(format_err!("no scorecard available")), + security_insights: SecurityInsights::new( + &Path::new("src/testdata/security-insights-v2/root") + .canonicalize() + .unwrap(), + ), + }) + .unwrap(); + + assert_eq!( + output, + CheckOutput::passed().url(Some( + "https://github.com/org/repo/blob/main/security-insights.yml".to_string(), + )) + ); + } + + // Helpers. + + fn github_metadata() -> MdRepository { + MdRepository { + default_branch_ref: Some(super::github::md::MdRepositoryDefaultBranchRef { + name: "main".to_string(), + }), + name: "repo".to_string(), + owner: super::github::md::MdRepositoryOwner { + login: "org".to_string(), + on: super::github::md::MdRepositoryOwnerOn::Organization, + }, + ..MdRepository::default() + } + } +} diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/invalid-github-v2/.github/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/invalid-github-v2/.github/security-insights.yml new file mode 100644 index 00000000..c9d75d7f --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/invalid-github-v2/.github/security-insights.yml @@ -0,0 +1,5 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/.github/security-insights.yml diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/invalid-github-v2/SECURITY-INSIGHTS.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/invalid-github-v2/SECURITY-INSIGHTS.yml new file mode 100644 index 00000000..9cec2a95 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/invalid-github-v2/SECURITY-INSIGHTS.yml @@ -0,0 +1,22 @@ +header: + schema-version: 1.0.0 + last-updated: '2023-09-28' + last-reviewed: '2023-09-28' + expiration-date: '2024-09-28T01:00:00.000Z' + project-url: https://github.com/ossf/security-insights-spec + project-release: '1.0.0' +project-lifecycle: + status: active + bug-fixes-only: false +contribution-policy: + accepts-pull-requests: true + accepts-automated-pull-requests: true +security-contacts: + - type: email + value: security@example.com +vulnerability-reporting: + accepts-vulnerability-reports: true +dependencies: + env-dependencies-policy: + policy-url: https://example.com/v1/dependencies-policy +distribution-points: [] diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-github-v2/.github/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-github-v2/.github/security-insights.yml new file mode 100644 index 00000000..ad42f754 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-github-v2/.github/security-insights.yml @@ -0,0 +1,22 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/.github/security-insights.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + documentation: + dependency-management-policy: https://example.com/v2/github/dependency-management-policy + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-github-v2/SECURITY-INSIGHTS.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-github-v2/SECURITY-INSIGHTS.yml new file mode 100644 index 00000000..9cec2a95 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-github-v2/SECURITY-INSIGHTS.yml @@ -0,0 +1,22 @@ +header: + schema-version: 1.0.0 + last-updated: '2023-09-28' + last-reviewed: '2023-09-28' + expiration-date: '2024-09-28T01:00:00.000Z' + project-url: https://github.com/ossf/security-insights-spec + project-release: '1.0.0' +project-lifecycle: + status: active + bug-fixes-only: false +contribution-policy: + accepts-pull-requests: true + accepts-automated-pull-requests: true +security-contacts: + - type: email + value: security@example.com +vulnerability-reporting: + accepts-vulnerability-reports: true +dependencies: + env-dependencies-policy: + policy-url: https://example.com/v1/dependencies-policy +distribution-points: [] diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-github-v1/.github/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-github-v1/.github/security-insights.yml new file mode 100644 index 00000000..bb104d27 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-github-v1/.github/security-insights.yml @@ -0,0 +1,22 @@ +header: + schema-version: 1.0.0 + last-updated: '2023-09-28' + last-reviewed: '2023-09-28' + expiration-date: '2024-09-28T01:00:00.000Z' + project-url: https://github.com/example/project +project-lifecycle: + status: active + bug-fixes-only: false +contribution-policy: + accepts-pull-requests: true + accepts-automated-pull-requests: true +distribution-points: + - https://github.com/example/project +security-contacts: + - type: email + value: security@example.com +vulnerability-reporting: + accepts-vulnerability-reports: true +dependencies: + env-dependencies-policy: + policy-url: https://example.com/v1/github/dependencies-policy diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-github-v1/SECURITY-INSIGHTS.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-github-v1/SECURITY-INSIGHTS.yml new file mode 100644 index 00000000..3c5fe4d9 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-github-v1/SECURITY-INSIGHTS.yml @@ -0,0 +1,22 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/SECURITY-INSIGHTS.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + documentation: + dependency-management-policy: https://example.com/v2/legacy/dependency-management-policy + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-invalid-github-v1/.github/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-invalid-github-v1/.github/security-insights.yml new file mode 100644 index 00000000..d8196ac9 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-invalid-github-v1/.github/security-insights.yml @@ -0,0 +1,2 @@ +header: + schema-version: 1.0.0 diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-invalid-github-v1/SECURITY-INSIGHTS.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-invalid-github-v1/SECURITY-INSIGHTS.yml new file mode 100644 index 00000000..6919ccfb --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/prefer-legacy-v2-over-invalid-github-v1/SECURITY-INSIGHTS.yml @@ -0,0 +1,22 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/SECURITY-INSIGHTS.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + documentation: + dependency-management-policy: https://example.com/v2/legacy-invalid-v1/dependency-management-policy + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/v1-in-primary-path/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/v1-in-primary-path/security-insights.yml new file mode 100644 index 00000000..8a5cb857 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/v1-in-primary-path/security-insights.yml @@ -0,0 +1,22 @@ +header: + schema-version: 1.0.0 + last-updated: '2023-09-28' + last-reviewed: '2023-09-28' + expiration-date: '2024-09-28T01:00:00.000Z' + project-url: https://github.com/example/project +project-lifecycle: + status: active + bug-fixes-only: false +contribution-policy: + accepts-pull-requests: true + accepts-automated-pull-requests: true +distribution-points: + - https://github.com/example/project +security-contacts: + - type: email + value: security@example.com +vulnerability-reporting: + accepts-vulnerability-reports: true +dependencies: + env-dependencies-policy: + policy-url: https://example.com/v1/dependencies-policy diff --git a/clomonitor-core/src/testdata/security-insights-v1-and-v2/v2-in-legacy-path/SECURITY-INSIGHTS.yml b/clomonitor-core/src/testdata/security-insights-v1-and-v2/v2-in-legacy-path/SECURITY-INSIGHTS.yml new file mode 100644 index 00000000..595ce2ee --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v1-and-v2/v2-in-legacy-path/SECURITY-INSIGHTS.yml @@ -0,0 +1,22 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/SECURITY-INSIGHTS.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + documentation: + dependency-management-policy: https://example.com/v2/dependency-management-policy + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v1/SECURITY-INSIGHTS.yml b/clomonitor-core/src/testdata/security-insights-v1/root/SECURITY-INSIGHTS.yml similarity index 58% rename from clomonitor-core/src/testdata/security-insights-v1/SECURITY-INSIGHTS.yml rename to clomonitor-core/src/testdata/security-insights-v1/root/SECURITY-INSIGHTS.yml index 27cbe005..b065afe8 100644 --- a/clomonitor-core/src/testdata/security-insights-v1/SECURITY-INSIGHTS.yml +++ b/clomonitor-core/src/testdata/security-insights-v1/root/SECURITY-INSIGHTS.yml @@ -9,34 +9,34 @@ project-lifecycle: status: active bug-fixes-only: false core-maintainers: - - github:luigigubello - - github:eddie-knight + - github:luigigubello + - github:eddie-knight contribution-policy: accepts-pull-requests: true accepts-automated-pull-requests: true code-of-conduct: https://openssf.org/community/code-of-conduct documentation: -- https://github.com/ossf/security-insights-spec/blob/main/specification.md + - https://github.com/ossf/security-insights-spec/blob/main/specification.md distribution-points: -- https://github.com/ossf/security-insights-spec + - https://github.com/ossf/security-insights-spec security-artifacts: threat-model: threat-model-created: true evidence-url: - - https://github.com/ossf/security-insights-spec/blob/main/docs/threat-model.md + - https://github.com/ossf/security-insights-spec/blob/main/docs/threat-model.md security-testing: -- tool-type: sca - tool-name: Dependabot - tool-version: latest - integration: - ad-hoc: false - ci: true - before-release: true - comment: | - Dependabot is enabled for this repo. + - tool-type: sca + tool-name: Dependabot + tool-version: latest + integration: + ad-hoc: false + ci: true + before-release: true + comment: | + Dependabot is enabled for this repo. security-contacts: -- type: email - value: security@openssf.org + - type: email + value: security@openssf.org vulnerability-reporting: accepts-vulnerability-reports: true security-policy: https://github.com/ossf/security-insights-spec/security/policy @@ -44,6 +44,8 @@ vulnerability-reporting: comment: | The first and best way to report a vulnerability is by using private security issues in GitHub. dependencies: + env-dependencies-policy: + policy-url: https://example.com/v1/dependencies-policy third-party-packages: true dependencies-lists: - - https://github.com/ossf/security-insights-spec/blob/main/validators/python/requirements.txt \ No newline at end of file + - https://github.com/ossf/security-insights-spec/blob/main/validators/python/requirements.txt 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..ad42f754 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/github/.github/security-insights.yml @@ -0,0 +1,22 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/.github/security-insights.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + documentation: + dependency-management-policy: https://example.com/v2/github/dependency-management-policy + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v2/invalid-header/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/invalid-header/security-insights.yml new file mode 100644 index 00000000..8772c016 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/invalid-header/security-insights.yml @@ -0,0 +1,19 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + url: https://example.com/raw/main/security-insights.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v2/invalid-missing-sections/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/invalid-missing-sections/security-insights.yml new file mode 100644 index 00000000..ae188820 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/invalid-missing-sections/security-insights.yml @@ -0,0 +1,5 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/security-insights.yml diff --git a/clomonitor-core/src/testdata/security-insights-v2/invalid-no-policy/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/invalid-no-policy/security-insights.yml new file mode 100644 index 00000000..f9530a95 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/invalid-no-policy/security-insights.yml @@ -0,0 +1,20 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/security-insights.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v2/invalid-repository/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/invalid-repository/security-insights.yml new file mode 100644 index 00000000..73341d77 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/invalid-repository/security-insights.yml @@ -0,0 +1,8 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/security-insights.yml +repository: + url: https://github.com/example/project + status: active diff --git a/clomonitor-core/src/testdata/security-insights-v2/prefer-root/.github/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/prefer-root/.github/security-insights.yml new file mode 100644 index 00000000..ad42f754 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/prefer-root/.github/security-insights.yml @@ -0,0 +1,22 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/.github/security-insights.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + documentation: + dependency-management-policy: https://example.com/v2/github/dependency-management-policy + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v2/prefer-root/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/prefer-root/security-insights.yml new file mode 100644 index 00000000..5b4d4088 --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/prefer-root/security-insights.yml @@ -0,0 +1,22 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/security-insights.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + documentation: + dependency-management-policy: https://example.com/v2/root/dependency-management-policy + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/clomonitor-core/src/testdata/security-insights-v2/root/security-insights.yml b/clomonitor-core/src/testdata/security-insights-v2/root/security-insights.yml new file mode 100644 index 00000000..72ff981b --- /dev/null +++ b/clomonitor-core/src/testdata/security-insights-v2/root/security-insights.yml @@ -0,0 +1,22 @@ +header: + schema-version: 2.0.0 + last-reviewed: '2025-04-01' + last-updated: '2025-03-01' + url: https://example.com/raw/main/security-insights.yml +repository: + url: https://github.com/example/project + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Example maintainer + primary: true + documentation: + dependency-management-policy: https://example.com/v2/dependency-management-policy + license: + url: https://example.com/LICENSE + expression: Apache-2.0 + security: + assessments: + self: + comment: Self assessment diff --git a/docs/checks.md b/docs/checks.md index 93eb8f89..8ca7458d 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -587,11 +587,39 @@ This check determines whether the project's GitHub Action workflows has dangerou **ID**: `dependencies_policy` -Project should provide a dependencies policy that describes how dependencies are consumed and updated. +Projects should provide a dependencies policy that describes how +dependencies are consumed and updated. 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 available in a valid + [OpenSSF Security Insights](https://security-insights.openssf.org/) + *manifest file*: +- In v1 manifests, from `dependencies > env-dependencies-policy > + policy-url`. +- In v2 manifests, from `repository > documentation > + dependency-management-policy`. + +Note that: + +- When multiple supported manifests are present, CLOMonitor inspects them in + this order: + + - `security-insights.yml` + - `.github/security-insights.yml` + - `SECURITY-INSIGHTS.yml`. + +- CLOMonitor determines each manifest version using the `header > + schema-version` field. + +- CLOMonitor selects the first v2 manifest found during that scan. + +- If no v2 manifest is found, CLOMonitor falls back to the first v1 manifest + found during that scan. + +- CLOMonitor does not fall back to a lower-priority manifest when a + selected manifest is invalid or when the selected manifest does not + include the dependencies policy URL. ### Dependency update tool (from OpenSSF Scorecard) @@ -613,11 +641,32 @@ 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://security-insights.openssf.org/) manifest file. 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 v1 or v2 *manifest file* + is found in one of CLOMonitor's supported manifest locations. + +Note that: + +- When multiple supported manifests are present, CLOMonitor inspects them in + this order: + + - `security-insights.yml` + - `.github/security-insights.yml` + - `SECURITY-INSIGHTS.yml`. + +- CLOMonitor determines each manifest version using the `header > + schema-version` field. + +- CLOMonitor selects the first v2 manifest found during that scan. + +- If no v2 manifest is found, CLOMonitor falls back to the first v1 manifest + found during that scan. + +- CLOMonitor does not fall back to a lower-priority manifest when a + selected manifest is invalid. ### Security policy diff --git a/web/src/data.tsx b/web/src/data.tsx index 7375f171..24f75c50 100644 --- a/web/src/data.tsx +++ b/web/src/data.tsx @@ -479,7 +479,7 @@ export const REPORT_OPTIONS: ReportOptionInfo = { The project provides an{' '} OpenSSF Security Insights {' '}