Skip to main content

just_shield/
observe.rs

1//! 관찰 기록 → 판정 (층 ⓒ 유출 정책층의 판정 코어, ADR-0006).
2//!
3//! 관찰자(DNS 중계)와 판정은 기록 파일 하나로 분리된다 — 그래서 이 모듈의 모든
4//! 판정은 네트워크 없이 손으로 쓴 기록으로 테스트된다 (v1의 facts.txt 패턴).
5//!
6//! 판정 정책: 락에 없는 잡 = 보고 + 초안 제안, 절대 실패 없음.
7//! 락에 있는 잡 = 미등재 목적지 관찰 시 🔴 — "조회했다 + 락에 없다"는 둘 다
8//! 검증 가능한 사실이다 (ADR-0002). 탈출구는 락 편집 그 자체.
9
10use crate::egress_lockfile::{self, EgressLock};
11use crate::rules::{Finding, Severity};
12use std::collections::BTreeSet;
13
14/// 관찰 기록 — 관찰자가 남기는 "잡 이름 + 조회된 도메인 집합".
15pub struct Record {
16    pub job: String,
17    pub domains: BTreeSet<String>,
18}
19
20/// 기록 파일 파싱. 첫 유효 줄은 `job <이름>`, 이후는 한 줄 한 도메인.
21pub 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
55/// 판정 결과.
56pub struct ObserveOutcome {
57    pub job: String,
58    pub observed: BTreeSet<String>,
59    /// 잡이 egress.lock에 있었는가 (잠금 선택제).
60    pub locked: bool,
61    /// 잠근 잡의 미등재 목적지 — 🔴 EGRESS.
62    pub findings: Vec<Finding>,
63    /// 락에 없는 잡에게 제안하는 복붙용 초안.
64    pub draft: Option<String>,
65}
66
67/// 기록 + (있다면) 락 → 판정.
68pub 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            // 잠그지 않은 잡 — 어떤 입력에도 실패하지 않는다.
73            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
125/// 사람용 관찰 보고서.
126pub 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        // 정규화: 소문자·끝점 제거.
183        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()); // job 줄 없음
222        assert!(parse_record("job a\njob b\n").is_err()); // 중복
223        assert!(parse_record("job a\ntwo tokens\n").is_err());
224    }
225}