1use std::{fs, path::PathBuf, sync::LazyLock};
2
3use anyhow::{Context, Result};
4use regex::Regex;
5
6use crate::{
7 model::{PolicySeverity, PolicyViolation, PolicyViolationType, ScriptType, ScriptUsage},
8 workflow::{discover_workflow_files, scan_workflow, scan_workflow_scripts},
9};
10
11static PERMISSIONS_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
12 Regex::new(r"^(?P<indent>\s*)permissions:\s*(?P<value>[^#]*)").expect("valid permissions regex")
13});
14
15static PERMISSION_ENTRY_RE: LazyLock<Regex> = LazyLock::new(|| {
16 Regex::new(r"^\s*(?P<scope>[A-Za-z0-9_-]+):\s*(?P<value>[A-Za-z0-9_-]+)")
17 .expect("valid permission entry regex")
18});
19
20static TOP_LEVEL_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
21 Regex::new(r"^[A-Za-z_][A-Za-z0-9_-]*\s*:").expect("valid top-level yaml key regex")
22});
23
24static YAML_KEY_RE: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"^\s*[A-Za-z_][A-Za-z0-9_-]*\s*:").expect("valid yaml key regex"));
26
27static JOB_LINE_RE: LazyLock<Regex> = LazyLock::new(|| {
28 Regex::new(r"^ (?P<name>[A-Za-z0-9_-]+):\s*(?:#.*)?$").expect("valid job line regex")
29});
30
31static JOB_FIELD_RE: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(r"^\s+(?P<field>[A-Za-z0-9_-]+):").expect("valid job field regex"));
33
34#[derive(Debug, Clone, Eq, PartialEq)]
35pub struct PolicyOptions {
36 pub repo_root: PathBuf,
37 pub workflows_path: PathBuf,
38 pub check_scripts: bool,
39 pub check_policies: bool,
40}
41
42#[derive(Debug, Clone, Eq, PartialEq)]
43pub struct PolicyReport {
44 pub workflow_files: usize,
45 pub script_usages: Vec<ScriptUsage>,
46 pub policy_violations: Vec<PolicyViolation>,
47 pub summary: PolicySummary,
48}
49
50impl PolicyReport {
51 #[must_use]
52 pub const fn has_findings(&self) -> bool {
53 !self.script_usages.is_empty() || !self.policy_violations.is_empty()
54 }
55}
56
57#[derive(Debug, Clone, Eq, PartialEq, Default)]
58pub struct PolicySummary {
59 pub total_scripts: usize,
60 pub bash_scripts: usize,
61 pub python_scripts: usize,
62 pub high_violations: usize,
63 pub medium_violations: usize,
64 pub low_violations: usize,
65}
66
67#[derive(Debug, Clone, Copy, Default)]
68pub struct PolicyScanner;
69
70impl PolicyScanner {
71 #[must_use]
72 pub const fn new() -> Self {
73 Self
74 }
75
76 pub fn scan(&self, options: &PolicyOptions) -> Result<PolicyReport> {
77 let repo_root = options.repo_root.canonicalize().with_context(|| {
78 format!("failed to resolve repository root '{}'", options.repo_root.display())
79 })?;
80 let workflow_root = if options.workflows_path.is_absolute() {
81 options.workflows_path.clone()
82 } else {
83 repo_root.join(&options.workflows_path)
84 };
85
86 if !workflow_root.exists() {
87 return Ok(PolicyReport {
88 workflow_files: 0,
89 script_usages: Vec::new(),
90 policy_violations: Vec::new(),
91 summary: PolicySummary::default(),
92 });
93 }
94
95 let workflow_files = discover_workflow_files(&repo_root, &options.workflows_path)?;
96 let mut script_usages = Vec::new();
97 let mut policy_violations = Vec::new();
98
99 for workflow_file in &workflow_files {
100 if options.check_scripts {
101 script_usages.extend(scan_workflow_scripts(workflow_file)?);
102 }
103 if options.check_policies {
104 policy_violations.extend(scan_policy_violations(workflow_file)?);
105 }
106 }
107
108 let summary = summarize(&script_usages, &policy_violations);
109
110 Ok(PolicyReport {
111 workflow_files: workflow_files.len(),
112 script_usages,
113 policy_violations,
114 summary,
115 })
116 }
117}
118
119fn scan_policy_violations(workflow_file: &std::path::Path) -> Result<Vec<PolicyViolation>> {
120 let mut violations = Vec::new();
121
122 for action in scan_workflow(workflow_file)? {
123 if !action.is_pinned() {
124 violations.push(PolicyViolation {
125 file: action.file,
126 line_number: action.line_number,
127 violation_type: PolicyViolationType::UnpinnedAction,
128 severity: PolicySeverity::High,
129 description: format!(
130 "external action '{}' should be pinned to a full 40-character commit SHA",
131 action.action_slug
132 ),
133 context: action.original_line,
134 });
135 }
136 }
137
138 let content = fs::read_to_string(workflow_file)
139 .with_context(|| format!("failed to read workflow '{}'", workflow_file.display()))?;
140 violations.extend(scan_permission_violations(workflow_file, &content));
141 violations.extend(scan_timeout_violations(workflow_file, &content));
142
143 Ok(violations)
144}
145
146fn scan_permission_violations(path: &std::path::Path, content: &str) -> Vec<PolicyViolation> {
147 let mut violations = Vec::new();
148 let mut has_top_level_permissions = false;
149 let mut permissions_block_indent = None;
150
151 for (index, line) in content.lines().enumerate() {
152 let line_number = index + 1;
153 if let Some(block_indent) = permissions_block_indent {
154 if !line.trim().is_empty()
155 && leading_spaces(line) <= block_indent
156 && YAML_KEY_RE.is_match(line)
157 {
158 permissions_block_indent = None;
159 } else if let Some(captures) = PERMISSION_ENTRY_RE.captures(line) {
160 let scope = captures.name("scope").expect("scope capture is required").as_str();
161 let value = captures.name("value").expect("value capture is required").as_str();
162 push_permission_value_violation(
163 path,
164 line_number,
165 scope,
166 value,
167 line,
168 &mut violations,
169 );
170 }
171 }
172
173 let Some(captures) = PERMISSIONS_LINE_RE.captures(line) else {
174 continue;
175 };
176
177 let indent = captures.name("indent").map_or(0, |capture| capture.as_str().chars().count());
178 if indent == 0 {
179 has_top_level_permissions = true;
180 }
181
182 let value = captures.name("value").expect("value capture is required").as_str().trim();
183 if value.is_empty() {
184 permissions_block_indent = Some(indent);
185 } else {
186 push_permissions_shorthand_violation(path, line_number, value, line, &mut violations);
187 }
188 }
189
190 if !has_top_level_permissions {
191 violations.push(PolicyViolation {
192 file: path.to_path_buf(),
193 line_number: 1,
194 violation_type: PolicyViolationType::MissingPermissions,
195 severity: PolicySeverity::Medium,
196 description: "workflow should declare explicit top-level permissions".to_owned(),
197 context: String::new(),
198 });
199 }
200
201 violations
202}
203
204fn push_permissions_shorthand_violation(
205 path: &std::path::Path,
206 line_number: usize,
207 value: &str,
208 context: &str,
209 violations: &mut Vec<PolicyViolation>,
210) {
211 match value {
212 "write-all" => violations.push(PolicyViolation {
213 file: path.to_path_buf(),
214 line_number,
215 violation_type: PolicyViolationType::ExcessivePermissions,
216 severity: PolicySeverity::High,
217 description: "permissions should not use write-all".to_owned(),
218 context: context.to_owned(),
219 }),
220 "read-all" | "{}" | "read" => {}
221 other if other.contains("write") => violations.push(PolicyViolation {
222 file: path.to_path_buf(),
223 line_number,
224 violation_type: PolicyViolationType::ExcessivePermissions,
225 severity: PolicySeverity::Medium,
226 description: format!("permission shorthand '{other}' should be reviewed"),
227 context: context.to_owned(),
228 }),
229 _ => {}
230 }
231}
232
233fn push_permission_value_violation(
234 path: &std::path::Path,
235 line_number: usize,
236 scope: &str,
237 value: &str,
238 context: &str,
239 violations: &mut Vec<PolicyViolation>,
240) {
241 if value != "write" {
242 return;
243 }
244
245 violations.push(PolicyViolation {
246 file: path.to_path_buf(),
247 line_number,
248 violation_type: PolicyViolationType::ExcessivePermissions,
249 severity: if scope == "id-token" { PolicySeverity::Low } else { PolicySeverity::Medium },
250 description: format!("permission '{scope}: write' should be minimized or justified"),
251 context: context.to_owned(),
252 });
253}
254
255fn scan_timeout_violations(path: &std::path::Path, content: &str) -> Vec<PolicyViolation> {
256 let mut violations = Vec::new();
257 let mut in_jobs = false;
258 let mut current_job = None;
259
260 for (index, line) in content.lines().enumerate() {
261 let line_number = index + 1;
262 if line.starts_with("jobs:") {
263 in_jobs = true;
264 continue;
265 }
266
267 if in_jobs && TOP_LEVEL_KEY_RE.is_match(line) && !line.starts_with("jobs:") {
268 finish_job(path, current_job.take(), &mut violations);
269 in_jobs = false;
270 }
271
272 if !in_jobs {
273 continue;
274 }
275
276 if let Some(captures) = JOB_LINE_RE.captures(line) {
277 finish_job(path, current_job.take(), &mut violations);
278 let name = captures.name("name").expect("name capture is required").as_str().to_owned();
279 current_job = Some(JobState {
280 name,
281 line_number,
282 context: line.to_owned(),
283 has_timeout: false,
284 has_runnable_content: false,
285 });
286 continue;
287 }
288
289 if let Some(job) = &mut current_job
290 && let Some(captures) = JOB_FIELD_RE.captures(line)
291 {
292 let field = captures.name("field").expect("field capture is required").as_str();
293 match field {
294 "timeout-minutes" => job.has_timeout = true,
295 "runs-on" | "steps" => job.has_runnable_content = true,
296 _ => {}
297 }
298 }
299 }
300
301 finish_job(path, current_job, &mut violations);
302
303 violations
304}
305
306fn finish_job(
307 path: &std::path::Path,
308 job: Option<JobState>,
309 violations: &mut Vec<PolicyViolation>,
310) {
311 let Some(job) = job else {
312 return;
313 };
314
315 if job.has_runnable_content && !job.has_timeout {
316 violations.push(PolicyViolation {
317 file: path.to_path_buf(),
318 line_number: job.line_number,
319 violation_type: PolicyViolationType::MissingTimeoutMinutes,
320 severity: PolicySeverity::Medium,
321 description: format!("job '{}' should declare timeout-minutes", job.name),
322 context: job.context,
323 });
324 }
325}
326
327#[derive(Debug, Clone, Eq, PartialEq)]
328struct JobState {
329 name: String,
330 line_number: usize,
331 context: String,
332 has_timeout: bool,
333 has_runnable_content: bool,
334}
335
336fn summarize(scripts: &[ScriptUsage], violations: &[PolicyViolation]) -> PolicySummary {
337 PolicySummary {
338 total_scripts: scripts.len(),
339 bash_scripts: scripts.iter().filter(|usage| usage.script_type == ScriptType::Bash).count(),
340 python_scripts: scripts
341 .iter()
342 .filter(|usage| usage.script_type == ScriptType::Python)
343 .count(),
344 high_violations: violations
345 .iter()
346 .filter(|violation| violation.severity == PolicySeverity::High)
347 .count(),
348 medium_violations: violations
349 .iter()
350 .filter(|violation| violation.severity == PolicySeverity::Medium)
351 .count(),
352 low_violations: violations
353 .iter()
354 .filter(|violation| violation.severity == PolicySeverity::Low)
355 .count(),
356 }
357}
358
359fn leading_spaces(value: &str) -> usize {
360 value.chars().take_while(|character| *character == ' ').count()
361}
362
363#[cfg(test)]
364mod tests {
365 use std::{fs, path::PathBuf};
366
367 use tempfile::tempdir;
368
369 use super::{PolicyOptions, PolicyScanner};
370 use crate::model::{PolicySeverity, PolicyViolationType, ScriptType};
371
372 #[test]
373 fn scanner_reports_scripts_and_policy_violations() {
374 let temp_dir = tempdir().expect("tempdir");
375 let workflow_dir = temp_dir.path().join(".github").join("workflows");
376 fs::create_dir_all(&workflow_dir).expect("create workflow directory");
377 fs::write(
378 workflow_dir.join("ci.yml"),
379 r"name: CI
380
381on:
382 pull_request:
383
384permissions:
385 contents: write
386
387jobs:
388 lint:
389 runs-on: ubuntu-latest
390 steps:
391 - uses: actions/checkout@v4
392 - run: python3 scripts/check.py
393 - run: ./bin/bootstrap.sh
394",
395 )
396 .expect("write workflow");
397
398 let scanner = PolicyScanner::new();
399 let report = scanner
400 .scan(&PolicyOptions {
401 repo_root: temp_dir.path().to_path_buf(),
402 workflows_path: PathBuf::from(".github/workflows"),
403 check_scripts: true,
404 check_policies: true,
405 })
406 .expect("scan workflows");
407
408 assert_eq!(report.workflow_files, 1);
409 assert_eq!(report.summary.total_scripts, 2);
410 assert_eq!(report.summary.bash_scripts, 1);
411 assert_eq!(report.summary.python_scripts, 1);
412 assert!(report.script_usages.iter().any(|usage| usage.script_type == ScriptType::Bash));
413 assert!(report.script_usages.iter().any(|usage| usage.script_type == ScriptType::Python));
414 assert!(
415 report
416 .policy_violations
417 .iter()
418 .any(|violation| violation.violation_type == PolicyViolationType::UnpinnedAction)
419 );
420 assert!(
421 report
422 .policy_violations
423 .iter()
424 .any(|violation| violation.violation_type
425 == PolicyViolationType::ExcessivePermissions)
426 );
427 assert!(report.policy_violations.iter().any(
428 |violation| violation.violation_type == PolicyViolationType::MissingTimeoutMinutes
429 ));
430 }
431
432 #[test]
433 fn scanner_treats_missing_workflow_directory_as_empty_report() {
434 let temp_dir = tempdir().expect("tempdir");
435 let scanner = PolicyScanner::new();
436 let report = scanner
437 .scan(&PolicyOptions {
438 repo_root: temp_dir.path().to_path_buf(),
439 workflows_path: PathBuf::from(".github/workflows"),
440 check_scripts: true,
441 check_policies: true,
442 })
443 .expect("scan workflows");
444
445 assert_eq!(report.workflow_files, 0);
446 assert!(!report.has_findings());
447 }
448
449 #[test]
450 fn scanner_accepts_compliant_workflow() {
451 let temp_dir = tempdir().expect("tempdir");
452 let workflow_dir = temp_dir.path().join(".github").join("workflows");
453 fs::create_dir_all(&workflow_dir).expect("create workflow directory");
454 fs::write(
455 workflow_dir.join("ci.yml"),
456 r"name: CI
457
458on:
459 pull_request:
460
461permissions:
462 contents: read
463
464jobs:
465 lint:
466 timeout-minutes: 10
467 runs-on: ubuntu-latest
468 steps:
469 - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567 # v4
470 - run: cargo test
471",
472 )
473 .expect("write workflow");
474
475 let scanner = PolicyScanner::new();
476 let report = scanner
477 .scan(&PolicyOptions {
478 repo_root: temp_dir.path().to_path_buf(),
479 workflows_path: PathBuf::from(".github/workflows"),
480 check_scripts: true,
481 check_policies: true,
482 })
483 .expect("scan workflows");
484
485 assert_eq!(report.workflow_files, 1);
486 assert!(!report.has_findings());
487 }
488
489 #[test]
490 fn scanner_stops_job_permission_blocks_at_sibling_fields() {
491 let temp_dir = tempdir().expect("tempdir");
492 let workflow_dir = temp_dir.path().join(".github").join("workflows");
493 fs::create_dir_all(&workflow_dir).expect("create workflow directory");
494 fs::write(
495 workflow_dir.join("ci.yml"),
496 r"name: CI
497
498on:
499 pull_request:
500
501permissions:
502 contents: read
503
504jobs:
505 lint:
506 permissions:
507 contents: read
508 timeout-minutes: 10
509 runs-on: ubuntu-latest
510 steps:
511 - run: cargo test
512",
513 )
514 .expect("write workflow");
515
516 let scanner = PolicyScanner::new();
517 let report = scanner
518 .scan(&PolicyOptions {
519 repo_root: temp_dir.path().to_path_buf(),
520 workflows_path: PathBuf::from(".github/workflows"),
521 check_scripts: false,
522 check_policies: true,
523 })
524 .expect("scan workflows");
525
526 assert!(
527 !report
528 .policy_violations
529 .iter()
530 .any(|violation| violation.violation_type
531 == PolicyViolationType::ExcessivePermissions)
532 );
533 }
534
535 #[test]
536 fn scanner_flags_missing_top_level_permissions_and_write_all() {
537 let temp_dir = tempdir().expect("tempdir");
538 let workflow_dir = temp_dir.path().join(".github").join("workflows");
539 fs::create_dir_all(&workflow_dir).expect("create workflow directory");
540 fs::write(
541 workflow_dir.join("release.yml"),
542 r"name: Release
543
544on:
545 workflow_dispatch:
546
547jobs:
548 release:
549 permissions: write-all
550 timeout-minutes: 20
551 runs-on: ubuntu-latest
552 steps:
553 - run: cargo test
554",
555 )
556 .expect("write workflow");
557
558 let scanner = PolicyScanner::new();
559 let report = scanner
560 .scan(&PolicyOptions {
561 repo_root: temp_dir.path().to_path_buf(),
562 workflows_path: PathBuf::from(".github/workflows"),
563 check_scripts: false,
564 check_policies: true,
565 })
566 .expect("scan workflows");
567
568 assert!(
569 report.policy_violations.iter().any(
570 |violation| violation.violation_type == PolicyViolationType::MissingPermissions
571 )
572 );
573 assert!(
574 report
575 .policy_violations
576 .iter()
577 .any(|violation| violation.severity == PolicySeverity::High)
578 );
579 }
580}