1use anyhow::{Context, Result};
2use camino::{Utf8Path, Utf8PathBuf};
3use serde_yaml::Value as Yaml;
4use std::fs;
5
6use crate::call::{ClassifyOptions, classify};
7use crate::model::{
8 ApiDetection, ApiDetectionOrigin, Check, Compatibility, JobApiReport, PermissionResolution,
9 PermissionScope, PermissionSet, ReceiptSummary, StepApiReport, WorkflowReport,
10};
11use crate::permissions;
12
13const GITHUB_API_HOSTS: &[&str] = &["api.github.com", "uploads.github.com"];
14
15pub fn scan_workflows(
16 repo_root: &Utf8Path,
17 workflows: &[Utf8PathBuf],
18) -> Result<Vec<WorkflowReport>> {
19 let (paths, join_with_repo) = if workflows.is_empty() {
20 (discover_workflows(repo_root)?, true)
21 } else {
22 (workflows.to_vec(), false)
23 };
24
25 let mut reports = Vec::new();
26 for workflow in paths {
27 let absolute = if workflow.is_absolute() || !join_with_repo {
28 workflow.clone()
29 } else {
30 repo_root.join(&workflow)
31 };
32 reports.push(scan_workflow_file(&workflow, &absolute)?);
33 }
34 Ok(reports)
35}
36
37fn discover_workflows(repo_root: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
38 let dir = repo_root.join(".github").join("workflows");
39 if !dir.is_dir() {
40 return Ok(Vec::new());
41 }
42 let mut found = Vec::new();
43 for entry in fs::read_dir(&dir).with_context(|| format!("reading {dir}"))? {
44 let entry = entry?;
45 let path = entry.path();
46 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
47 continue;
48 };
49 if !matches!(ext, "yml" | "yaml") {
50 continue;
51 }
52 let Some(utf8) = Utf8PathBuf::from_path_buf(path).ok() else {
53 continue;
54 };
55 let relative = utf8
56 .strip_prefix(repo_root)
57 .map(Utf8PathBuf::from)
58 .unwrap_or_else(|_| utf8.clone());
59 found.push(relative);
60 }
61 found.sort();
62 Ok(found)
63}
64
65fn scan_workflow_file(workflow_path: &Utf8Path, absolute: &Utf8Path) -> Result<WorkflowReport> {
66 let text = fs::read_to_string(absolute).with_context(|| format!("reading {absolute}"))?;
67 let stripped = strip_utf8_bom(&text);
68 let value: Yaml =
69 serde_yaml::from_str(stripped).with_context(|| format!("parsing YAML {absolute}"))?;
70
71 let mut checks = Vec::new();
72 let Yaml::Mapping(root) = &value else {
73 checks.push(Check::fail(
74 "workflow.invalid_root",
75 "workflow root must be a YAML mapping",
76 ));
77 return Ok(WorkflowReport {
78 workflow: workflow_path.to_owned(),
79 workflow_permissions: None,
80 jobs: Vec::new(),
81 summary: ReceiptSummary::from_checks(&checks),
82 checks,
83 });
84 };
85
86 let workflow_permissions_yaml = mapping_lookup(root, "permissions");
87 let (workflow_permissions, workflow_checks) =
88 permissions::parse_yaml_block(workflow_permissions_yaml);
89 checks.extend(workflow_checks);
90
91 let jobs_yaml = mapping_lookup(root, "jobs");
92 let mut jobs = Vec::new();
93 let mut summary = ReceiptSummary::from_checks(&checks);
94
95 if let Some(Yaml::Mapping(jobs_map)) = jobs_yaml {
96 for (key, value) in jobs_map {
97 let Yaml::String(job_id) = key else {
98 checks.push(Check::warn(
99 "workflow.non_string_job_id",
100 "skipping job with non-string id",
101 ));
102 continue;
103 };
104 let Yaml::Mapping(job_map) = value else {
105 checks.push(Check::warn(
106 "workflow.job_not_mapping",
107 format!("job '{job_id}' is not a mapping"),
108 ));
109 continue;
110 };
111 let job_report = scan_job(job_id, job_map, workflow_permissions.as_ref());
112 summary.add(&job_report.summary);
113 jobs.push(job_report);
114 }
115 } else if jobs_yaml.is_none() {
116 checks.push(Check::warn(
117 "workflow.no_jobs",
118 "workflow has no `jobs:` mapping",
119 ));
120 } else {
121 checks.push(Check::fail(
122 "workflow.jobs_not_mapping",
123 "`jobs:` must be a mapping",
124 ));
125 }
126
127 summary.add(&ReceiptSummary::from_checks(&checks));
128
129 Ok(WorkflowReport {
130 workflow: workflow_path.to_owned(),
131 workflow_permissions,
132 jobs,
133 summary,
134 checks,
135 })
136}
137
138fn scan_job(
139 job_id: &str,
140 job_map: &serde_yaml::Mapping,
141 workflow_permissions: Option<&PermissionSet>,
142) -> JobApiReport {
143 let mut checks = Vec::new();
144 let job_permissions_yaml = mapping_lookup(job_map, "permissions");
145 let (job_permissions, perm_checks) = permissions::parse_yaml_block(job_permissions_yaml);
146 checks.extend(perm_checks);
147
148 let resolution: PermissionResolution = permissions::resolve(
149 workflow_permissions.cloned(),
150 job_permissions,
151 PermissionScope::Job,
152 );
153
154 let effective = resolution.effective.clone();
155 let mut steps_reports = Vec::new();
156
157 if let Some(Yaml::Sequence(steps)) = mapping_lookup(job_map, "steps") {
158 for (index, step_value) in steps.iter().enumerate() {
159 let Yaml::Mapping(step_map) = step_value else {
160 checks.push(Check::warn(
161 "workflow.step_not_mapping",
162 format!("job '{job_id}' step {index} is not a mapping"),
163 ));
164 continue;
165 };
166 let step_report = scan_step(job_id, index, step_map, &effective);
167 steps_reports.push(step_report);
168 }
169 }
170
171 let mut summary = ReceiptSummary::from_checks(&checks);
172 summary.merge_checks(&resolution.checks);
173 for step in &steps_reports {
174 summary.add(&step_summary(step));
175 }
176
177 JobApiReport {
178 job_id: job_id.to_owned(),
179 permissions: resolution,
180 steps: steps_reports,
181 summary,
182 checks,
183 }
184}
185
186fn step_summary(step: &StepApiReport) -> ReceiptSummary {
187 ReceiptSummary::from_checks(&step.checks)
188}
189
190fn scan_step(
191 job_id: &str,
192 index: usize,
193 step_map: &serde_yaml::Mapping,
194 effective: &PermissionSet,
195) -> StepApiReport {
196 let step_name = mapping_lookup(step_map, "name").and_then(|v| match v {
197 Yaml::String(text) => Some(text.clone()),
198 _ => None,
199 });
200 let uses = mapping_lookup(step_map, "uses").and_then(|v| match v {
201 Yaml::String(text) => Some(text.clone()),
202 _ => None,
203 });
204
205 let mut detections = Vec::new();
206 let mut checks = Vec::new();
207 let location = format!("job '{job_id}' step {index}");
208
209 if let Some(uses_ref) = uses.as_deref() {
210 detections.extend(detect_from_action(uses_ref, &location));
211 }
212
213 if let Some(Yaml::String(run_text)) = mapping_lookup(step_map, "run") {
214 detections.extend(detect_from_run(run_text, &location));
215 }
216
217 for detection in &mut detections {
218 let report = classify(ClassifyOptions {
219 method: detection.method.clone(),
220 path: detection.path.clone(),
221 url: None,
222 origin: Some(location.clone()),
223 permissions: Some(effective.clone()),
224 });
225 detection.classification = report.classification;
226 detection.catalog_match = report.catalog_match.clone();
227 detection.satisfied = report.satisfied;
228 detection.missing_permissions = report.missing_permissions.clone();
229 detection.unsupported_reason = report.unsupported_reason.clone();
230 checks.extend(report.checks);
231 }
232
233 StepApiReport {
234 step_index: index,
235 step_name,
236 uses,
237 detections,
238 checks,
239 }
240}
241
242fn detect_from_action(uses: &str, location: &str) -> Vec<ApiDetection> {
243 let (slug, _ref) = match uses.split_once('@') {
244 Some((slug, r)) => (slug, Some(r)),
245 None => (uses, None),
246 };
247
248 let mut detections = Vec::new();
249 let owner_repo = match slug.rsplit_once('/') {
250 Some((owner, _)) if !owner.is_empty() => slug.to_owned(),
251 _ => slug.to_owned(),
252 };
253
254 let _ = owner_repo;
255 let label_prefix = format!("uses {uses}");
256
257 match slug {
258 "softprops/action-gh-release" | "ncipollo/release-action" => {
259 detections.push(ApiDetection {
260 origin: ApiDetectionOrigin::ReleaseAction,
261 label: format!("{label_prefix} (create release at {location})"),
262 method: "POST".to_owned(),
263 path: "/repos/{owner}/{repo}/releases".to_owned(),
264 classification: Compatibility::Simulated,
265 catalog_match: None,
266 satisfied: false,
267 missing_permissions: Vec::new(),
268 unsupported_reason: None,
269 });
270 detections.push(ApiDetection {
271 origin: ApiDetectionOrigin::ReleaseAction,
272 label: format!("{label_prefix} (upload assets at {location})"),
273 method: "POST".to_owned(),
274 path: "/repos/{owner}/{repo}/releases/{id}/assets".to_owned(),
275 classification: Compatibility::Simulated,
276 catalog_match: None,
277 satisfied: false,
278 missing_permissions: Vec::new(),
279 unsupported_reason: None,
280 });
281 }
282 "actions/github-script" => {
283 detections.push(ApiDetection {
284 origin: ApiDetectionOrigin::GithubScript,
285 label: format!("{label_prefix} (generic Octokit surface at {location})"),
286 method: "GET".to_owned(),
287 path: "/rate_limit".to_owned(),
288 classification: Compatibility::Exact,
289 catalog_match: None,
290 satisfied: false,
291 missing_permissions: Vec::new(),
292 unsupported_reason: None,
293 });
294 }
295 "aws-actions/configure-aws-credentials" | "google-github-actions/auth" | "azure/login" => {
296 detections.push(ApiDetection {
297 origin: ApiDetectionOrigin::OidcAction,
298 label: format!("{label_prefix} (OIDC exchange at {location})"),
299 method: "GET".to_owned(),
300 path: "/rate_limit".to_owned(),
301 classification: Compatibility::Simulated,
302 catalog_match: None,
303 satisfied: false,
304 missing_permissions: Vec::new(),
305 unsupported_reason: Some(
306 "oidc.cloud_exchange: federated OIDC exchange happens outside the ci-forge shim".to_owned(),
307 ),
308 });
309 }
310 _ => {}
311 }
312
313 detections
314}
315
316fn detect_from_run(run: &str, location: &str) -> Vec<ApiDetection> {
317 let mut detections = Vec::new();
318 for line in run.lines() {
319 let trimmed = line.trim();
320 if trimmed.is_empty() || trimmed.starts_with('#') {
321 continue;
322 }
323 let tokens = match shell_words::split(trimmed) {
324 Ok(tokens) => tokens,
325 Err(_) => continue,
326 };
327 if tokens.is_empty() {
328 continue;
329 }
330 let cmd = tokens[0].as_str();
331 match cmd {
332 "gh" => detections.extend(detect_from_gh(&tokens, location)),
333 "curl" => detections.extend(detect_from_curl(&tokens, location)),
334 _ => {}
335 }
336 }
337 detections
338}
339
340fn detect_from_gh(tokens: &[String], location: &str) -> Vec<ApiDetection> {
341 let mut detections = Vec::new();
342 let positional: Vec<&str> = tokens
343 .iter()
344 .skip(1)
345 .filter(|t| !t.starts_with('-'))
346 .map(String::as_str)
347 .collect();
348 if positional.is_empty() {
349 return detections;
350 }
351 let label = format!("gh {} at {}", positional.join(" "), location);
352
353 match positional[0] {
354 "release" => detections.extend(detect_gh_release(&positional, location)),
355 "issue" => detections.extend(detect_gh_issue(&positional, location)),
356 "pr" => detections.extend(detect_gh_pr(&positional, location)),
357 "run" => detections.extend(detect_gh_run(&positional, location)),
358 "workflow" => detections.extend(detect_gh_workflow(&positional, location)),
359 "api" => detections.extend(detect_gh_api(tokens, location)),
360 _ => detections.push(ApiDetection {
361 origin: ApiDetectionOrigin::GhCli,
362 label,
363 method: "GET".to_owned(),
364 path: "gh-unmapped".to_owned(),
365 classification: Compatibility::Unsupported,
366 catalog_match: None,
367 satisfied: false,
368 missing_permissions: Vec::new(),
369 unsupported_reason: Some(format!(
370 "gh-cli.subcommand_not_in_v1: 'gh {}' is not mapped by v1.0",
371 positional[0]
372 )),
373 }),
374 }
375
376 detections
377}
378
379fn detect_gh_release(positional: &[&str], location: &str) -> Vec<ApiDetection> {
380 let sub = positional.get(1).copied().unwrap_or("");
381 let label = |verb: &str| format!("gh release {verb} at {location}");
382 match sub {
383 "create" => vec![
384 release_create(label("create")),
385 release_assets_upload(label("create (asset upload)")),
386 ],
387 "upload" => vec![release_assets_upload(label("upload"))],
388 "edit" => vec![ApiDetection {
389 origin: ApiDetectionOrigin::GhCli,
390 label: label("edit"),
391 method: "PATCH".to_owned(),
392 path: "/repos/{owner}/{repo}/releases/{id}".to_owned(),
393 classification: Compatibility::Simulated,
394 catalog_match: None,
395 satisfied: false,
396 missing_permissions: Vec::new(),
397 unsupported_reason: None,
398 }],
399 "delete" => vec![ApiDetection {
400 origin: ApiDetectionOrigin::GhCli,
401 label: label("delete"),
402 method: "DELETE".to_owned(),
403 path: "/repos/{owner}/{repo}/releases/{id}".to_owned(),
404 classification: Compatibility::Simulated,
405 catalog_match: None,
406 satisfied: false,
407 missing_permissions: Vec::new(),
408 unsupported_reason: None,
409 }],
410 "view" => vec![ApiDetection {
411 origin: ApiDetectionOrigin::GhCli,
412 label: label("view"),
413 method: "GET".to_owned(),
414 path: "/repos/{owner}/{repo}/releases/tags/{tag}".to_owned(),
415 classification: Compatibility::Simulated,
416 catalog_match: None,
417 satisfied: false,
418 missing_permissions: Vec::new(),
419 unsupported_reason: None,
420 }],
421 "list" => vec![ApiDetection {
422 origin: ApiDetectionOrigin::GhCli,
423 label: label("list"),
424 method: "GET".to_owned(),
425 path: "/repos/{owner}/{repo}/releases".to_owned(),
426 classification: Compatibility::Simulated,
427 catalog_match: None,
428 satisfied: false,
429 missing_permissions: Vec::new(),
430 unsupported_reason: None,
431 }],
432 "download" => vec![ApiDetection {
433 origin: ApiDetectionOrigin::GhCli,
434 label: label("download"),
435 method: "GET".to_owned(),
436 path: "/repos/{owner}/{repo}/releases/assets/{id}".to_owned(),
437 classification: Compatibility::Simulated,
438 catalog_match: None,
439 satisfied: false,
440 missing_permissions: Vec::new(),
441 unsupported_reason: None,
442 }],
443 _ => Vec::new(),
444 }
445}
446
447fn release_create(label: String) -> ApiDetection {
448 ApiDetection {
449 origin: ApiDetectionOrigin::GhCli,
450 label,
451 method: "POST".to_owned(),
452 path: "/repos/{owner}/{repo}/releases".to_owned(),
453 classification: Compatibility::Simulated,
454 catalog_match: None,
455 satisfied: false,
456 missing_permissions: Vec::new(),
457 unsupported_reason: None,
458 }
459}
460
461fn release_assets_upload(label: String) -> ApiDetection {
462 ApiDetection {
463 origin: ApiDetectionOrigin::GhCli,
464 label,
465 method: "POST".to_owned(),
466 path: "/repos/{owner}/{repo}/releases/{id}/assets".to_owned(),
467 classification: Compatibility::Simulated,
468 catalog_match: None,
469 satisfied: false,
470 missing_permissions: Vec::new(),
471 unsupported_reason: None,
472 }
473}
474
475fn detect_gh_issue(positional: &[&str], location: &str) -> Vec<ApiDetection> {
476 let sub = positional.get(1).copied().unwrap_or("");
477 let label = |verb: &str| format!("gh issue {verb} at {location}");
478 match sub {
479 "create" => vec![ApiDetection {
480 origin: ApiDetectionOrigin::GhCli,
481 label: label("create"),
482 method: "POST".to_owned(),
483 path: "/repos/{owner}/{repo}/issues".to_owned(),
484 classification: Compatibility::Simulated,
485 catalog_match: None,
486 satisfied: false,
487 missing_permissions: Vec::new(),
488 unsupported_reason: None,
489 }],
490 "comment" => vec![ApiDetection {
491 origin: ApiDetectionOrigin::GhCli,
492 label: label("comment"),
493 method: "POST".to_owned(),
494 path: "/repos/{owner}/{repo}/issues/{number}/comments".to_owned(),
495 classification: Compatibility::Simulated,
496 catalog_match: None,
497 satisfied: false,
498 missing_permissions: Vec::new(),
499 unsupported_reason: None,
500 }],
501 "close" | "reopen" | "edit" | "lock" | "unlock" => vec![ApiDetection {
502 origin: ApiDetectionOrigin::GhCli,
503 label: label(sub),
504 method: "PATCH".to_owned(),
505 path: "/repos/{owner}/{repo}/issues/{number}".to_owned(),
506 classification: Compatibility::Simulated,
507 catalog_match: None,
508 satisfied: false,
509 missing_permissions: Vec::new(),
510 unsupported_reason: None,
511 }],
512 "view" => vec![ApiDetection {
513 origin: ApiDetectionOrigin::GhCli,
514 label: label("view"),
515 method: "GET".to_owned(),
516 path: "/repos/{owner}/{repo}/issues/{number}".to_owned(),
517 classification: Compatibility::Simulated,
518 catalog_match: None,
519 satisfied: false,
520 missing_permissions: Vec::new(),
521 unsupported_reason: None,
522 }],
523 "list" => vec![ApiDetection {
524 origin: ApiDetectionOrigin::GhCli,
525 label: label("list"),
526 method: "GET".to_owned(),
527 path: "/repos/{owner}/{repo}/issues".to_owned(),
528 classification: Compatibility::Simulated,
529 catalog_match: None,
530 satisfied: false,
531 missing_permissions: Vec::new(),
532 unsupported_reason: None,
533 }],
534 _ => Vec::new(),
535 }
536}
537
538fn detect_gh_pr(positional: &[&str], location: &str) -> Vec<ApiDetection> {
539 let sub = positional.get(1).copied().unwrap_or("");
540 let label = |verb: &str| format!("gh pr {verb} at {location}");
541 match sub {
542 "create" => vec![ApiDetection {
543 origin: ApiDetectionOrigin::GhCli,
544 label: label("create"),
545 method: "POST".to_owned(),
546 path: "/repos/{owner}/{repo}/pulls".to_owned(),
547 classification: Compatibility::Simulated,
548 catalog_match: None,
549 satisfied: false,
550 missing_permissions: Vec::new(),
551 unsupported_reason: None,
552 }],
553 "edit" | "close" | "reopen" => vec![ApiDetection {
554 origin: ApiDetectionOrigin::GhCli,
555 label: label(sub),
556 method: "PATCH".to_owned(),
557 path: "/repos/{owner}/{repo}/pulls/{number}".to_owned(),
558 classification: Compatibility::Simulated,
559 catalog_match: None,
560 satisfied: false,
561 missing_permissions: Vec::new(),
562 unsupported_reason: None,
563 }],
564 "review" => vec![ApiDetection {
565 origin: ApiDetectionOrigin::GhCli,
566 label: label("review"),
567 method: "POST".to_owned(),
568 path: "/repos/{owner}/{repo}/pulls/{number}/reviews".to_owned(),
569 classification: Compatibility::Simulated,
570 catalog_match: None,
571 satisfied: false,
572 missing_permissions: Vec::new(),
573 unsupported_reason: None,
574 }],
575 "comment" => vec![ApiDetection {
576 origin: ApiDetectionOrigin::GhCli,
577 label: label("comment"),
578 method: "POST".to_owned(),
579 path: "/repos/{owner}/{repo}/issues/{number}/comments".to_owned(),
580 classification: Compatibility::Simulated,
581 catalog_match: None,
582 satisfied: false,
583 missing_permissions: Vec::new(),
584 unsupported_reason: None,
585 }],
586 "view" => vec![ApiDetection {
587 origin: ApiDetectionOrigin::GhCli,
588 label: label("view"),
589 method: "GET".to_owned(),
590 path: "/repos/{owner}/{repo}/pulls/{number}".to_owned(),
591 classification: Compatibility::Simulated,
592 catalog_match: None,
593 satisfied: false,
594 missing_permissions: Vec::new(),
595 unsupported_reason: None,
596 }],
597 "list" => vec![ApiDetection {
598 origin: ApiDetectionOrigin::GhCli,
599 label: label("list"),
600 method: "GET".to_owned(),
601 path: "/repos/{owner}/{repo}/pulls".to_owned(),
602 classification: Compatibility::Simulated,
603 catalog_match: None,
604 satisfied: false,
605 missing_permissions: Vec::new(),
606 unsupported_reason: None,
607 }],
608 _ => Vec::new(),
609 }
610}
611
612fn detect_gh_run(positional: &[&str], location: &str) -> Vec<ApiDetection> {
613 let sub = positional.get(1).copied().unwrap_or("");
614 let label = |verb: &str| format!("gh run {verb} at {location}");
615 match sub {
616 "view" => vec![ApiDetection {
617 origin: ApiDetectionOrigin::GhCli,
618 label: label("view"),
619 method: "GET".to_owned(),
620 path: "/repos/{owner}/{repo}/actions/runs/{id}".to_owned(),
621 classification: Compatibility::Simulated,
622 catalog_match: None,
623 satisfied: false,
624 missing_permissions: Vec::new(),
625 unsupported_reason: None,
626 }],
627 "list" => vec![ApiDetection {
628 origin: ApiDetectionOrigin::GhCli,
629 label: label("list"),
630 method: "GET".to_owned(),
631 path: "/repos/{owner}/{repo}/actions/runs".to_owned(),
632 classification: Compatibility::Simulated,
633 catalog_match: None,
634 satisfied: false,
635 missing_permissions: Vec::new(),
636 unsupported_reason: None,
637 }],
638 "cancel" => vec![ApiDetection {
639 origin: ApiDetectionOrigin::GhCli,
640 label: label("cancel"),
641 method: "POST".to_owned(),
642 path: "/repos/{owner}/{repo}/actions/runs/{id}/cancel".to_owned(),
643 classification: Compatibility::Simulated,
644 catalog_match: None,
645 satisfied: false,
646 missing_permissions: Vec::new(),
647 unsupported_reason: None,
648 }],
649 _ => Vec::new(),
650 }
651}
652
653fn detect_gh_workflow(positional: &[&str], location: &str) -> Vec<ApiDetection> {
654 let sub = positional.get(1).copied().unwrap_or("");
655 let label = |verb: &str| format!("gh workflow {verb} at {location}");
656 match sub {
657 "run" => vec![ApiDetection {
658 origin: ApiDetectionOrigin::GhCli,
659 label: label("run"),
660 method: "POST".to_owned(),
661 path: "/repos/{owner}/{repo}/actions/workflows/{id}/dispatches".to_owned(),
662 classification: Compatibility::Simulated,
663 catalog_match: None,
664 satisfied: false,
665 missing_permissions: Vec::new(),
666 unsupported_reason: None,
667 }],
668 "view" | "list" => vec![ApiDetection {
669 origin: ApiDetectionOrigin::GhCli,
670 label: label(sub),
671 method: "GET".to_owned(),
672 path: "/repos/{owner}/{repo}/actions/workflows".to_owned(),
673 classification: Compatibility::Simulated,
674 catalog_match: None,
675 satisfied: false,
676 missing_permissions: Vec::new(),
677 unsupported_reason: None,
678 }],
679 _ => Vec::new(),
680 }
681}
682
683fn detect_gh_api(tokens: &[String], location: &str) -> Vec<ApiDetection> {
684 let mut method = "GET".to_owned();
685 let mut path: Option<String> = None;
686 let mut iter = tokens.iter().skip(2).peekable();
687 while let Some(arg) = iter.next() {
688 match arg.as_str() {
689 "-X" | "--method" => {
690 if let Some(next) = iter.next() {
691 method = next.to_uppercase();
692 }
693 }
694 "-H" | "--header" | "-f" | "--field" | "-F" | "--raw-field" | "-q" | "--jq" | "-i"
695 | "--include" | "--paginate" | "--silent" | "-s" => {
696 if matches!(
697 arg.as_str(),
698 "-H" | "--header" | "-f" | "--field" | "-F" | "--raw-field" | "-q" | "--jq"
699 ) {
700 let _ = iter.next();
701 }
702 }
703 other if other.starts_with('-') => {
704 let _ = iter.next();
705 }
706 other if path.is_none() => {
707 path = Some(other.to_owned());
708 }
709 _ => {}
710 }
711 }
712
713 let Some(raw_path) = path else {
714 return Vec::new();
715 };
716
717 let normalized = normalize_api_path(&raw_path);
718 vec![ApiDetection {
719 origin: ApiDetectionOrigin::GhCli,
720 label: format!("gh api {method} {normalized} at {location}"),
721 method,
722 path: normalized,
723 classification: Compatibility::Simulated,
724 catalog_match: None,
725 satisfied: false,
726 missing_permissions: Vec::new(),
727 unsupported_reason: None,
728 }]
729}
730
731fn normalize_api_path(raw: &str) -> String {
732 let stripped = raw.trim();
733 if stripped.starts_with('/') {
734 stripped.to_owned()
735 } else {
736 format!("/{stripped}")
737 }
738}
739
740fn detect_from_curl(tokens: &[String], location: &str) -> Vec<ApiDetection> {
741 let mut method = "GET".to_owned();
742 let mut url: Option<String> = None;
743 let mut iter = tokens.iter().skip(1).peekable();
744 while let Some(arg) = iter.next() {
745 match arg.as_str() {
746 "-X" | "--request" => {
747 if let Some(next) = iter.next() {
748 method = next.to_uppercase();
749 }
750 }
751 "-d" | "--data" | "--data-raw" | "--data-binary" | "-H" | "--header" | "-u"
752 | "--user" | "-A" | "--user-agent" | "--cookie" | "-b" | "-o" | "--output" => {
753 let _ = iter.next();
754 }
755 "-s" | "-sS" | "--silent" | "-L" | "--location" | "-f" | "--fail" | "-i" | "-I"
756 | "--head" | "--retry" => {}
757 other
758 if (other.starts_with("http://") || other.starts_with("https://"))
759 && url.is_none() =>
760 {
761 url = Some(other.to_owned());
762 }
763 other if other.starts_with('-') => {
764 }
766 _ => {}
767 }
768 }
769
770 let Some(url) = url else {
771 return Vec::new();
772 };
773
774 let Some(path) = extract_github_path(&url) else {
775 return Vec::new();
776 };
777
778 vec![ApiDetection {
779 origin: ApiDetectionOrigin::Curl,
780 label: format!("curl {method} {url} at {location}"),
781 method,
782 path,
783 classification: Compatibility::Simulated,
784 catalog_match: None,
785 satisfied: false,
786 missing_permissions: Vec::new(),
787 unsupported_reason: None,
788 }]
789}
790
791fn extract_github_path(url: &str) -> Option<String> {
792 let without_scheme = url
793 .strip_prefix("https://")
794 .or_else(|| url.strip_prefix("http://"))?;
795 let (host, rest) = without_scheme.split_once('/')?;
796 if !GITHUB_API_HOSTS
797 .iter()
798 .any(|known| host.eq_ignore_ascii_case(known))
799 {
800 return None;
801 }
802 Some(format!("/{}", rest))
803}
804
805fn mapping_lookup<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a Yaml> {
806 for (k, v) in map {
807 if let Yaml::String(s) = k {
808 if s == key {
809 return Some(v);
810 }
811 }
812 }
813 None
814}
815
816fn strip_utf8_bom(text: &str) -> &str {
817 text.strip_prefix('\u{feff}').unwrap_or(text)
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823
824 #[test]
825 fn detects_softprops_release_action_with_two_calls() {
826 let detections = detect_from_action("softprops/action-gh-release@v2", "step 0");
827 assert_eq!(detections.len(), 2);
828 assert_eq!(detections[0].path, "/repos/{owner}/{repo}/releases");
829 assert_eq!(
830 detections[1].path,
831 "/repos/{owner}/{repo}/releases/{id}/assets"
832 );
833 }
834
835 #[test]
836 fn detects_gh_release_create_run_block() {
837 let detections = detect_from_run("gh release create v1.0 ./binary.zip\n", "step 1");
838 assert_eq!(detections.len(), 2);
839 assert_eq!(detections[0].method, "POST");
840 assert_eq!(
841 detections[1].path,
842 "/repos/{owner}/{repo}/releases/{id}/assets"
843 );
844 }
845
846 #[test]
847 fn detects_gh_api_post_path_explicitly() {
848 let detections = detect_from_run(
849 "gh api repos/wildmason/mortar/releases --method POST\n",
850 "step 2",
851 );
852 assert_eq!(detections.len(), 1);
853 assert_eq!(detections[0].method, "POST");
854 assert_eq!(detections[0].path, "/repos/wildmason/mortar/releases");
855 }
856
857 #[test]
858 fn detects_curl_github_api_url() {
859 let detections = detect_from_run(
860 "curl -X POST -H 'Authorization: Bearer $TOKEN' https://api.github.com/repos/o/r/issues -d '{}'",
861 "step 3",
862 );
863 assert_eq!(detections.len(), 1);
864 assert_eq!(detections[0].method, "POST");
865 assert_eq!(detections[0].path, "/repos/o/r/issues");
866 }
867
868 #[test]
869 fn ignores_curl_to_non_github_host() {
870 let detections = detect_from_run(
871 "curl -X POST https://hooks.slack.com/services/x/y/z -d '{}'",
872 "step 4",
873 );
874 assert!(detections.is_empty());
875 }
876
877 #[test]
878 fn scan_workflow_detects_gh_release_with_resolved_permissions() {
879 let temp = tempfile::tempdir().unwrap();
880 let repo: Utf8PathBuf = temp.path().to_path_buf().try_into().unwrap();
881 let workflows_dir = repo.join(".github/workflows");
882 std::fs::create_dir_all(&workflows_dir).unwrap();
883 let workflow = workflows_dir.join("release.yml");
884 std::fs::write(
885 &workflow,
886 r#"
887name: Release
888on: push
889permissions:
890 contents: write
891jobs:
892 publish:
893 runs-on: ubuntu-latest
894 steps:
895 - name: Create release
896 run: gh release create v1.0 ./binary.zip
897"#,
898 )
899 .unwrap();
900
901 let reports = scan_workflows(&repo, &[]).unwrap();
902 assert_eq!(reports.len(), 1);
903 let report = &reports[0];
904 assert_eq!(report.jobs.len(), 1);
905 let job = &report.jobs[0];
906 assert_eq!(job.steps.len(), 1);
907 let step = &job.steps[0];
908 assert_eq!(step.detections.len(), 2);
909 assert!(step.detections.iter().all(|d| d.satisfied));
911 }
912
913 #[test]
914 fn scan_workflow_flags_missing_permissions_for_release() {
915 let temp = tempfile::tempdir().unwrap();
916 let repo: Utf8PathBuf = temp.path().to_path_buf().try_into().unwrap();
917 let workflows_dir = repo.join(".github/workflows");
918 std::fs::create_dir_all(&workflows_dir).unwrap();
919 let workflow = workflows_dir.join("release.yml");
920 std::fs::write(
921 &workflow,
922 r#"
923name: Release
924on: push
925permissions:
926 contents: read
927jobs:
928 publish:
929 runs-on: ubuntu-latest
930 steps:
931 - run: gh release create v1.0
932"#,
933 )
934 .unwrap();
935
936 let reports = scan_workflows(&repo, &[]).unwrap();
937 let step = &reports[0].jobs[0].steps[0];
938 assert!(step.detections.iter().any(|d| !d.satisfied));
939 assert!(
940 step.checks
941 .iter()
942 .any(|c| c.id.contains("permissions_insufficient"))
943 );
944 }
945}