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