1use crate::egress_lockfile::{self, EgressLock};
11use crate::rules::{Finding, Severity};
12use std::collections::BTreeSet;
13
14pub struct Record {
16 pub job: String,
17 pub domains: BTreeSet<String>,
18}
19
20pub fn parse_record(content: &str) -> Result<Record, String> {
22 let mut job: Option<String> = None;
23 let mut domains = BTreeSet::new();
24 for (idx, raw) in content.lines().enumerate() {
25 let line_no = idx + 1;
26 let line = raw.split('#').next().unwrap_or("").trim();
27 if line.is_empty() {
28 continue;
29 }
30 if let Some(name) = line.strip_prefix("job ") {
31 if job.is_some() {
32 return Err(format!("{line_no}행: job 줄이 중복됩니다"));
33 }
34 let name = name.trim();
35 if name.is_empty() {
36 return Err(format!("{line_no}행: 잡 이름이 비었습니다"));
37 }
38 job = Some(name.to_string());
39 continue;
40 }
41 if job.is_none() {
42 return Err(format!(
43 "{line_no}행: 첫 유효 줄은 `job <이름>`이어야 합니다"
44 ));
45 }
46 if line.split_whitespace().count() != 1 {
47 return Err(format!("{line_no}행: 도메인은 한 줄에 하나입니다"));
48 }
49 domains.insert(egress_lockfile::normalize(line));
50 }
51 let job = job.ok_or("기록에 `job <이름>` 줄이 없습니다")?;
52 Ok(Record { job, domains })
53}
54
55pub struct ObserveOutcome {
57 pub job: String,
58 pub observed: BTreeSet<String>,
59 pub locked: bool,
61 pub findings: Vec<Finding>,
63 pub draft: Option<String>,
65}
66
67pub fn verdict(record: &Record, lock: Option<&EgressLock>) -> ObserveOutcome {
69 let section = lock.and_then(|l| l.job(&record.job));
70 match section {
71 None => {
72 let mut draft = format!("[{}]\n", record.job);
74 for d in &record.domains {
75 draft.push_str(d);
76 draft.push('\n');
77 }
78 ObserveOutcome {
79 job: record.job.clone(),
80 observed: record.domains.clone(),
81 locked: false,
82 findings: Vec::new(),
83 draft: Some(draft),
84 }
85 }
86 Some(section) => {
87 let mut findings = Vec::new();
88 for domain in &record.domains {
89 let allowed = section
90 .patterns
91 .iter()
92 .any(|p| egress_lockfile::matches(p, domain));
93 if allowed {
94 continue;
95 }
96 findings.push(Finding {
97 rule: "EGRESS",
98 severity: Severity::High,
99 file: egress_lockfile::FILE_NAME.to_string(),
100 line: section.line,
101 uses: domain.clone(),
102 evidence: format!(
103 "잡 '{}'이(가) egress.lock [{}] 구획에 없는 '{}'을(를) 조회했습니다 — \
104 유출 신호일 수 있습니다. 이 잡이 쓰는 시크릿·토큰을 회전하고 통신 경위를 확인하세요 \
105 (TeamPCP류 사건의 피해자들은 유출을 몇 주 뒤에야 알았습니다)",
106 record.job, record.job, domain
107 ),
108 fix_hint: format!(
109 "의도된 통신이라면 egress.lock [{}] 구획에 다음 한 줄을 추가하세요: {}",
110 record.job, domain
111 ),
112 });
113 }
114 ObserveOutcome {
115 job: record.job.clone(),
116 observed: record.domains.clone(),
117 locked: true,
118 findings,
119 draft: None,
120 }
121 }
122 }
123}
124
125pub fn render_text(outcome: &ObserveOutcome) -> String {
127 let mut s = format!(
128 "just-shield observe — 잡 '{}'의 통신 기록 (도메인 {}개)\n\n",
129 outcome.job,
130 outcome.observed.len()
131 );
132 for d in &outcome.observed {
133 s.push_str(&format!(" {d}\n"));
134 }
135 s.push('\n');
136 if !outcome.locked {
137 s.push_str(
138 "이 잡은 egress.lock에 없습니다 — 관찰 보고만 합니다 (실패하지 않음)\n\
139 잠그려면 아래 초안을 검토해 egress.lock에 추가하세요:\n\n",
140 );
141 if let Some(draft) = &outcome.draft {
142 s.push_str(draft);
143 }
144 return s;
145 }
146 if outcome.findings.is_empty() {
147 s.push_str(&format!(
148 "✅ egress.lock [{}] 박제본과 일치 — 평소와 같은 통신입니다\n",
149 outcome.job
150 ));
151 return s;
152 }
153 for f in &outcome.findings {
154 s.push_str(&format!("🔴 {} {}:{}\n", f.rule, f.file, f.line));
155 s.push_str(&format!(" 목적지: {}\n", f.uses));
156 s.push_str(&format!(" 근거: {}\n", f.evidence));
157 s.push_str(&format!(" 해결: {}\n\n", f.fix_hint));
158 }
159 s.push_str(&format!(
160 "요약: 🔴 미등재 목적지 {}건 — 빌드 실패\n",
161 outcome.findings.len()
162 ));
163 s
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn lock(text: &str) -> EgressLock {
171 EgressLock::parse(text).unwrap()
172 }
173
174 #[test]
175 fn unlocked_job_never_fails_and_gets_draft() {
176 let record = parse_record("job build\ncrates.io\nGITHUB.COM.\n").unwrap();
177 let out = verdict(&record, Some(&lock("[release]\nghcr.io\n")));
178 assert!(!out.locked);
179 assert!(out.findings.is_empty());
180 let draft = out.draft.unwrap();
181 assert!(draft.starts_with("[build]\n"));
182 assert!(draft.contains("github.com\n"));
184 }
185
186 #[test]
187 fn no_lock_at_all_gets_draft() {
188 let record = parse_record("job release\nghcr.io\n").unwrap();
189 let out = verdict(&record, None);
190 assert!(!out.locked);
191 assert!(out.draft.is_some());
192 }
193
194 #[test]
195 fn locked_job_unlisted_domain_is_high() {
196 let record = parse_record("job release\nghcr.io\nevil.net\n").unwrap();
197 let out = verdict(&record, Some(&lock("[release]\nghcr.io\n")));
198 assert!(out.locked);
199 assert_eq!(out.findings.len(), 1);
200 let f = &out.findings[0];
201 assert_eq!(f.rule, "EGRESS");
202 assert_eq!(f.severity, Severity::High);
203 assert_eq!(f.uses, "evil.net");
204 assert!(f.evidence.contains("회전"));
205 assert!(f.fix_hint.contains("evil.net"));
206 }
207
208 #[test]
209 fn locked_job_all_listed_including_wildcard_is_silent() {
210 let record = parse_record("job release\nghcr.io\nabc123.blob.core.windows.net\n").unwrap();
211 let out = verdict(
212 &record,
213 Some(&lock("[release]\nghcr.io\n*.blob.core.windows.net\n")),
214 );
215 assert!(out.locked);
216 assert!(out.findings.is_empty());
217 }
218
219 #[test]
220 fn record_parse_rejects_malformed_input() {
221 assert!(parse_record("crates.io\n").is_err()); assert!(parse_record("job a\njob b\n").is_err()); assert!(parse_record("job a\ntwo tokens\n").is_err());
224 }
225}