use annotate_snippets::{AnnotationKind, Group, Level, Snippet};use serde_yaml::Value;use std::{ops::Range, path::Path};pub enum PermissionsError { /// No top-level `permissions:` key. Missing, /// Top-level default grants more than `contents: read`. ExcessiveDefault,}/// Validates a workflow's top-level (default) `permissions:`.////// Every workflow must declare one, and it may grant at most `contents: read`;/// jobs that need more must request it at the job level.pub fn validate_permissions(workflow: &Value) -> Result<(), PermissionsError> { let Some(permissions) = workflow.get("permissions") else { return Err(PermissionsError::Missing); }; let is_minimal = match permissions { Value::Mapping(mapping) => mapping.iter().all(|(scope, level)| { let level = level.as_str(); level == Some("none") || (scope.as_str() == Some("contents") && level == Some("read")) }), // String forms such as `read-all`/`write-all` always exceed the allowance. _ => false, }; if is_minimal { Ok(()) } else { Err(PermissionsError::ExcessiveDefault) }}impl PermissionsError { pub fn annotation_group<'a>(&self, file_path: &Path, raw_content: &'a str) -> Group<'a> { let (title, span, label) = match self { PermissionsError::Missing => ( "Workflow is missing a top-level `permissions:` key", first_line_span(raw_content, "name:"), "Add a top-level `permissions:` key so this workflow defaults to the least privilege it needs", ), PermissionsError::ExcessiveDefault => ( "Top-level workflow permissions must grant at most `contents: read`", first_line_span(raw_content, "permissions:"), "Lower the default to `contents: read` (or `{}`) and move elevated permissions to the jobs that need them", ), }; Level::ERROR.primary_title(title).element( Snippet::source(raw_content) .path(file_path.display().to_string()) .annotations(std::iter::once( AnnotationKind::Primary.span(span).label(label), )), ) }}/// Span of the first column-0 line starting with `prefix`, else empty.fn first_line_span(raw_content: &str, prefix: &str) -> Range<usize> { let mut offset = 0; for line in raw_content.lines() { let start = offset; offset += line.len() + 1; if line.starts_with(prefix) { return start..start + line.len(); } } Default::default()}
use annotate_snippets::{AnnotationKind, Group, Level, Snippet};
use serde_yaml::Value;
use std::{ops::Range, path::Path};
pub enum PermissionsError {
/// No top-level `permissions:` key.
Missing,
/// Top-level default grants more than `contents: read`.
ExcessiveDefault,
}
/// Validates a workflow's top-level (default) `permissions:`.
///
/// Every workflow must declare one, and it may grant at most `contents: read`;
/// jobs that need more must request it at the job level.
pub fn validate_permissions(workflow: &Value) -> Result<(), PermissionsError> {
let Some(permissions) = workflow.get("permissions") else {
return Err(PermissionsError::Missing);
};
let is_minimal = match permissions {
Value::Mapping(mapping) => mapping.iter().all(|(scope, level)| {
let level = level.as_str();
level == Some("none") || (scope.as_str() == Some("contents") && level == Some("read"))
}),
// String forms such as `read-all`/`write-all` always exceed the allowance.
_ => false,
};
if is_minimal {
Ok(())
} else {
Err(PermissionsError::ExcessiveDefault)
}
}
impl PermissionsError {
pub fn annotation_group<'a>(&self, file_path: &Path, raw_content: &'a str) -> Group<'a> {
let (title, span, label) = match self {
PermissionsError::Missing => (
"Workflow is missing a top-level `permissions:` key",
first_line_span(raw_content, "name:"),
"Add a top-level `permissions:` key so this workflow defaults to the least privilege it needs",
),
PermissionsError::ExcessiveDefault => (
"Top-level workflow permissions must grant at most `contents: read`",
first_line_span(raw_content, "permissions:"),
"Lower the default to `contents: read` (or `{}`) and move elevated permissions to the jobs that need them",
),
};
Level::ERROR.primary_title(title).element(
Snippet::source(raw_content)
.path(file_path.display().to_string())
.annotations(std::iter::once(
AnnotationKind::Primary.span(span).label(label),
)),
)
}
}
/// Span of the first column-0 line starting with `prefix`, else empty.
fn first_line_span(raw_content: &str, prefix: &str) -> Range<usize> {
let mut offset = 0;
for line in raw_content.lines() {
let start = offset;
offset += line.len() + 1;
if line.starts_with(prefix) {
return start..start + line.len();
}
}
Default::default()
}