Skip to main content

just_shield/
rules.rs

1//! 검사 규칙. R1(가변 참조) + 피해 반경 R6(시크릿 노출)·R7(권한 과잉)·R8(위험 트리거).
2
3use crate::github_facts::GithubFacts;
4use crate::lockfile::Lockfile;
5use crate::trust::{Trust, TrustContext};
6use crate::uses_ref::{self, RefKind, UsesRef};
7use crate::workflow::{UsesEntry, WorkflowDoc};
8use std::path::Path;
9
10/// 심각도 등급 (CONTEXT.md). 🔴는 사실 규칙만 낼 수 있다 (ADR-0002).
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Severity {
13    /// 🔴 실제 공격 경로가 열려 있음 — 빌드 실패.
14    High,
15    /// 🟡 피해 확대 요인 — 경고, `--strict`에서 실패.
16    Medium,
17    /// 🔵 안내 — 항상 경고만. 휴리스틱 단독 판정의 상한.
18    Info,
19}
20
21/// 규칙 위반 한 건. 모든 발견에는 근거와 해결 힌트가 붙는다 (ADR-0002 원칙 ③).
22pub struct Finding {
23    pub rule: &'static str,
24    pub severity: Severity,
25    pub file: String,
26    pub line: usize,
27    /// 관련 `uses:` 값. 참조와 무관한 규칙(R7 등)은 빈 문자열.
28    pub uses: String,
29    pub evidence: String,
30    pub fix_hint: String,
31}
32
33/// 무시 주석으로 수용된 발견 — 결과에서 지우지 않고 사유와 함께 남긴다 (침묵 ≠ 은폐).
34pub struct Suppressed {
35    pub finding: Finding,
36    pub reason: String,
37}
38
39/// R1 — 액션의 가변 참조(태그/브랜치/참조 없음) 탐지.
40///
41/// 신뢰 차등: 퍼스트파티(로컬·같은 소유자)는 침묵, GitHub 공식은 🔵 안내,
42/// 그 외 서드파티는 🔴 — 보안 벤더라는 평판도 예외가 아니다 (TeamPCP의 교훈).
43pub fn check_r1(file: &Path, entries: &[UsesEntry], ctx: &TrustContext) -> Vec<Finding> {
44    let mut out = Vec::new();
45    for e in entries {
46        let UsesRef::Repository {
47            owner_repo,
48            git_ref,
49        } = uses_ref::parse(&e.value)
50        else {
51            // 로컬 액션은 퍼스트파티, docker://는 R4(이미지)의 영역.
52            continue;
53        };
54        let trust = ctx.classify(&owner_repo);
55        if trust == Trust::FirstParty {
56            continue;
57        }
58        let ref_problem = match git_ref {
59            Some(RefKind::CommitSha(_)) => continue,
60            Some(RefKind::Mutable(r)) => format!(
61                "`@{r}`은(는) 태그/브랜치 — 공격자가 다른 커밋으로 옮겨 꽂을 수 있는 가변 참조입니다"
62            ),
63            None => {
64                "참조(@버전)가 없습니다 — 기본 브랜치를 그대로 따라가는 가변 참조입니다".to_string()
65            }
66        };
67        let (severity, evidence) = match trust {
68            Trust::Official => (
69                Severity::Info,
70                format!(
71                    "{ref_problem} (GitHub 공식 액션이라 완화 등급 — 그래도 SHA 핀 고정을 권고합니다)"
72                ),
73            ),
74            _ => (
75                Severity::High,
76                format!("{ref_problem} (TeamPCP는 이 방식으로 Trivy 태그 76개를 하이재킹했습니다)"),
77            ),
78        };
79        out.push(Finding {
80            rule: "R1",
81            severity,
82            file: file.display().to_string(),
83            line: e.line,
84            uses: e.value.clone(),
85            evidence,
86            fix_hint: format!(
87                "커밋 SHA로 핀 고정 — uses: {owner_repo}@<40자리 커밋 SHA>  # 원래 버전을 주석으로"
88            ),
89        });
90    }
91    out
92}
93
94/// R2 — 타이포스쿼팅 의심 (기본 🔵, `--online` 교차 검증으로만 격상).
95///
96/// 이름 유사도는 휴리스틱이므로 오프라인 단독 판정은 🔵 상한 (ADR-0002).
97/// 격상 조건은 보수적이다: 의심 저장소는 태그가 거의 없고(≤2) 원본은 풍부(≥10)할 때만.
98/// 애매하면 🔵에 머문다.
99pub fn check_r2(
100    file: &Path,
101    entries: &[UsesEntry],
102    ctx: &TrustContext,
103    facts: Option<&dyn GithubFacts>,
104) -> Vec<Finding> {
105    let popular = crate::typosquat::bundled_popular();
106    let mut out = Vec::new();
107    for e in entries {
108        let UsesRef::Repository { owner_repo, .. } = uses_ref::parse(&e.value) else {
109            continue;
110        };
111        if ctx.classify(&owner_repo) == Trust::FirstParty {
112            continue;
113        }
114        let repo = uses_ref::repo_root(&owner_repo).to_string();
115        let Some(original) = crate::typosquat::similar_popular(&repo, &popular) else {
116            continue;
117        };
118        let base_evidence = format!(
119            "`{repo}`은(는) 유명 액션 `{original}`과(와) 한 글자 차이입니다 —              타이포스쿼팅 위장의 흔한 형태 (TeamPCP는 aquasecurtiy.org 도메인을 썼습니다)"
120        );
121        // 교차 검증: 의심본이 무명(태그 ≤2)이고 원본이 유명(태그 ≥10)할 때만 격상.
122        let corroborated = facts.and_then(|f| {
123            let suspect = f.ref_count(&repo).ok()??;
124            let orig = f.ref_count(&original).ok()??;
125            (suspect <= 2 && orig >= 10).then_some((suspect, orig))
126        });
127        let (severity, evidence) = match corroborated {
128            Some((suspect, orig)) => (
129                Severity::High,
130                format!(
131                    "{base_evidence}. 교차 검증: 의심 저장소는 버전 태그 {suspect}개(무명),                      `{original}`은 {orig}개 — 증거가 모여 격상"
132                ),
133            ),
134            None => (
135                Severity::Info,
136                format!("{base_evidence}. 이름 유사도는 휴리스틱이므로 안내 등급입니다"),
137            ),
138        };
139        out.push(Finding {
140            rule: "R2",
141            severity,
142            file: file.display().to_string(),
143            line: e.line,
144            uses: e.value.clone(),
145            evidence,
146            fix_hint: format!("의도한 액션이 `{original}`인지 철자를 확인하세요"),
147        });
148    }
149    out
150}
151
152/// R3 — 무결성 검증 없는 파이프 설치(`curl ... | sh`) 탐지.
153///
154/// 셸 명령 해석은 본질적으로 휴리스틱이므로 ADR-0002에 따라 단독 판정은 🔵 상한.
155/// 체크섬 검증(sha256sum 등)이 동반된 스텝은 침묵한다.
156pub fn check_r3(file: &Path, doc: &WorkflowDoc) -> Vec<Finding> {
157    let mut out = Vec::new();
158    for job in &doc.jobs {
159        for step in &job.steps {
160            let pipe_install = step
161                .text
162                .lines()
163                .any(|l| (l.contains("curl") || l.contains("wget")) && pipes_to_shell(l));
164            if !pipe_install {
165                continue;
166            }
167            let verified = step.text.contains("sha256sum") || step.text.contains("shasum");
168            if verified {
169                continue;
170            }
171            out.push(Finding {
172                rule: "R3",
173                severity: Severity::Info,
174                file: file.display().to_string(),
175                line: step.line,
176                uses: String::new(),
177                evidence: "다운로드한 스크립트를 검증 없이 바로 실행하는 패턴으로 보입니다 —                            배포 서버가 오염되면 그대로 악성 코드가 실행됩니다 (Trivy식 바이너리 교체 통로).                            셸 해석은 휴리스틱이므로 안내 등급에 머뭅니다"
178                    .into(),
179                fix_hint: "다운로드 후 sha256sum 등으로 체크섬을 검증하고 실행하세요".into(),
180            });
181        }
182    }
183    out
184}
185
186/// `|` 뒤의 첫 명령이 셸인가 — `| shasum`(검증)을 `| sh`로 오인하지 않도록 토큰 단위로 본다.
187fn pipes_to_shell(line: &str) -> bool {
188    line.split('|').skip(1).any(|seg| {
189        let cmd = seg.split_whitespace().next().unwrap_or("");
190        matches!(cmd, "sh" | "bash" | "sudo") || cmd.ends_with("/sh") || cmd.ends_with("/bash")
191    })
192}
193
194/// R4 — 다이제스트 없는 컨테이너 이미지 참조 (🟡).
195///
196/// 다이제스트(`@sha256:`)의 유무는 문법적 사실이다. 태그는 내용물이 바뀔 수 있다.
197pub fn check_r4(file: &Path, entries: &[UsesEntry], images: &[UsesEntry]) -> Vec<Finding> {
198    let mut out = Vec::new();
199    let docker_uses = entries.iter().filter_map(|e| {
200        e.value
201            .strip_prefix("docker://")
202            .map(|img| (e.line, img.to_string(), e.value.clone()))
203    });
204    let image_keys = images
205        .iter()
206        .map(|e| (e.line, e.value.clone(), e.value.clone()));
207    for (line, image, raw) in docker_uses.chain(image_keys) {
208        if image.contains("@sha256:") {
209            continue;
210        }
211        out.push(Finding {
212            rule: "R4",
213            severity: Severity::Medium,
214            file: file.display().to_string(),
215            line,
216            uses: raw,
217            evidence: format!(
218                "`{image}`은(는) 다이제스트 없는 이미지 참조 — 태그는 같은 이름으로 내용물이                  바뀔 수 있는 가변 참조입니다"
219            ),
220            fix_hint: format!("다이제스트로 고정 — {image}@sha256:<다이제스트>"),
221        });
222    }
223    out
224}
225
226/// R6 — 시크릿을 사용하는 잡에서 서드파티 액션 실행 (🟡).
227///
228/// 액션 코드는 같은 잡의 시크릿에 접근 가능한 환경에서 돈다 — 오염되면 함께 털린다.
229pub fn check_r6(file: &Path, doc: &WorkflowDoc, ctx: &TrustContext) -> Vec<Finding> {
230    let mut out = Vec::new();
231    for job in &doc.jobs {
232        if !job.uses_secrets {
233            continue;
234        }
235        for step in &job.steps {
236            let Some(uses) = &step.uses else { continue };
237            let UsesRef::Repository { owner_repo, .. } = uses_ref::parse(uses) else {
238                continue;
239            };
240            if ctx.classify(&owner_repo) != Trust::ThirdParty {
241                continue;
242            }
243            out.push(Finding {
244                rule: "R6",
245                severity: Severity::Medium,
246                file: file.display().to_string(),
247                line: step.line,
248                uses: uses.clone(),
249                evidence: format!(
250                    "잡 '{}'은(는) 시크릿을 사용하는데 같은 잡에서 서드파티 액션이 실행됩니다 — \
251                     액션이 오염되면 시크릿이 함께 털립니다 (TeamPCP의 자격증명 수확 방식)",
252                    job.name
253                ),
254                fix_hint: "시크릿이 필요한 스텝과 서드파티 액션을 별도 잡으로 분리하세요".into(),
255            });
256        }
257    }
258    out
259}
260
261/// R7 — `permissions` 미선언 또는 광범위 권한 (🟡).
262///
263/// 기본 GITHUB_TOKEN은 권한이 넓다 — 탈취 시 피해 반경을 키운다.
264pub fn check_r7(file: &Path, doc: &WorkflowDoc) -> Vec<Finding> {
265    let mut out = Vec::new();
266    let file = file.display().to_string();
267    let broad_hint = "워크플로 상단에 `permissions: contents: read`를 선언하고, 필요한 잡에만 추가 권한을 부여하세요";
268
269    if let Some((line, value)) = &doc.workflow_permissions {
270        if value.contains("write-all") {
271            out.push(Finding {
272                rule: "R7",
273                severity: Severity::Medium,
274                file,
275                line: *line,
276                uses: String::new(),
277                evidence: "`permissions: write-all` — 토큰이 모든 쓰기 권한을 가집니다. \
278                           탈취 시 저장소 변조·2차 감염까지 가능해집니다"
279                    .into(),
280                fix_hint: broad_hint.into(),
281            });
282        }
283        return out;
284    }
285
286    for job in &doc.jobs {
287        match &job.permissions {
288            Some((line, value)) if value.contains("write-all") => out.push(Finding {
289                rule: "R7",
290                severity: Severity::Medium,
291                file: file.clone(),
292                line: *line,
293                uses: String::new(),
294                evidence: format!(
295                    "잡 '{}'의 `permissions: write-all` — 토큰이 모든 쓰기 권한을 가집니다",
296                    job.name
297                ),
298                fix_hint: broad_hint.into(),
299            }),
300            Some(_) => {}
301            None => out.push(Finding {
302                rule: "R7",
303                severity: Severity::Medium,
304                file: file.clone(),
305                line: job.line,
306                uses: String::new(),
307                evidence: format!(
308                    "잡 '{}'에 `permissions` 선언이 없습니다 — 기본 GITHUB_TOKEN은 권한이 넓어 \
309                     탈취 시 피해 반경을 키웁니다 (TeamPCP는 과잉 권한 토큰으로 48개 패키지를 2차 감염시켰습니다)",
310                    job.name
311                ),
312                fix_hint: broad_hint.into(),
313            }),
314        }
315    }
316    out
317}
318
319/// R9 — 알려진 감염 버전 대조 (동봉 권고 DB, 오프라인, 🔴).
320///
321/// 공개 권고 등재 여부는 사실이다. 알려진 악성 버전은 소유자 신뢰와 무관하게
322/// 절대적으로 위험하므로 신뢰 분류를 적용하지 않는다.
323pub fn check_r9(
324    file: &Path,
325    entries: &[UsesEntry],
326    db: &crate::advisory::AdvisoryDb,
327) -> Vec<Finding> {
328    let mut out = Vec::new();
329    for e in entries {
330        let UsesRef::Repository {
331            owner_repo,
332            git_ref: Some(git_ref),
333        } = uses_ref::parse(&e.value)
334        else {
335            continue;
336        };
337        let git_ref = match &git_ref {
338            RefKind::CommitSha(s) => s.as_str(),
339            RefKind::Mutable(r) => r.as_str(),
340        };
341        let repo = uses_ref::repo_root(&owner_repo);
342        let Some(advisory) = db.lookup(repo, git_ref) else {
343            continue;
344        };
345        out.push(Finding {
346            rule: "R9",
347            severity: Severity::High,
348            file: file.display().to_string(),
349            line: e.line,
350            uses: e.value.clone(),
351            evidence: format!(
352                "이 버전은 공개 보안 권고에 악성으로 등재되어 있습니다 — {}: {}",
353                advisory.source, advisory.note
354            ),
355            fix_hint: "즉시 제거/교체하고, 이 버전이 실행된 기간의 CI 로그와 시크릿 노출을 점검하세요 (이미 실행됐다면 사후 대응 필요)".into(),
356        });
357    }
358    out
359}
360
361/// R5 — 임포스터 커밋 검증 (`--online`, 🔴).
362///
363/// 핀된 SHA가 그 저장소의 정식 히스토리에서 도달 가능한가는 조회 가능한 사실이다.
364/// 도달 불가 = 포크 등에 숨긴 커밋을 핀에 꽂은 임포스터 신호 (TeamPCP Trivy 수법).
365pub fn check_r5(
366    file: &Path,
367    entries: &[UsesEntry],
368    facts: &dyn GithubFacts,
369    ctx: &TrustContext,
370) -> Vec<Finding> {
371    let mut out = Vec::new();
372    for e in entries {
373        let UsesRef::Repository {
374            owner_repo,
375            git_ref: Some(RefKind::CommitSha(sha)),
376        } = uses_ref::parse(&e.value)
377        else {
378            continue;
379        };
380        if ctx.classify(&owner_repo) == Trust::FirstParty {
381            continue;
382        }
383        let repo = uses_ref::repo_root(&owner_repo);
384        match facts.commit_reachable(repo, &sha) {
385            Ok(Some(true)) | Ok(None) => {}
386            Ok(Some(false)) => out.push(Finding {
387                rule: "R5",
388                severity: Severity::High,
389                file: file.display().to_string(),
390                line: e.line,
391                uses: e.value.clone(),
392                evidence: format!(
393                    "핀된 커밋 {sha}이(가) `{repo}`의 정식 히스토리에서 도달 불가합니다 — \
394                     포크에 숨긴 커밋을 꽂은 임포스터 커밋 신호 (TeamPCP의 Trivy 공격이 이 수법)"
395                ),
396                fix_hint: "이 SHA의 출처를 확인하고, 업스트림 정식 릴리스의 SHA로 교체하세요"
397                    .into(),
398            }),
399            Err(_) => out.push(Finding {
400                rule: "R5",
401                severity: Severity::Info,
402                file: file.display().to_string(),
403                line: e.line,
404                uses: e.value.clone(),
405                evidence: format!(
406                    "`{repo}@{sha}`의 도달 가능성을 확인하지 못했습니다 — 판정 보류 \
407                     (확인 불가는 오탐을 만들지 않습니다)"
408                ),
409                fix_hint: "네트워크 상태를 확인하고 다시 시도하세요".into(),
410            }),
411        }
412    }
413    out
414}
415
416/// R10 — 쿨다운: 발행된 지 기준 일수가 안 된 참조 경고 (`--online`, 🟡).
417///
418/// 제로데이를 탐지하는 게 아니라 미검증 기간을 회피하는 전략이다 (CONTEXT.md).
419/// 시각을 알 수 없는 참조는 판정하지 않는다 (추측 금지).
420pub fn check_r10(
421    file: &Path,
422    entries: &[UsesEntry],
423    facts: &dyn GithubFacts,
424    ctx: &TrustContext,
425    cooldown_days: u32,
426    now: i64,
427) -> Vec<Finding> {
428    let mut out = Vec::new();
429    let threshold = i64::from(cooldown_days) * 86_400;
430    for e in entries {
431        let UsesRef::Repository {
432            owner_repo,
433            git_ref: Some(_),
434        } = uses_ref::parse(&e.value)
435        else {
436            continue;
437        };
438        if ctx.classify(&owner_repo) == Trust::FirstParty {
439            continue;
440        }
441        let repo = uses_ref::repo_root(&owner_repo);
442        let git_ref = e.value.split_once('@').map(|(_, r)| r).unwrap_or_default();
443        let Ok(Some(ts)) = facts.ref_timestamp(repo, git_ref) else {
444            continue;
445        };
446        let age = now - ts;
447        if age >= threshold {
448            continue;
449        }
450        let age_days = age / 86_400;
451        out.push(Finding {
452            rule: "R10",
453            severity: Severity::Medium,
454            file: file.display().to_string(),
455            line: e.line,
456            uses: e.value.clone(),
457            evidence: format!(
458                "이 참조는 발행된 지 {age_days}일밖에 안 됐습니다 (기준 {cooldown_days}일) — \
459                 갓 나온 버전은 아직 아무도 검증하지 않은 버전입니다. 오염은 보통 며칠 내 \
460                 발각되므로, 숙성 기간은 미검증 창(제로데이 창)을 회피하는 전략입니다"
461            ),
462            fix_hint: format!(
463                "{cooldown_days}일이 지난 뒤 도입하거나, 검증된 이전 버전을 사용하세요 \
464                 (기준 조정: --cooldown-days)"
465            ),
466        });
467    }
468    out
469}
470
471/// LOCK — shield.lock 박제본 대비 태그 이동 탐지 (ADR-0003).
472///
473/// 박제 SHA ≠ 현재 SHA는 조회 가능한 사실이다. 단 `v4` 같은 메이저 별칭과 브랜치는
474/// 정상적으로도 이동하므로 🔵 안내에 머물고, 점이 포함된 정확 버전 태그의 이동만
475/// 🔴다 — 이것이 TeamPCP가 Trivy 76개 태그에 쓴 하이재킹의 형태다.
476pub fn check_lock(
477    file: &Path,
478    entries: &[UsesEntry],
479    lockfile: &Lockfile,
480    facts: Option<&dyn GithubFacts>,
481    ctx: &TrustContext,
482) -> Vec<Finding> {
483    let mut out = Vec::new();
484    for e in entries {
485        let UsesRef::Repository {
486            owner_repo,
487            git_ref: Some(RefKind::Mutable(git_ref)),
488        } = uses_ref::parse(&e.value)
489        else {
490            continue;
491        };
492        // 퍼스트파티는 섭취 검증 대상이 아니다 (CONTEXT.md) — LOCK도 Tier 1 규칙.
493        if ctx.classify(&owner_repo) == Trust::FirstParty {
494            continue;
495        }
496        let repo = uses_ref::repo_root(&owner_repo).to_string();
497        let Some(locked_sha) = lockfile.get(&repo, &git_ref) else {
498            out.push(Finding {
499                rule: "LOCK",
500                severity: Severity::Info,
501                file: file.display().to_string(),
502                line: e.line,
503                uses: e.value.clone(),
504                evidence: format!(
505                    "가변 참조 `{repo}@{git_ref}`이(가) shield.lock에 박제되어 있지 않습니다 — \
506                     이동 감시 대상에서 빠져 있습니다"
507                ),
508                fix_hint: "`just-shield lock`을 실행해 박제본을 갱신하세요".into(),
509            });
510            continue;
511        };
512        let Some(facts) = facts else {
513            // 오프라인: 현재 SHA를 조회할 수 없으므로 대조는 건너뛴다 (오탐 금지).
514            continue;
515        };
516        let current = match facts.resolve_ref(&repo, &git_ref) {
517            Ok(Some(sha)) => sha,
518            Ok(None) | Err(_) => {
519                out.push(Finding {
520                    rule: "LOCK",
521                    severity: Severity::Info,
522                    file: file.display().to_string(),
523                    line: e.line,
524                    uses: e.value.clone(),
525                    evidence: format!(
526                        "`{repo}@{git_ref}`의 현재 SHA를 확인하지 못했습니다 — 판정 보류 (확인 불가는 오탐을 만들지 않습니다)"
527                    ),
528                    fix_hint: "네트워크 상태를 확인하고 다시 시도하세요".into(),
529                });
530                continue;
531            }
532        };
533        if current == locked_sha {
534            continue;
535        }
536        // 정확 버전 태그(점 포함)는 정상 상황에서 움직이지 않는다 → 이동 = 🔴.
537        // 메이저 별칭(v4)·브랜치는 릴리스마다 합법적으로 이동할 수 있다 → 🔵.
538        let exact_version = git_ref.contains('.');
539        let (severity, label) = if exact_version {
540            (Severity::High, "태그 하이재킹 신호")
541        } else {
542            (
543                Severity::Info,
544                "이동 감지 — 메이저 별칭/브랜치는 정상 릴리스로도 이동합니다",
545            )
546        };
547        out.push(Finding {
548            rule: "LOCK",
549            severity,
550            file: file.display().to_string(),
551            line: e.line,
552            uses: e.value.clone(),
553            evidence: format!(
554                "박제 시점의 `{repo}@{git_ref}`은(는) {locked_sha}였는데 지금은 {current}를 \
555                 가리킵니다 — {label} (TeamPCP가 Trivy/KICS에 쓴 수법)"
556            ),
557            fix_hint: "업스트림 릴리스 노트로 의도된 변경인지 확인하고, 맞다면 `just-shield lock`을 재실행하세요"
558                .into(),
559        });
560    }
561    out
562}
563
564/// R8 — 위험 트리거(`pull_request_target`/`workflow_run`) + 외부 PR 코드 체크아웃 (🔴).
565///
566/// 두 설정의 조합이 파일에 존재하는가라는 사실 판정 (ADR-0002).
567pub fn check_r8(file: &Path, doc: &WorkflowDoc) -> Vec<Finding> {
568    let dangerous_trigger =
569        doc.on_text.contains("pull_request_target") || doc.on_text.contains("workflow_run");
570    if !dangerous_trigger {
571        return Vec::new();
572    }
573    let mut out = Vec::new();
574    for job in &doc.jobs {
575        for step in &job.steps {
576            let checks_out_pr = step.text.contains("github.event.pull_request.head")
577                || step.text.contains("github.head_ref");
578            if !checks_out_pr {
579                continue;
580            }
581            out.push(Finding {
582                rule: "R8",
583                severity: Severity::High,
584                file: file.display().to_string(),
585                line: step.line,
586                uses: step.uses.clone().unwrap_or_default(),
587                evidence: "위험 트리거는 시크릿 접근 권한으로 실행되는데, 이 스텝이 외부 PR의 \
588                           코드를 체크아웃합니다 — 외부인이 시크릿 있는 환경에서 코드를 실행할 수 \
589                           있게 됩니다"
590                    .into(),
591                fix_hint: "`pull_request` 트리거로 바꾸거나, 외부 PR head 체크아웃을 제거하세요"
592                    .into(),
593            });
594        }
595    }
596    out
597}