Skip to content

Commit 31f3841

Browse files
committed
Support security-insight v2
1 parent 840a886 commit 31f3841

File tree

8 files changed

+387
-57
lines changed

8 files changed

+387
-57
lines changed

clomonitor-core/src/linter/checks/datasource/security_insights.rs

Lines changed: 271 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,183 @@
1-
use std::path::Path;
1+
use std::path::{Path, PathBuf};
22

33
use anyhow::{Context, Result};
44
use serde::{Deserialize, Serialize};
55

66
use crate::linter::util;
77

8-
/// OpenSSF Security Insights manifest file name.
9-
pub(crate) const SECURITY_INSIGHTS_MANIFEST_FILE: &str = "SECURITY-INSIGHTS.yml";
8+
/// Candidate manifest file paths, searched in order. First match wins.
9+
/// v2 locations are checked first, with v1 as a fallback.
10+
const MANIFEST_CANDIDATES: &[&str] = &[
11+
"security-insights.yml",
12+
".github/security-insights.yml",
13+
".gitlab/security-insights.yml",
14+
"SECURITY-INSIGHTS.yml",
15+
];
16+
17+
/// Lightweight struct used to peek at the schema version before full parsing.
18+
#[derive(Deserialize)]
19+
#[serde(rename_all = "kebab-case")]
20+
struct VersionProbe {
21+
header: VersionProbeHeader,
22+
}
23+
24+
#[derive(Deserialize)]
25+
#[serde(rename_all = "kebab-case")]
26+
struct VersionProbeHeader {
27+
schema_version: String,
28+
}
1029

1130
/// OpenSSF Security Insights manifest.
1231
///
32+
/// Supports both v1 (SECURITY-INSIGHTS.yml) and v2 (security-insights.yml)
33+
/// of the specification.
34+
#[derive(Debug, Clone, PartialEq)]
35+
pub(crate) struct SecurityInsights {
36+
/// Relative path (from repository root) where the manifest was found.
37+
pub manifest_path: PathBuf,
38+
/// Parsed manifest content.
39+
pub version: SecurityInsightsVersion,
40+
}
41+
42+
/// Parsed manifest content, either v1 or v2.
43+
#[derive(Debug, Clone, PartialEq)]
44+
pub(crate) enum SecurityInsightsVersion {
45+
V2(SecurityInsightsV2),
46+
V1(SecurityInsightsV1),
47+
}
48+
49+
impl SecurityInsights {
50+
/// Create a new SecurityInsights instance from the first manifest file
51+
/// found at the root path provided.
52+
pub(crate) fn new(root: &Path) -> Result<Option<Self>> {
53+
for candidate in MANIFEST_CANDIDATES {
54+
let full_path = root.join(candidate);
55+
if !full_path.exists() {
56+
continue;
57+
}
58+
let content = util::fs::read_to_string(&full_path)
59+
.context("error reading security insights manifest file")?;
60+
61+
// Peek at schema-version to decide which struct to deserialize into.
62+
let probe: VersionProbe = serde_yaml::from_str(&content)
63+
.context("invalid security insights manifest (cannot read header)")?;
64+
65+
let version = if probe.header.schema_version.starts_with("2.") {
66+
let v2: SecurityInsightsV2 = serde_yaml::from_str(&content)
67+
.context("invalid security insights v2 manifest")?;
68+
SecurityInsightsVersion::V2(v2)
69+
} else if probe.header.schema_version.starts_with("1.") {
70+
let v1: SecurityInsightsV1 = serde_yaml::from_str(&content)
71+
.context("invalid security insights v1 manifest")?;
72+
SecurityInsightsVersion::V1(v1)
73+
} else {
74+
return Ok(None);
75+
};
76+
77+
return Ok(Some(SecurityInsights {
78+
manifest_path: PathBuf::from(candidate),
79+
version,
80+
}));
81+
}
82+
Ok(None)
83+
}
84+
85+
/// Return the dependency policy URL, abstracting over v1 and v2 paths.
86+
pub(crate) fn dependency_policy_url(&self) -> Option<&str> {
87+
match &self.version {
88+
SecurityInsightsVersion::V1(v1) => v1
89+
.dependencies
90+
.as_ref()
91+
.and_then(|d| d.env_dependencies_policy.as_ref())
92+
.and_then(|p| p.policy_url.as_deref()),
93+
SecurityInsightsVersion::V2(v2) => v2
94+
.repository
95+
.as_ref()
96+
.and_then(|r| r.documentation.as_ref())
97+
.and_then(|d| d.dependency_management_policy.as_deref()),
98+
}
99+
}
100+
}
101+
102+
// ---------------------------------------------------------------------------
103+
// v2 types
104+
// ---------------------------------------------------------------------------
105+
106+
/// OpenSSF Security Insights v2 manifest.
107+
///
108+
/// Note: the types defined below do not contain *all* the fields available in
109+
/// the specification, just the ones needed by CLOMonitor.
110+
///
111+
/// Covers schema versions 2.0.0 through 2.2.0+. Minor versions only add
112+
/// optional fields, so a single set of structs with `Option` handles all.
113+
///
114+
/// For more details please see the spec documentation:
115+
/// https://github.com/ossf/security-insights/blob/v2.2.0/spec/schema.cue
116+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
117+
#[serde(rename_all = "kebab-case")]
118+
pub(crate) struct SecurityInsightsV2 {
119+
pub header: HeaderV2,
120+
pub project: Option<ProjectV2>,
121+
pub repository: Option<RepositoryV2>,
122+
}
123+
124+
/// High-level metadata about the schema (v2).
125+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126+
#[serde(rename_all = "kebab-case")]
127+
pub(crate) struct HeaderV2 {
128+
pub schema_version: String,
129+
pub last_updated: String,
130+
pub last_reviewed: String,
131+
pub url: String,
132+
pub comment: Option<String>,
133+
pub project_si_source: Option<String>,
134+
}
135+
136+
/// Project information (v2).
137+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
138+
#[serde(rename_all = "kebab-case")]
139+
pub(crate) struct ProjectV2 {
140+
pub name: String,
141+
}
142+
143+
/// Repository information (v2).
144+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
145+
#[serde(rename_all = "kebab-case")]
146+
pub(crate) struct RepositoryV2 {
147+
pub url: String,
148+
pub status: String,
149+
pub documentation: Option<RepositoryDocumentationV2>,
150+
}
151+
152+
/// Repository documentation links (v2).
153+
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
154+
#[serde(rename_all = "kebab-case")]
155+
pub(crate) struct RepositoryDocumentationV2 {
156+
pub dependency_management_policy: Option<String>,
157+
}
158+
159+
// ---------------------------------------------------------------------------
160+
// v1 types (legacy)
161+
// ---------------------------------------------------------------------------
162+
163+
/// OpenSSF Security Insights v1 manifest.
164+
///
13165
/// Note: the types defined below do not contain *all* the fields available in
14166
/// the specification, just the ones needed by CLOMonitor.
15167
///
16168
/// For more details please see the spec documentation:
17169
/// https://github.com/ossf/security-insights-spec/blob/v1.0.0/specification.md
18170
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
19171
#[serde(rename_all = "kebab-case")]
20-
pub(crate) struct SecurityInsights {
172+
pub(crate) struct SecurityInsightsV1 {
21173
pub contribution_policy: ContributionPolicy,
22174
pub dependencies: Option<Dependencies>,
23175
pub distribution_points: Vec<String>,
24-
pub header: Header,
176+
pub header: HeaderV1,
25177
pub project_lifecycle: ProjectLifecycle,
26178
pub security_artifacts: Option<SecurityArtifacts>,
27179
pub security_contacts: Vec<SecurityContact>,
28-
pub vulnerability_reporting: VulnerabilityReporting,
29-
}
30-
31-
impl SecurityInsights {
32-
/// Create a new SecurityInsights instance from the manifest file located
33-
/// at the path provided.
34-
pub(crate) fn new(path: &Path) -> Result<Option<Self>> {
35-
let manifest_path = path.join(SECURITY_INSIGHTS_MANIFEST_FILE);
36-
if !Path::new(&manifest_path).exists() {
37-
return Ok(None);
38-
}
39-
let content = util::fs::read_to_string(manifest_path)
40-
.context("error reading security insights manifest file")?;
41-
serde_yaml::from_str(&content).context("invalid security insights manifest")
42-
}
180+
pub vulnerability_reporting: VulnerabilityReportingV1,
43181
}
44182

45183
/// Project's contribution rules, requirements, and policies.
@@ -66,10 +204,10 @@ pub(crate) struct EnvDependenciesPolicy {
66204
pub policy_url: Option<String>,
67205
}
68206

69-
/// High-level information about the project.
207+
/// High-level information about the project (v1).
70208
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
71209
#[serde(rename_all = "kebab-case")]
72-
pub(crate) struct Header {
210+
pub(crate) struct HeaderV1 {
73211
pub expiration_date: String,
74212
pub project_url: String,
75213
pub schema_version: String,
@@ -128,54 +266,142 @@ pub(crate) struct SecurityContact {
128266
pub value: String,
129267
}
130268

131-
/// Policies and procedures about how to report properly a security issue.
269+
/// Policies and procedures about how to report properly a security issue (v1).
132270
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
133271
#[serde(rename_all = "kebab-case")]
134-
pub(crate) struct VulnerabilityReporting {
272+
pub(crate) struct VulnerabilityReportingV1 {
135273
accepts_vulnerability_reports: bool,
136274
}
137275

138276
#[cfg(test)]
139277
mod tests {
140278
use super::*;
141279

142-
const TESTDATA_PATH: &str = "src/testdata/security-insights-v1";
280+
const TESTDATA_PATH_V2: &str = "src/testdata/security-insights-v2";
281+
const TESTDATA_PATH_V1: &str = "src/testdata/security-insights-v1";
282+
283+
// -----------------------------------------------------------------------
284+
// General discovery tests
285+
// -----------------------------------------------------------------------
143286

144287
#[test]
145288
fn new_returns_none_when_file_does_not_exist() {
146-
let result = SecurityInsights::new(&Path::new(TESTDATA_PATH).join("not-found")).unwrap();
289+
let result =
290+
SecurityInsights::new(&Path::new(TESTDATA_PATH_V2).join("not-found")).unwrap();
147291
assert!(result.is_none());
148292
}
149293

294+
// -----------------------------------------------------------------------
295+
// v1 tests
296+
// -----------------------------------------------------------------------
297+
150298
#[test]
151-
fn new_returns_error_when_file_is_invalid() {
152-
let result = SecurityInsights::new(&Path::new(TESTDATA_PATH).join("invalid"));
299+
fn new_returns_error_when_v1_file_is_invalid() {
300+
let result = SecurityInsights::new(&Path::new(TESTDATA_PATH_V1).join("invalid"));
153301
assert!(result.is_err());
154302
}
155303

156304
#[test]
157-
fn new_parses_valid_manifest() {
158-
let result = SecurityInsights::new(Path::new(TESTDATA_PATH)).unwrap();
305+
fn new_parses_valid_v1_manifest() {
306+
let result = SecurityInsights::new(Path::new(TESTDATA_PATH_V1)).unwrap();
159307
assert!(result.is_some());
160-
let insights = result.unwrap();
308+
let si = result.unwrap();
309+
310+
assert_eq!(si.manifest_path, PathBuf::from("SECURITY-INSIGHTS.yml"));
311+
let v1 = match &si.version {
312+
SecurityInsightsVersion::V1(v) => v,
313+
_ => panic!("expected V1"),
314+
};
161315

162-
assert_eq!(insights.header.expiration_date, "2024-09-28T01:00:00.000Z");
316+
assert_eq!(v1.header.expiration_date, "2024-09-28T01:00:00.000Z");
163317
assert_eq!(
164-
insights.header.project_url,
318+
v1.header.project_url,
165319
"https://github.com/ossf/security-insights-spec"
166320
);
167-
assert_eq!(insights.header.schema_version, "1.0.0");
168-
assert!(insights.contribution_policy.accepts_automated_pull_requests);
169-
assert!(insights.contribution_policy.accepts_pull_requests);
170-
assert!(!insights.project_lifecycle.bug_fixes_only);
171-
assert_eq!(insights.project_lifecycle.status, "active");
172-
assert!(
173-
insights
174-
.vulnerability_reporting
175-
.accepts_vulnerability_reports
321+
assert_eq!(v1.header.schema_version, "1.0.0");
322+
assert!(v1.contribution_policy.accepts_automated_pull_requests);
323+
assert!(v1.contribution_policy.accepts_pull_requests);
324+
assert!(!v1.project_lifecycle.bug_fixes_only);
325+
assert_eq!(v1.project_lifecycle.status, "active");
326+
assert!(v1.vulnerability_reporting.accepts_vulnerability_reports);
327+
assert_eq!(v1.security_contacts.len(), 1);
328+
assert_eq!(v1.security_contacts[0].kind, "email");
329+
assert_eq!(v1.security_contacts[0].value, "security@openssf.org");
330+
}
331+
332+
// -----------------------------------------------------------------------
333+
// v2 tests
334+
// -----------------------------------------------------------------------
335+
336+
#[test]
337+
fn new_returns_error_when_v2_file_is_invalid() {
338+
let result = SecurityInsights::new(&Path::new(TESTDATA_PATH_V2).join("invalid"));
339+
assert!(result.is_err());
340+
}
341+
342+
#[test]
343+
fn new_parses_valid_v2_manifest() {
344+
let result = SecurityInsights::new(Path::new(TESTDATA_PATH_V2)).unwrap();
345+
assert!(result.is_some());
346+
let si = result.unwrap();
347+
348+
assert_eq!(si.manifest_path, PathBuf::from("security-insights.yml"));
349+
let v2 = match &si.version {
350+
SecurityInsightsVersion::V2(v) => v,
351+
_ => panic!("expected V2"),
352+
};
353+
354+
assert_eq!(v2.header.schema_version, "2.0.0");
355+
assert_eq!(
356+
v2.header.url,
357+
"https://example.com/foo/bar/raw/branch/main/security-insights.yml"
358+
);
359+
assert_eq!(v2.repository.as_ref().unwrap().status, "active");
360+
}
361+
362+
#[test]
363+
fn new_finds_v2_in_github_dir() {
364+
let result = SecurityInsights::new(Path::new("src/testdata/security-insights-v2-github"))
365+
.unwrap();
366+
assert!(result.is_some());
367+
let si = result.unwrap();
368+
369+
assert_eq!(
370+
si.manifest_path,
371+
PathBuf::from(".github/security-insights.yml")
176372
);
177-
assert_eq!(insights.security_contacts.len(), 1);
178-
assert_eq!(insights.security_contacts[0].kind, "email");
179-
assert_eq!(insights.security_contacts[0].value, "security@openssf.org");
373+
assert!(matches!(si.version, SecurityInsightsVersion::V2(_)));
374+
}
375+
376+
// -----------------------------------------------------------------------
377+
// Helper method tests
378+
// -----------------------------------------------------------------------
379+
380+
#[test]
381+
fn dependency_policy_url_v1() {
382+
let si = SecurityInsights::new(Path::new(TESTDATA_PATH_V1))
383+
.unwrap()
384+
.unwrap();
385+
// The v1 test fixture does not have env-dependencies-policy set.
386+
assert!(si.dependency_policy_url().is_none());
387+
}
388+
389+
#[test]
390+
fn dependency_policy_url_v2() {
391+
let si = SecurityInsights::new(Path::new(TESTDATA_PATH_V2))
392+
.unwrap()
393+
.unwrap();
394+
assert_eq!(
395+
si.dependency_policy_url(),
396+
Some("https://example.com/dependency-management-policy")
397+
);
398+
}
399+
400+
#[test]
401+
fn new_returns_none_for_unsupported_version() {
402+
let result =
403+
SecurityInsights::new(Path::new("src/testdata/security-insights-unsupported"))
404+
.unwrap();
405+
assert!(result.is_none());
180406
}
181407
}

clomonitor-core/src/linter/checks/dependencies_policy.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@ pub(crate) fn check(input: &CheckInput) -> Result<CheckOutput> {
1818
.as_ref()
1919
.map_err(|e| format_err!("{e:?}"))?
2020
.as_ref()
21-
.and_then(|si| si.dependencies.as_ref())
22-
.and_then(|de| de.env_dependencies_policy.as_ref())
23-
.and_then(|dp| dp.policy_url.as_ref())
21+
.and_then(|si| si.dependency_policy_url())
2422
{
25-
return Ok(CheckOutput::passed().url(Some(policy_url.clone())));
23+
return Ok(CheckOutput::passed().url(Some(policy_url.to_owned())));
2624
}
2725
Ok(CheckOutput::not_passed())
2826
}

0 commit comments

Comments
 (0)