Skip to main content

gha_github_service_proof/
workflow.rs

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                // unknown flag with potential value; consume conservatively
765            }
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        // both detections should be satisfied by contents: write
910        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}