@@ -494,6 +494,95 @@ pub fn format_ci_gate_json(output: &CiGateOutput) -> String {
494494 serde_json:: to_string_pretty ( output) . unwrap_or_else ( |e| format ! ( "{{\" error\" : \" {e}\" }}" ) )
495495}
496496
497+ /// Render reconciliation results as SARIF 2.1.0 so CI can upload them to GitHub
498+ /// code scanning and get inline PR annotations. Missing capabilities are errors;
499+ /// excess grants are warnings.
500+ pub fn format_sarif ( reconciliations : & [ Reconciliation ] ) -> String {
501+ let mut results = Vec :: new ( ) ;
502+ for reconciliation in reconciliations {
503+ let ( rule_id, level) = match reconciliation. kind {
504+ ReconciliationKind :: MissingCapability => ( "missing_capability" , "error" ) ,
505+ ReconciliationKind :: ExcessCapability => ( "excess_capability" , "warning" ) ,
506+ _ => continue ,
507+ } ;
508+ let capability = reconciliation
509+ . capability
510+ . as_ref ( )
511+ . map ( |capability| {
512+ let mut parts = vec ! [ format!( "{:?}" , capability. category) ] ;
513+ for field in [ & capability. provider , & capability. service , & capability. action ] {
514+ if let Some ( value) = field {
515+ parts. push ( value. clone ( ) ) ;
516+ }
517+ }
518+ parts. join ( " / " )
519+ } )
520+ . unwrap_or_else ( || "capability" . to_string ( ) ) ;
521+ let text = match reconciliation. kind {
522+ ReconciliationKind :: MissingCapability => {
523+ format ! ( "Required capability not granted by target: {capability}" )
524+ }
525+ ReconciliationKind :: ExcessCapability => {
526+ format ! ( "Granted capability exceeds requirements (over-privilege): {capability}" )
527+ }
528+ _ => unreachable ! ( ) ,
529+ } ;
530+ let mut result = serde_json:: json!( {
531+ "ruleId" : rule_id,
532+ "level" : level,
533+ "message" : { "text" : text } ,
534+ } ) ;
535+ // Attach a source location when the evidence carries one.
536+ if let Some ( evidence) = reconciliation
537+ . evidence
538+ . iter ( )
539+ . find ( |evidence| evidence. file . is_some ( ) )
540+ {
541+ let uri = evidence. file . clone ( ) . unwrap_or_default ( ) ;
542+ let mut region = serde_json:: Map :: new ( ) ;
543+ if let Some ( line) = evidence. line {
544+ region. insert ( "startLine" . to_string ( ) , serde_json:: json!( line. max( 1 ) ) ) ;
545+ }
546+ if let Some ( column) = evidence. column {
547+ region. insert ( "startColumn" . to_string ( ) , serde_json:: json!( column. max( 1 ) ) ) ;
548+ }
549+ result[ "locations" ] = serde_json:: json!( [ {
550+ "physicalLocation" : {
551+ "artifactLocation" : { "uri" : uri } ,
552+ "region" : serde_json:: Value :: Object ( region) ,
553+ }
554+ } ] ) ;
555+ }
556+ results. push ( result) ;
557+ }
558+
559+ let bundle = serde_json:: json!( {
560+ "$schema" : "https://json.schemastore.org/sarif-2.1.0.json" ,
561+ "version" : "2.1.0" ,
562+ "runs" : [ {
563+ "tool" : {
564+ "driver" : {
565+ "name" : "rsscript-reir" ,
566+ "informationUri" : "https://github.com/Haofei/rsscript" ,
567+ "version" : env!( "CARGO_PKG_VERSION" ) ,
568+ "rules" : [
569+ {
570+ "id" : "missing_capability" ,
571+ "shortDescription" : { "text" : "Required capability is not granted by the deployment target." }
572+ } ,
573+ {
574+ "id" : "excess_capability" ,
575+ "shortDescription" : { "text" : "Granted capability exceeds what the code requires (over-privilege)." }
576+ }
577+ ]
578+ }
579+ } ,
580+ "results" : results,
581+ } ]
582+ } ) ;
583+ serde_json:: to_string_pretty ( & bundle) . unwrap_or_else ( |e| format ! ( "{{\" error\" : \" {e}\" }}" ) )
584+ }
585+
497586fn is_capability_fact ( fact : & Fact , role : FactRole ) -> bool {
498587 fact. kind == FactKind :: Capability && fact. role == Some ( role) && fact. capability . is_some ( )
499588}
@@ -1534,6 +1623,17 @@ mod tests {
15341623 assert_eq ! ( strict. status, CiGateStatus :: Fail ) ;
15351624 }
15361625
1626+ #[ test]
1627+ fn sarif_reports_missing_capability_as_error ( ) {
1628+ let sarif = format_sarif ( & [ missing_reconciliation ( ) ] ) ;
1629+ let value: serde_json:: Value = serde_json:: from_str ( & sarif) . expect ( "valid SARIF JSON" ) ;
1630+ assert_eq ! ( value[ "version" ] , "2.1.0" ) ;
1631+ let results = value[ "runs" ] [ 0 ] [ "results" ] . as_array ( ) . expect ( "results array" ) ;
1632+ assert_eq ! ( results. len( ) , 1 ) ;
1633+ assert_eq ! ( results[ 0 ] [ "ruleId" ] , "missing_capability" ) ;
1634+ assert_eq ! ( results[ 0 ] [ "level" ] , "error" ) ;
1635+ }
1636+
15371637 #[ test]
15381638 fn error_diagnostic_makes_bundle_invalid_for_gating ( ) {
15391639 let required = capability_fact ( "fact.req" , FactRole :: Required , FactValue :: True ) ;
0 commit comments