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
21pub struct Finding {
23 pub rule: &'static str,
24 pub severity: Severity,
25 pub file: String,
26 pub line: usize,
27 pub uses: String,
29 pub evidence: String,
30 pub fix_hint: String,
31}
32
33pub struct Suppressed {
35 pub finding: Finding,
36 pub reason: String,
37}
38
39pub 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 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
94pub 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 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
152pub 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
186fn 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
194pub 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
226pub 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
261pub 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
319pub 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
361pub 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
416pub 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
471pub 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 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 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 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
564pub 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}