1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Severity {
13 High,
15 Medium,
17 Info,
19}
20
21#[derive(Clone)]
23pub struct Finding {
24 pub rule: &'static str,
25 pub severity: Severity,
26 pub file: String,
27 pub line: usize,
28 pub uses: String,
30 pub evidence: String,
31 pub fix_hint: String,
32}
33
34pub struct Suppressed {
36 pub finding: Finding,
37 pub reason: String,
38}
39
40pub 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 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
95pub 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 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
153pub 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
187fn 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
195pub 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
227pub 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
262pub 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
320pub 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
362pub 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
417pub 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
472pub 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 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 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 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
565pub 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}