diff --git a/Cargo.lock b/Cargo.lock index ce79be6f..7fe77dfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,6 +373,7 @@ dependencies = [ "octocrab", "regex", "serde 1.0.136", + "serde_yaml", "tokio", "tracing", ] @@ -1902,6 +1903,18 @@ dependencies = [ "serde 1.0.136", ] +[[package]] +name = "serde_yaml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a521f2940385c165a24ee286aa8599633d162077a54bdcae2a6fd5a7bfa7a0" +dependencies = [ + "indexmap", + "ryu", + "serde 1.0.136", + "yaml-rust", +] + [[package]] name = "sha2" version = "0.10.1" diff --git a/clomonitor-core/Cargo.toml b/clomonitor-core/Cargo.toml index a7c5a6ec..01cc23ce 100644 --- a/clomonitor-core/Cargo.toml +++ b/clomonitor-core/Cargo.toml @@ -13,5 +13,6 @@ lazy_static = "1.4.0" octocrab = "0.15.4" regex = "1.5.4" serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.8.23" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1.29" diff --git a/clomonitor-core/src/linter/check.rs b/clomonitor-core/src/linter/check.rs index e080b16c..562f48cb 100644 --- a/clomonitor-core/src/linter/check.rs +++ b/clomonitor-core/src/linter/check.rs @@ -37,6 +37,33 @@ where pub case_sensitive: bool, } +/// Check if the content of any of the files that match the globs provided +/// matches any of the regular expressions given, returning the captured value +/// when there is a match. This function expects that the regular expressions +/// provided contain one capture group. +pub(crate) fn content_find(globs: Globs

, regexps: R) -> Result, Error> +where + P: IntoIterator, + P::Item: AsRef, + R: IntoIterator, + R::Item: AsRef, +{ + let mut res = Vec::::new(); + for regexp in regexps { + res.push(Regex::new(regexp.as_ref())?); + } + for path in matching_paths(globs)?.iter() { + if let Ok(content) = fs::read_to_string(path) { + for re in res.iter() { + if let Some(c) = re.captures(&content) { + return Ok(Some(c[1].to_string())); + } + } + } + } + Ok(None) +} + /// Check if the content of any of the files that match the globs provided /// matches any of the regular expressions given. pub(crate) fn content_matches(globs: Globs

, regexps: R) -> Result @@ -179,6 +206,85 @@ mod tests { const TESTDATA_PATH: &str = "src/linter/testdata"; + #[test] + fn content_find_found() { + assert_eq!( + content_find( + Globs { + root: Path::new(TESTDATA_PATH), + patterns: README_FILE, + case_sensitive: true, + }, + LICENSE_SCANNING_URL + ) + .unwrap() + .unwrap(), + "https://snyk.io/test/github/username/repo".to_string() + ); + } + + #[test] + fn content_find_not_found() { + assert_eq!( + content_find( + Globs { + root: Path::new(TESTDATA_PATH), + patterns: README_FILE, + case_sensitive: true, + }, + [r"non-existing pattern"] + ) + .unwrap(), + None + ); + } + + #[test] + fn content_find_file_not_found() { + assert_eq!( + content_find( + Globs { + root: Path::new(TESTDATA_PATH), + patterns: vec!["nonexisting"], + case_sensitive: true, + }, + [r"pattern"] + ) + .unwrap(), + None + ); + } + + #[test] + fn content_find_invalid_glob_pattern() { + assert!(matches!( + content_find( + Globs { + root: Path::new(TESTDATA_PATH), + patterns: vec!["invalid***"], + case_sensitive: true, + }, + [r"pattern"] + ), + Err(_) + )); + } + + #[test] + fn content_find_invalid_regexp() { + assert!(matches!( + content_find( + Globs { + root: Path::new(TESTDATA_PATH), + patterns: README_FILE, + case_sensitive: true, + }, + [r"***"] + ), + Err(_) + )); + } + #[test] fn content_matches_match() { assert!(content_matches( diff --git a/clomonitor-core/src/linter/metadata.rs b/clomonitor-core/src/linter/metadata.rs new file mode 100644 index 00000000..b6fb1aa2 --- /dev/null +++ b/clomonitor-core/src/linter/metadata.rs @@ -0,0 +1,69 @@ +use anyhow::Error; +use serde::Deserialize; +use std::ffi::OsStr; +use std::fs; +use std::path::Path; + +/// Metadata file name. +pub const METADATA_FILE: &str = ".clomonitor.yml"; + +/// CloMonitor metadata. +#[derive(Debug, Deserialize)] +pub struct Metadata { + pub license_scanning: Option, +} + +impl Metadata { + /// Create a new metadata instance from the contents of the file located at + /// the path provided. + pub fn from>(path: P) -> Result, Error> { + if !Path::new(&path).exists() { + return Ok(None); + } + let content = fs::read_to_string(path.as_ref())?; + Ok(serde_yaml::from_str(&content)?) + } +} + +/// License scanning section of the metadata. +#[derive(Debug, Deserialize)] +pub struct LicenseScanning { + pub url: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + const TESTDATA_PATH: &str = "src/linter/testdata"; + + #[test] + fn metadata_from_path_success() { + assert_eq!( + Metadata::from(Path::new(TESTDATA_PATH).join(METADATA_FILE)) + .unwrap() + .unwrap() + .license_scanning + .unwrap() + .url + .unwrap(), + "https://license-scanning.url" + ); + } + + #[test] + fn metadata_from_path_not_found() { + assert!(matches!( + Metadata::from(Path::new(TESTDATA_PATH).join("not-found")), + Ok(None) + )); + } + + #[test] + fn metadata_from_path_invalid_metadata_file() { + assert!(matches!( + Metadata::from(Path::new(TESTDATA_PATH).join("LICENSE")), + Err(_) + )); + } +} diff --git a/clomonitor-core/src/linter/mod.rs b/clomonitor-core/src/linter/mod.rs index db666ed0..184c2e00 100644 --- a/clomonitor-core/src/linter/mod.rs +++ b/clomonitor-core/src/linter/mod.rs @@ -5,6 +5,7 @@ use std::path::Path; use std::str::FromStr; mod check; +mod metadata; mod patterns; pub mod primary; pub mod secondary; diff --git a/clomonitor-core/src/linter/patterns.rs b/clomonitor-core/src/linter/patterns.rs index 6cafb3ac..da99428d 100644 --- a/clomonitor-core/src/linter/patterns.rs +++ b/clomonitor-core/src/linter/patterns.rs @@ -24,7 +24,10 @@ pub(crate) static ROADMAP_HEADER: [&str; 1] = [r"(?im)^#+.*roadmap.*$"]; // License pub(crate) static LICENSE_FILE: [&str; 2] = ["LICENSE*", "COPYING*"]; -pub(crate) static FOSSA_BADGE_URL: [&str; 1] = [r"https://app.fossa.*/api/projects/.*"]; +pub(crate) static LICENSE_SCANNING_URL: [&str; 2] = [ + r"\[!\[.*\]\(https://app.fossa.*/api/projects/.*\)\]\((.*)\)", + r"\[!\[.*\]\(https://snyk.io/test/github/[^/]+/[^/]+/badge.svg\)\]\((.*)\)", +]; // Best practices pub(crate) static ARTIFACTHUB_BADGE_URL: [&str; 1] = diff --git a/clomonitor-core/src/linter/primary.rs b/clomonitor-core/src/linter/primary.rs index 31661d04..3f360feb 100644 --- a/clomonitor-core/src/linter/primary.rs +++ b/clomonitor-core/src/linter/primary.rs @@ -1,5 +1,6 @@ use super::{ check::{self, Globs}, + metadata::*, patterns::*, LintOptions, }; @@ -37,7 +38,7 @@ pub struct Documentation { #[non_exhaustive] pub struct License { pub approved: Option, - pub fossa_badge: bool, + pub scanning: Option, pub spdx_id: Option, } @@ -60,6 +61,9 @@ pub struct Security { /// Lint the path provided and return a report. pub async fn lint(options: LintOptions<'_>) -> Result { + // Read and parse metadata + let md = Metadata::from(options.root.join(METADATA_FILE))?; + // Async checks: documentation, best_practices let (documentation, best_practices) = tokio::try_join!( lint_documentation(options.root, options.url), @@ -68,7 +72,7 @@ pub async fn lint(options: LintOptions<'_>) -> Result { Ok(Report { documentation, - license: lint_license(options.root)?, + license: lint_license(options.root, &md)?, best_practices, security: lint_security(options.root)?, }) @@ -184,7 +188,7 @@ async fn lint_documentation(root: &Path, repo_url: &str) -> Result Result { +fn lint_license(root: &Path, md: &Option) -> Result { // SPDX id let spdx_id = check::license(Globs { root, @@ -198,19 +202,29 @@ fn lint_license(root: &Path) -> Result { approved = Some(check::is_approved_license(spdx_id)) } - // FOSSA badge - let fossa_badge = check::content_matches( - Globs { - root, - patterns: README_FILE, - case_sensitive: true, - }, - FOSSA_BADGE_URL, - )?; + // Scanning url + let mut scanning_url: Option = None; + if let Some(md) = md { + if let Some(license_scanning) = &md.license_scanning { + if let Some(url) = &license_scanning.url { + scanning_url = Some(url.to_owned()) + } + } + } + if scanning_url.is_none() { + scanning_url = check::content_find( + Globs { + root, + patterns: README_FILE, + case_sensitive: true, + }, + LICENSE_SCANNING_URL, + )?; + } Ok(License { approved, - fossa_badge, + scanning: scanning_url, spdx_id, }) } diff --git a/clomonitor-core/src/linter/testdata/.clomonitor.yml b/clomonitor-core/src/linter/testdata/.clomonitor.yml new file mode 100644 index 00000000..eb905b17 --- /dev/null +++ b/clomonitor-core/src/linter/testdata/.clomonitor.yml @@ -0,0 +1,2 @@ +license_scanning: + url: https://license-scanning.url diff --git a/clomonitor-core/src/linter/testdata/README.md b/clomonitor-core/src/linter/testdata/README.md index de2de047..a61663cb 100644 --- a/clomonitor-core/src/linter/testdata/README.md +++ b/clomonitor-core/src/linter/testdata/README.md @@ -1,5 +1,7 @@ # Sample README file +[![Known Vulnerabilities](https://snyk.io/test/github/username/repo/badge.svg)](https://snyk.io/test/github/username/repo) + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi id ex nunc. ## Adopters diff --git a/clomonitor-core/src/score/primary.rs b/clomonitor-core/src/score/primary.rs index f68756cc..202ffd4c 100644 --- a/clomonitor-core/src/score/primary.rs +++ b/clomonitor-core/src/score/primary.rs @@ -66,7 +66,7 @@ pub(crate) fn calculate_score(report: &Report) -> Score { score.license += 60; } } - if report.license.fossa_badge { + if report.license.scanning.is_some() { score.license += 20; } if report.license.spdx_id.is_some() { @@ -138,7 +138,7 @@ mod tests { }, license: License { approved: Some(true), - fossa_badge: true, + scanning: Some("https://license-scanning.url".to_string()), spdx_id: Some("Apache-2.0".to_string()), }, best_practices: BestPractices { @@ -178,7 +178,7 @@ mod tests { }, license: License { approved: None, - fossa_badge: false, + scanning: None, spdx_id: None, }, best_practices: BestPractices { diff --git a/clomonitor-linter/src/display.rs b/clomonitor-linter/src/display.rs index 5360b746..170ff3e7 100644 --- a/clomonitor-linter/src/display.rs +++ b/clomonitor-linter/src/display.rs @@ -105,8 +105,8 @@ pub(crate) fn display_primary(report: &linter::primary::Report, score: &score::p cell_check(report.license.approved.unwrap_or(false)), ]) .add_row(vec![ - cell_entry("License / FOSSA badge"), - cell_check(report.license.fossa_badge), + cell_entry("License / Scanning"), + cell_check(report.license.scanning.is_some()), ]) .add_row(vec![ cell_entry("Best practices / Artifact Hub badge"), diff --git a/database/tests/functions/projects/get_project.sql b/database/tests/functions/projects/get_project.sql index 2993e1ee..07a2e59a 100644 --- a/database/tests/functions/projects/get_project.sql +++ b/database/tests/functions/projects/get_project.sql @@ -75,7 +75,7 @@ insert into report ( "license": { "spdx_id": "Apache-2.0", "approved": true, - "fossa_badge": true + "scanning": "https://license-scanning.url" }, "security": { "security_policy": true @@ -145,7 +145,7 @@ select is( }, "license": { "approved": true, - "fossa_badge": true, + "scanning": "https://license-scanning.url", "spdx_id": "Apache-2.0" }, "report_kind": "Primary", diff --git a/web/src/data.tsx b/web/src/data.tsx index 8fb70ac9..96b71513 100644 --- a/web/src/data.tsx +++ b/web/src/data.tsx @@ -193,21 +193,6 @@ export const REPORT_OPTIONS: ReportOptionInfo = { ), }, - [ReportOption.FossaBadge]: { - icon: , - name: 'FOSSA badge', - legend: ( - - FOSSA scans and automatically identifies, manages and addresses open source licensing issues and - security vulnerabilities - - ), - description: ( - - We check that the README file contains a FOSSA badge - - ), - }, [ReportOption.Governance]: { icon: , name: 'Governance', @@ -220,6 +205,21 @@ export const REPORT_OPTIONS: ReportOptionInfo = { ), }, + [ReportOption.LicenseScanning]: { + icon: , + name: 'License scanning', + legend: ( + + License scanning software scans and automatically identifies, manages and addresses open source licensing issues + + ), + description: ( + + We check that the README file contains a FOSSA or Snyk badge. It’s also possible to provide a + scanning url in the .clomonitor.yml metadata file + + ), + }, [ReportOption.Maintainers]: { icon: , name: 'Maintainers', diff --git a/web/src/layout/detail/report/OptionCell.module.css b/web/src/layout/detail/report/OptionCell.module.css index 88ef66a1..362bac16 100644 --- a/web/src/layout/detail/report/OptionCell.module.css +++ b/web/src/layout/detail/report/OptionCell.module.css @@ -23,6 +23,10 @@ margin-left: -3px; } +.miniIcon { + font-size: 0.75rem; +} + @media only screen and (min-width: 576px) { .name { font-size: 1rem; diff --git a/web/src/layout/detail/report/OptionCell.tsx b/web/src/layout/detail/report/OptionCell.tsx index 7ee25264..ddba02e3 100644 --- a/web/src/layout/detail/report/OptionCell.tsx +++ b/web/src/layout/detail/report/OptionCell.tsx @@ -1,9 +1,11 @@ import { isBoolean, isNull, isUndefined } from 'lodash'; import { FaRegCheckCircle, FaRegQuestionCircle, FaRegTimesCircle } from 'react-icons/fa'; +import { FiExternalLink } from 'react-icons/fi'; import { REPORT_OPTIONS } from '../../../data'; import { ReportOption, ReportOptionData } from '../../../types'; import ElementWithTooltip from '../../common/ElementWithTooltip'; +import ExternalLink from '../../common/ExternalLink'; import styles from './OptionCell.module.css'; interface Props { @@ -53,6 +55,22 @@ const OptionCell = (props: Props) => { case ReportOption.SPDX: return {props.value || 'Not detected'}; + case ReportOption.LicenseScanning: + return ( + <> + {isNull(props.value) ? ( + {opt.name} + ) : ( + +

+ {opt.name} + +
+ + )} + + ); + default: return {opt.name}; } diff --git a/web/src/layout/detail/report/Row.test.tsx b/web/src/layout/detail/report/Row.test.tsx index ffcc4114..3ce5a293 100644 --- a/web/src/layout/detail/report/Row.test.tsx +++ b/web/src/layout/detail/report/Row.test.tsx @@ -62,13 +62,13 @@ describe('Row', () => { }); it('renders options in correct order', () => { - render(); + render(); const opts = screen.getAllByTestId('opt-name'); expect(opts).toHaveLength(3); expect(opts[0]).toHaveTextContent('Apache-2.0'); expect(opts[1]).toHaveTextContent('Approved license'); - expect(opts[2]).toHaveTextContent('FOSSA badge'); + expect(opts[2]).toHaveTextContent('License scanning'); }); }); }); diff --git a/web/src/layout/detail/repositories/__snapshots__/index.test.tsx.snap b/web/src/layout/detail/repositories/__snapshots__/index.test.tsx.snap index a38263f2..e0b9d65a 100644 --- a/web/src/layout/detail/repositories/__snapshots__/index.test.tsx.snap +++ b/web/src/layout/detail/repositories/__snapshots__/index.test.tsx.snap @@ -2110,104 +2110,6 @@ exports[`RepositoriesList creates snapshot 1`] = ` - - - - - - - -
-
-
- - - -
-
-
- - FOSSA badge - -
-
- - - FOSSA - - scans and automatically identifies, manages and addresses open source licensing issues and security vulnerabilities - -
-
-
-
- - -
-
- - - -
-
- - diff --git a/web/src/types.ts b/web/src/types.ts index 16519483..f00ce7f9 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -163,8 +163,8 @@ export enum ReportOption { CodeOfConduct = 'code_of_conduct', CommunityMeeting = 'community_meeting', Contributing = 'contributing', - FossaBadge = 'fossa_badge', Governance = 'governance', + LicenseScanning = 'scanning', Maintainers = 'maintainers', OpenSSFBadge = 'openssf_badge', Readme = 'readme',