1- use std:: path:: Path ;
1+ use std:: path:: { Path , PathBuf } ;
22
33use anyhow:: { Context , Result } ;
44use serde:: { Deserialize , Serialize } ;
55
66use 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) ]
139277mod 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}
0 commit comments