diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c9f78..fe77e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ ### 추가 +- **정책 파일 신뢰 기록 (TOFU).** `airlock run`이 정책 파일을 읽을 때마다 정책 다이제스트를 `<감사루트>/trusted-policies.jsonl`의 기록과 대조함. 처음 보거나 바뀐 정책은 `/dev/tty`로 사람에게 확인받고, 터미널이 없으면 종료 코드 78로 거부하며 **`--yes`와 `--observe`로 건너뛸 수 없음**. `airlock policy trust [PATH] [--list]`가 신설되고 `airlock setup`이 만든 파일은 자동으로 기록됨. Linux Landlock이 아직 없는 경로의 생성을 거부할 수 없어 샌드박스 안 에이전트가 `./airlock.toml`을 심을 수 있다는 한계의 로드 시점 방어임. 근거는 `docs/design.md` 9.6 +- **`--mediate full`이 rename과 link 계열을 중계함.** `rename`, `renameat`, `renameat2`, `link`, `linkat`, `symlink`, `symlinkat`을 원본 `delete`와 목적지 `create`로 판정하며 하나라도 deny면 거부함. 같은 디렉토리 안 `rename`으로 자기보호 대상 정책 파일을 갈아 끼우는 경로가 이 수준에서 닫힘. 기본 수준 `exec-net`에는 넣지 않음 +- **Linux seccomp 필터가 모든 중계 수준(`off` 포함)에서 `ioctl(TIOCSTI)`와 `ioctl(TIOCLINUX)`를 EPERM으로 거부함.** 자식이 상속한 터미널에 입력을 밀어 넣어 ask 승인 프롬프트를 위조하는 경로를 닫음. macOS는 Seatbelt가 이미 거부함. 정상 프로그램은 두 ioctl을 쓰지 않으며 bubblewrap과 flatpak이 같은 조치를 함 - **출력 로케일 (한국어·영문).** 모든 사람용 출력(CLI 도움말, 배너와 한계 목록, 승인 프롬프트, 감사 보고, 마법사, 생성되는 정책 파일의 주석)이 로케일을 따름. 결정 순서는 `AIRLOCK_LANG` -> `~/.config/airlock/config.toml`의 `locale` 키 -> `LC_ALL`/`LC_MESSAGES`/`LANG` 접두 -> 기본 한국어. 기계 판독 필드(JSON 키, `kind`·`status`·`verdict`, 스키마 이름, 규칙 id)와 **정책 다이제스트는 로케일과 무관하게 고정**임. 자세한 것은 `docs/i18n.md` - **`airlock setup` 첫 질문이 언어 선택.** 답하는 즉시 나머지 질문이 그 언어로 나가고 선택이 구성 파일에 저장됨. 영문 프리셋(`examples/policy/en/*.toml`)이 추가되었으며 한국어판과 주석만 다르고 다이제스트가 같음을 테스트가 강제함 - `airlock audit report --json` 최상위에 `locale` 필드 추가. 이상 `detail` 산문이 로케일을 따르므로 리포트 본문 다이제스트가 생성 로케일에 묶이며, `report`와 `ack`는 같은 로케일에서 돌려야 함 (`docs/limitations.md` 7.18) @@ -41,6 +44,12 @@ ### 수정 +- **macOS 프록시 모드가 유닉스 도메인 소켓을 통째로 열던 문제.** `(allow network-outbound (remote unix))`를 무조건 방출해 샌드박스 안 프로세스가 바깥 유닉스 소켓 리스너로 데이터를 내보내고 상속된 `SSH_AUTH_SOCK`으로 ssh-agent에 서명을 요청할 수 있었으며, 살아 있는 docker.sock이면 컨테이너 데몬에 닿았음. 전부 프록시도 감사 로그도 거치지 않았음. 이제 `/private/var/run/mDNSResponder`와 `/private/var/run/syslog` 리터럴 두 개만 열고 배너와 `gaps()`에 그 목록을 드러냄. 자식 환경에서 `SSH_AUTH_SOCK`과 `DOCKER_HOST`를 벗김 +- **앵커 재앵커링으로 종료 뒤 덧붙이기가 탐지되지 않던 문제.** 같은 세션의 두 번째 앵커 줄이 경고로만 남아, 체인과 앵커 파일에 append만 할 수 있는 공격자가 종료된 세션에 엔트리를 덧붙이고 새 앵커 한 줄로 검증을 통과시킬 수 있었음. 세션당 앵커는 정확히 하나이며 초과는 실패임. 앵커 뒤 엔트리(`entries_after_anchor`), `session_end` 뒤 엔트리, 앵커된 세션의 `head.json` 뒤처짐(`anchored_head_lag`, `fsync_per_entry = false`만 예외)이 전부 검증 실패와 증거 이상이 됨. `Session::finish()`를 두 번 부르면 오류 +- **상대 심볼릭 링크 대상을 어휘적 부모에 붙여 해소하던 문제.** 심볼릭 링크된 디렉토리 아래의 `x -> ../id_rsa`를 커널은 링크 대상 쪽 파일로 여는데 엔진은 작업 공간 안 경로로 계산해 작업 공간 규칙으로 허용했음. 상대 대상은 해소된 부모에 붙이고, 매달린 링크도 같은 기준으로 계산함 +- **macOS `/System/Volumes/Data/...` 표기가 전 티어를 비껴가던 문제.** firmlink 때문에 같은 파일의 두 번째 철자가 자기보호와 `~/.ssh/**` forbid에 매칭하지 않았음 (커널 Seatbelt는 막았지만 정책 판정, `explain`, `--observe`, 감사 귀속이 틀렸음). `/usr/share/firmlinks` 표(못 읽으면 내장 목록)에 있는 대상을 루트 표기로 접으며 요청 경로와 규칙 경로 양쪽에 적용함. 정책 다이제스트는 바뀌지 않음 +- **프록시 평문 전달 경로의 bare-LF 요청 스머글링.** 헤드를 `\r\n`으로만 잘라 줄 안의 `\n`이 값에 남았고 그것이 업스트림에 그대로 재조립되어 두 번째 `Host`를 심을 수 있었음. CRLF 쌍이 아닌 CR과 LF, 헤더 값의 제어문자, obs-fold, 중복 `Host`, `HTTP/1.0`과 `HTTP/1.1` 이외의 버전, 비ASCII 호스트를 판정 전에 400으로 거부하고 업스트림에 접속하지 않음 +- **중계 층이 `dirfd`를 부호 있는 64비트로 읽던 문제.** 커널은 하위 32비트만 보므로 상위 비트를 세운 값이 `AT_FDCWD`로 해석되어야 하는데 기준을 잃고 브로커 cwd에 붙였음. 커널과 같이 읽고 기준을 못 찾으면 거부함 - **Landlock 순회 예산이 루트 전체에서 공유되어 뒤쪽 루트가 통째로 빠지던 문제.** `/usr` 아래 큰 트리(안드로이드 SDK, CUDA 등)가 예산 20만을 다 쓰면 그 뒤의 `/bin`, `/sbin`, `/lib`, `/etc`, 작업 공간이 규칙을 하나도 못 받아 프로그램이 exec 조차 되지 않았음. 예산을 루트마다 새로 주고, 예산이 끊겨도 그 전에 검사를 마친 항목은 규칙을 받게 함 - **나열할 수 없는 디렉토리가 개별 허용 단계에서 도로 열리던 문제.** 순회는 `Denied`로 판정했지만 매칭되는 규칙이 없어 `blocked`가 false 라, 하위를 검사하지도 못한 채 규칙을 받았음. 순회 결과를 기억해 개별 허용에서 제외함 - **순회 순서가 `read_dir` 반환 순서에 의존하던 문제.** 파일시스템 해시 순서라 같은 정책이 머신마다 다른 강제 범위를 냈음. 이름 순으로 고정함 diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 10ee3e2..3549463 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -40,7 +40,7 @@ 이것이 가장 중요합니다. 별표 7은 **조직이 사용자 단말에 통제를 강제하는** 구도를 전제합니다. 반면 airlock의 신뢰 경계는 "브로커 vs. 에이전트"이고 **사용자는 경계 안쪽**입니다. 구체적으로, -- 정책 파일은 호출자 uid가 소유하고 group·other 쓰기가 없으면 그대로 신뢰됩니다(`airlock-policy/src/path.rs:51`). 서명도 중앙 배포도 요구하지 않기 때문에 사용자가 언제든 다시 쓸 수 있습니다. +- 정책 파일은 호출자 uid가 소유하고 group·other 쓰기가 없으면 읽히고(`airlock-policy/src/path.rs:51`), 처음 보거나 다이제스트가 바뀐 파일은 `/dev/tty`에서 사용자 확인을 한 번 받아 신뢰 기록에 남습니다(`docs/design.md` 9.6). 확인의 주체가 사용자 자신이고 서명도 중앙 배포도 요구하지 않기 때문에 사용자가 언제든 다시 쓰고 다시 신뢰할 수 있습니다. - 감사 로그 위치는 `AIRLOCK_AUDIT_DIR`로 사용자가 옮길 수 있습니다(`airlock/src/paths.rs:57`). - 무엇보다 명령 앞에 `airlock run --`을 붙이지 않으면 아무 통제도 걸리지 않습니다. diff --git a/Cargo.lock b/Cargo.lock index 933f705..b235892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ "airlock-proxy", "airlock-setup", "clap", + "libc", "serde_json", ] diff --git a/README.md b/README.md index ba275a5..95fb769 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ $ airlock policy check $ airlock policy explain --file ~/.ssh/id_rsa $ airlock policy explain --exec rm -rf / -# Run under the broker +# Run under the broker. A policy file seen for the first time gets a [y/N] prompt (files written by setup are already trusted) $ airlock run -- claude # Verify and inspect what happened @@ -40,8 +40,11 @@ If there is no policy file, only the built-in baseline applies. Airlock looks fo ```bash $ cp examples/policy/strict.toml airlock.toml +$ airlock policy trust airlock.toml ``` +A policy file you placed by hand is shown (path and digest) on the first `airlock run` and must be confirmed with `[y/N]`. Runs without a terminal (CI, headless) are refused instead of prompted, so approve first with `airlock policy trust` as above. A changed policy is asked about again, and `--yes` does not skip this check. + The semantics of the policy system are written up in [policy-dsl.md](docs/policy-dsl.md). All human-facing output is available in Korean (default) and English. `airlock setup` asks for the language first and saves the choice to `~/.config/airlock/config.toml`; `AIRLOCK_LANG=en` overrides it per run. English preset variants live in `examples/policy/en/`. Details in [i18n.md](docs/i18n.md). diff --git a/README_KR.md b/README_KR.md index a8c54f7..78e0a69 100644 --- a/README_KR.md +++ b/README_KR.md @@ -24,7 +24,7 @@ $ airlock policy check $ airlock policy explain --file ~/.ssh/id_rsa $ airlock policy explain --exec rm -rf / -# 브로커 아래에서 실행 +# 브로커 아래에서 실행. 처음 보는 정책 파일이면 [y/N] 확인을 받음 (setup 이 만든 파일은 이미 신뢰됨) $ airlock run -- claude # 무슨 일이 있었는지 검증, 조회 @@ -40,8 +40,11 @@ $ airlock audit ack --note "일일 점검" ```bash $ cp examples/policy/strict.toml airlock.toml +$ airlock policy trust airlock.toml ``` +손으로 둔 정책 파일은 첫 `airlock run`에서 경로와 다이제스트를 보여 주고 `[y/N]` 확인을 받습니다. 터미널이 없는 실행(CI, 헤드리스)은 확인 없이 거부되므로 위처럼 `airlock policy trust`로 먼저 승인합니다. 정책 내용이 바뀌면 다시 묻고, `--yes`로는 건너뛸 수 없습니다. + 정책 시스템에 관한 의미론적 문서를 [policy-dsl.md](docs/policy-dsl.md)에 정리해 두었습니다. 모든 사람용 출력은 한국어(기본)와 영문을 지원합니다. `airlock setup`의 첫 질문이 언어 선택이며 선택은 `~/.config/airlock/config.toml`에 저장됩니다. 일회성 전환은 `AIRLOCK_LANG=en`으로 합니다. 영문 프리셋은 `examples/policy/en/`에 있습니다. 자세한 내용은 [i18n.md](docs/i18n.md)를 참고하세요. diff --git a/crates/airlock-audit/src/anchor.rs b/crates/airlock-audit/src/anchor.rs index d221427..eecdea0 100644 --- a/crates/airlock-audit/src/anchor.rs +++ b/crates/airlock-audit/src/anchor.rs @@ -42,6 +42,7 @@ use crate::error::Error; use crate::log::{FILE_MODE, create_dir_all_private}; use crate::time::{format_rfc3339_nanos, now_unix_nanos}; use crate::types::{Hash, SessionId}; +use crate::verify::VerifyReport; pub const ANCHOR_FILE: &str = "anchors.jsonl"; pub const ANCHOR_DOMAIN: &[u8] = b"airlock.anchor.v1\x00"; @@ -285,6 +286,13 @@ pub enum AnchorFailure { previous: u64, got: u64, }, + SessionReanchored { + seq: u64, + session: SessionId, + first_seq: u64, + previous: u64, + got: u64, + }, SessionHeadMismatch { session: SessionId, anchor_seq: u64, @@ -293,6 +301,18 @@ pub enum AnchorFailure { chain_head_seq: u64, chain_head_hash: Hash, }, + EntriesAfterAnchor { + session: SessionId, + anchor_seq: u64, + anchor_head_seq: u64, + chain_head_seq: u64, + }, + AnchoredHeadLag { + session: SessionId, + anchor_seq: u64, + head_seq: u64, + chain_seq: u64, + }, Io(Error), } @@ -427,6 +447,24 @@ impl fmt::Display for AnchorFailure { ) ) ), + Self::SessionReanchored { + seq, + session, + first_seq, + previous, + got, + } => write!( + f, + "{}", + tr!( + format!( + "앵커 seq {seq}가 세션 {session}을 다시 앵커함 (처음은 seq {first_seq}, head_seq {previous} -> {got}). 세션당 앵커는 정확히 하나이므로 종료 뒤 덧붙이기 의심" + ), + format!( + "anchor seq {seq} re-anchors session {session} (first at seq {first_seq}, head_seq {previous} -> {got}); a session has exactly one anchor, so append after close is suspected" + ) + ) + ), Self::SessionHeadMismatch { session, anchor_seq, @@ -435,16 +473,16 @@ impl fmt::Display for AnchorFailure { chain_head_seq, chain_head_hash, } => { - if chain_head_seq > anchor_head_seq { + if chain_head_seq < anchor_head_seq { write!( f, "{}", tr!( format!( - "세션 {session}의 체인이 앵커보다 김. 앵커(seq {anchor_seq})는 head {anchor_head_seq}, 체인은 head {chain_head_seq}. 앵커 이후 덧붙이기 의심" + "세션 {session}의 체인이 앵커보다 짧음. 앵커(seq {anchor_seq})는 head {anchor_head_seq}, 체인은 head {chain_head_seq}. 잘라내기 의심" ), format!( - "the chain of session {session} is longer than the anchor; the anchor (seq {anchor_seq}) says head {anchor_head_seq}, the chain says head {chain_head_seq}. Suspected append after anchoring" + "the chain of session {session} is shorter than the anchor; the anchor (seq {anchor_seq}) says head {anchor_head_seq}, the chain says head {chain_head_seq}. Suspected truncation" ) ) ) @@ -463,6 +501,40 @@ impl fmt::Display for AnchorFailure { ) } } + Self::EntriesAfterAnchor { + session, + anchor_seq, + anchor_head_seq, + chain_head_seq, + } => write!( + f, + "{}", + tr!( + format!( + "세션 {session}의 체인이 앵커보다 김. 앵커(seq {anchor_seq})는 head {anchor_head_seq}, 체인은 head {chain_head_seq}. 앵커 이후 덧붙이기 의심" + ), + format!( + "the chain of session {session} is longer than the anchor; the anchor (seq {anchor_seq}) says head {anchor_head_seq}, the chain says head {chain_head_seq}. Suspected append after anchoring" + ) + ) + ), + Self::AnchoredHeadLag { + session, + anchor_seq, + head_seq, + chain_seq, + } => write!( + f, + "{}", + tr!( + format!( + "세션 {session}은 앵커(seq {anchor_seq})가 있는데 head.json이 뒤처짐 (head {head_seq}, 체인 {chain_seq}). 앵커는 head.json이 fsync된 뒤에 쓰이므로 크래시 잔여일 수 없음. 앵커되지 않은 세션에 엔트리와 앵커를 덧붙인 것 의심" + ), + format!( + "session {session} has an anchor (seq {anchor_seq}) yet head.json lags (head {head_seq}, chain {chain_seq}); the anchor is written after head.json is fsynced, so this cannot be crash residue. Suspected entry and anchor appended to an unanchored session" + ) + ) + ), Self::Io(e) => write!(f, "{e}"), } } @@ -473,7 +545,6 @@ impl std::error::Error for AnchorFailure {} #[derive(Debug, Clone, PartialEq, Eq)] pub enum AnchorWarning { ClockWentBackwards { seq: u64, prev_ts: u64, ts: u64 }, - SessionReanchored { session: SessionId, count: u64 }, } impl fmt::Display for AnchorWarning { @@ -491,16 +562,6 @@ impl fmt::Display for AnchorWarning { ) ) ), - Self::SessionReanchored { session, count } => write!( - f, - "{}", - tr!( - format!("세션 {session}이 {count}번 앵커됨. 중간 체크포인트로 판단"), - format!( - "session {session} anchored {count} times; judged to be intermediate checkpoints" - ) - ) - ), } } } @@ -553,10 +614,11 @@ pub fn read_anchors(dir: &Path) -> Result, AnchorFailure> { Ok(out) } -/// 세션 하나의 앵커 기록을 찾습니다. 여러 번 앵커된 세션은 가장 나중 줄을 돌려줍니다. +/// 세션 하나의 앵커 기록을 찾습니다. 세션당 앵커 줄은 정확히 하나입니다. /// /// 조회 전에 앵커 체인 전체를 검증합니다. 깨진 체인에서 꺼낸 값으로 대조하면 없는 보증을 -/// 만들어 내기 때문입니다. +/// 만들어 내기 때문입니다. 같은 세션의 줄이 둘 이상이면 그 검증이 실패하므로 "가장 나중 +/// 줄" 을 고르는 경로는 없습니다. /// /// # Errors /// 앵커 체인 검증이 실패하면 그 사유를 그대로 돌려줍니다. @@ -581,8 +643,8 @@ pub fn lookup(dir: &Path, session: &SessionId) -> Result, An /// /// # Errors /// 앵커 체인이 깨졌거나, 앵커가 가리키는 head 와 실제 head 가 다르면 실패합니다. 체인이 -/// 앵커보다 긴 경우도 실패입니다. 앵커는 세션 종료 시점에 쓰므로 그 뒤에 자란 체인은 종료 -/// 후 덧붙이기를 뜻합니다. +/// 앵커보다 긴 경우는 [`AnchorFailure::EntriesAfterAnchor`] 입니다. 앵커는 세션 종료 +/// 시점에 쓰므로 그 뒤에 자란 체인은 종료 후 덧붙이기를 뜻합니다. pub fn check_session( dir: &Path, session: &SessionId, @@ -594,6 +656,12 @@ pub fn check_session( Some(a) if a.head_seq == head_seq && a.head_hash == *head_hash => { Ok(AnchorCheck::Matches { anchor_seq: a.seq }) } + Some(a) if head_seq > a.head_seq => Err(AnchorFailure::EntriesAfterAnchor { + session: *session, + anchor_seq: a.seq, + anchor_head_seq: a.head_seq, + chain_head_seq: head_seq, + }), Some(a) => Err(AnchorFailure::SessionHeadMismatch { session: *session, anchor_seq: a.seq, @@ -605,6 +673,41 @@ pub fn check_session( } } +/// 검증을 통과한 세션 체인을 앵커 기록과 대조합니다. +/// +/// [`check_session`] 에 더해 head.json 의 뒤처짐을 봅니다. 앵커는 엔트리와 head.json 이 +/// fsync 된 뒤에 쓰이므로, 앵커가 있는데 head.json 이 한 칸 뒤처진 상태는 크래시 잔여가 +/// 아니라 앵커되지 않은 세션에 엔트리 하나와 앵커 줄을 덧붙인 흔적입니다. 제네시스가 +/// `fsync_per_entry: false` 인 세션만 내구화 순서가 보장되지 않으므로 경고로 남깁니다. +/// +/// # Arguments +/// `dir` - 앵커 루트 +/// `report` - 이미 통과한 세션 체인 검증 결과 +/// +/// # Errors +/// [`check_session`] 의 실패에 더해, fsync 세션에서 앵커가 있는데 head.json 이 뒤처지면 +/// [`AnchorFailure::AnchoredHeadLag`] 입니다. +pub fn check_session_report( + dir: &Path, + report: &VerifyReport, +) -> Result { + let check = check_session(dir, &report.session, report.head_seq, &report.head_hash)?; + let AnchorCheck::Matches { anchor_seq } = check else { + return Ok(check); + }; + if report.fsync_per_entry + && let Some(head_seq) = report.head_lag() + { + return Err(AnchorFailure::AnchoredHeadLag { + session: report.session, + anchor_seq, + head_seq, + chain_seq: report.head_seq, + }); + } + Ok(check) +} + fn open_anchors(dir: &Path) -> Result, AnchorFailure> { let path = dir.join(ANCHOR_FILE); match File::open(&path) { @@ -626,9 +729,8 @@ fn scan( let mut entry_count: u64 = 0; let mut warnings: Vec = Vec::new(); - // 세션 -> (첫 등장 seq, 마지막 head_seq, 등장 횟수) - let mut seen: HashMap = HashMap::new(); - let mut order: Vec = Vec::new(); + // 세션 -> (첫 등장 seq, head_seq) + let mut seen: HashMap = HashMap::new(); let mut buf = String::new(); loop { @@ -692,29 +794,34 @@ fn scan( }); } - match seen.get_mut(&entry.session) { + // 세션당 앵커는 정확히 하나이며 두 번째 줄은 head_seq 에 따라 사유만 갈린다 + match seen.get(&entry.session) { None => { - seen.insert(entry.session, (entry.seq, entry.head_seq, 1)); - order.push(entry.session); + seen.insert(entry.session, (entry.seq, entry.head_seq)); } - Some((first_seq, last_head_seq, count)) => { - if entry.head_seq == *last_head_seq { + Some(&(first_seq, previous)) => { + if entry.head_seq == previous { return Err(AnchorFailure::SessionDuplicated { seq: entry.seq, session: entry.session, - first_seq: *first_seq, + first_seq, }); } - if entry.head_seq < *last_head_seq { + if entry.head_seq < previous { return Err(AnchorFailure::HeadSeqRegressed { seq: entry.seq, session: entry.session, - previous: *last_head_seq, + previous, got: entry.head_seq, }); } - *last_head_seq = entry.head_seq; - *count = count.saturating_add(1); + return Err(AnchorFailure::SessionReanchored { + seq: entry.seq, + session: entry.session, + first_seq, + previous, + got: entry.head_seq, + }); } } @@ -739,18 +846,6 @@ fn scan( return Err(AnchorFailure::ChainEmpty); } - // HashMap 순회 순서는 실행마다 달라집니다. 보고가 재현 가능해야 하므로 등장 순으로 냅니다 - for session in &order { - if let Some((_, _, count)) = seen.get(session) - && *count > 1 - { - warnings.push(AnchorWarning::SessionReanchored { - session: *session, - count: *count, - }); - } - } - Ok(AnchorReport { entries: entry_count, sessions: seen.len() as u64, diff --git a/crates/airlock-audit/src/lib.rs b/crates/airlock-audit/src/lib.rs index e9e071c..c4c1944 100644 --- a/crates/airlock-audit/src/lib.rs +++ b/crates/airlock-audit/src/lib.rs @@ -11,7 +11,7 @@ mod verify; pub use anchor::{ ANCHOR_DOMAIN, ANCHOR_FILE, ANCHOR_VERSION, AnchorCheck, AnchorEntry, AnchorFailure, AnchorLog, - AnchorReport, AnchorWarning, check_session, read_anchors, verify_anchors, + AnchorReport, AnchorWarning, check_session, check_session_report, read_anchors, verify_anchors, }; pub use entry::{DOMAIN, Entry, Record, compute_hash}; pub use error::{Error, Result}; diff --git a/crates/airlock-audit/src/verify.rs b/crates/airlock-audit/src/verify.rs index bf8f9d5..48b1634 100644 --- a/crates/airlock-audit/src/verify.rs +++ b/crates/airlock-audit/src/verify.rs @@ -59,6 +59,10 @@ pub enum Failure { seq: u64, for_seq: u64, }, + EntryAfterSessionEnd { + end_seq: u64, + seq: u64, + }, HeadMismatch { head: Box, chain_seq: u64, @@ -191,6 +195,18 @@ impl fmt::Display for Failure { format!("seq {seq} approval targets seq {for_seq}, which is not an ask") ) ), + Self::EntryAfterSessionEnd { end_seq, seq } => write!( + f, + "{}", + tr!( + format!( + "seq {seq}가 session_end(seq {end_seq}) 뒤에 있음. 체인은 session_end로 끝나야 하므로 종료 뒤 덧붙이기 의심" + ), + format!( + "seq {seq} follows session_end (seq {end_seq}); the chain must end with session_end, so append after close is suspected" + ) + ) + ), Self::HeadMismatch { head, chain_seq, @@ -337,6 +353,9 @@ pub struct VerifyReport { pub session: SessionId, pub head_seq: u64, pub head_hash: Hash, + /// 제네시스가 기록한 값. 앵커 대조가 head.json 뒤처짐을 실패로 올릴지 정할 때 봅니다 + pub fsync_per_entry: bool, + pub session_ended: bool, pub warnings: Vec, } @@ -344,6 +363,13 @@ impl VerifyReport { pub fn is_clean(&self) -> bool { self.warnings.is_empty() } + + pub fn head_lag(&self) -> Option { + self.warnings.iter().find_map(|w| match w { + Warning::HeadLagsByOne { head_seq, .. } => Some(*head_seq), + _ => None, + }) + } } pub fn verify_dir(dir: impl AsRef) -> Result { @@ -385,6 +411,8 @@ pub fn verify_stream( let mut last_seq: u64 = 0; let mut observe_count: u64 = 0; let mut entry_count: u64 = 0; + let mut fsync_per_entry = true; + let mut ended_at: Option = None; let mut open_asks: HashSet = HashSet::new(); let mut answered: HashSet = HashSet::new(); @@ -471,6 +499,21 @@ pub fn verify_stream( }); } + if let Some(end_seq) = ended_at { + return Err(Failure::EntryAfterSessionEnd { + end_seq, + seq: entry.seq, + }); + } + match &entry.event { + Event::SessionStart { + fsync_per_entry: per_entry, + .. + } => fsync_per_entry = *per_entry, + Event::SessionEnd { .. } => ended_at = Some(entry.seq), + _ => {} + } + if entry.seq > 0 && entry.ts < last_ts { warnings.push(Warning::ClockWentBackwards { seq: entry.seq, @@ -571,6 +614,8 @@ pub fn verify_stream( session, head_seq: last_seq, head_hash: prev_hash, + fsync_per_entry, + session_ended: ended_at.is_some(), warnings, }) } diff --git a/crates/airlock-audit/tests/anchor.rs b/crates/airlock-audit/tests/anchor.rs index 6cf4a45..384e54e 100644 --- a/crates/airlock-audit/tests/anchor.rs +++ b/crates/airlock-audit/tests/anchor.rs @@ -2,9 +2,10 @@ use std::fs; use std::path::{Path, PathBuf}; use airlock_audit::{ - ANCHOR_FILE, AnchorCheck, AnchorEntry, AnchorFailure, AnchorLog, AnchorWarning, AuditLog, - CHAIN_FILE, Decision, Enforcement, Entry, Event, FileMode, GenesisInfo, HEAD_FILE, Hash, Head, - Mediation, Record, SessionId, now_unix_nanos, verify_anchors, verify_dir, + ANCHOR_FILE, AnchorCheck, AnchorEntry, AnchorFailure, AnchorLog, AuditLog, CHAIN_FILE, + Decision, Enforcement, Entry, Event, ExitStatus, FileMode, GenesisInfo, HEAD_FILE, Hash, Head, + Mediation, Record, SessionId, Warning, check_session_report, now_unix_nanos, verify_anchors, + verify_dir, }; struct Scratch(PathBuf); @@ -78,6 +79,85 @@ fn build_session(dir: &Path, session: SessionId) -> (u64, Hash) { (log.head_seq().unwrap(), log.head_hash()) } +/// 브로커처럼 `session_end` 로 끝나는 세션 체인을 만들고 최종 head 를 돌려줍니다. +fn build_closed_session(dir: &Path, session: SessionId, fsync: bool) -> (u64, Hash) { + let mut log = AuditLog::create(dir, session, Enforcement::Landlock, fsync, genesis()).unwrap(); + log.append(Record::new( + "pid:100 claude", + file_read("/Users/me/work/src/main.rs"), + Decision::Allow, + )) + .unwrap(); + log.append(Record::new( + "airlock", + Event::SessionEnd { + status: ExitStatus::Exited { code: 0 }, + }, + Decision::Allow, + )) + .unwrap(); + (log.head_seq().unwrap(), log.head_hash()) +} + +fn read_entries(dir: &Path) -> Vec { + fs::read_to_string(dir.join(CHAIN_FILE)) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap()) + .collect() +} + +fn write_head(dir: &Path, last: &Entry) { + fs::write( + dir.join(HEAD_FILE), + serde_json::to_string_pretty(&Head { + version: 1, + seq: last.seq, + hash: last.hash, + session: last.session, + }) + .unwrap(), + ) + .unwrap(); +} + +/// append 권한만 가진 공격자가 하는 일. 체인 끝에 자체 정합적인 엔트리 `count` 개를 +/// 잇고, `fix_head` 면 head.json 도 새 끝으로 맞춥니다. +fn forge_tail(dir: &Path, count: u64, fix_head: bool) -> (u64, Hash) { + let entries = read_entries(dir); + let mut last = entries.last().unwrap().clone(); + let mut body = String::new(); + for _ in 0..count { + let forged = Entry::seal( + last.seq + 1, + last.ts + 1, + last.session, + last.enforcement, + last.hash, + Record::new( + "pid:100 claude", + file_read("/Users/me/.ssh/id_ed25519"), + Decision::Allow, + ), + ); + assert!(forged.hash_is_valid()); + body.push_str(&serde_json::to_string(&forged).unwrap()); + body.push('\n'); + last = forged; + } + use std::io::Write; + let mut f = fs::OpenOptions::new() + .append(true) + .open(dir.join(CHAIN_FILE)) + .unwrap(); + f.write_all(body.as_bytes()).unwrap(); + if fix_head { + write_head(dir, &last); + } + (last.seq, last.hash) +} + fn read_anchor_lines(root: &Path) -> Vec { fs::read_to_string(root.join(ANCHOR_FILE)) .unwrap() @@ -299,33 +379,48 @@ fn regressed_head_seq_is_detected() { } #[test] -fn checkpoint_reanchoring_is_allowed_but_reported() { - let s = Scratch::new("checkpoint"); +fn reanchoring_is_a_failure() { + let s = Scratch::new("reanchor"); let mut anchors = AnchorLog::open(s.root()).unwrap(); let session = SessionId::from_bytes([1; 16]); anchors .append(session, 1, Hash::from_bytes([0x01; 32])) .unwrap(); + // 더 긴 head 로 다시 앵커한다. 브로커에는 체크포인트가 없으므로 이것은 종료 뒤 + // 덧붙이기의 흔적이지 정상 경로가 아니다 anchors .append(session, 7, Hash::from_bytes([0x07; 32])) .unwrap(); - let report = verify_anchors(s.root()).unwrap(); - assert_eq!(report.sessions, 1); + let err = verify_anchors(s.root()).unwrap_err(); assert!( - report - .warnings - .iter() - .any(|w| matches!(w, AnchorWarning::SessionReanchored { count: 2, .. })), - "{:?}", - report.warnings + matches!( + err, + AnchorFailure::SessionReanchored { + seq: 1, + first_seq: 0, + previous: 1, + got: 7, + .. + } + ), + "같은 세션의 두 번째 앵커 줄이 경고로 통과하면 8.4 의 약속이 무효가 됨: {err}" ); + assert!(err.to_string().contains("덧붙이기 의심"), "{err}"); - // 조회는 가장 나중 앵커를 돌려주어야 한다 - let found = airlock_audit::anchor::lookup(s.root(), &session) - .unwrap() - .unwrap(); - assert_eq!(found.head_seq, 7); + // 조회도 "가장 나중 줄" 을 돌려주지 않고 실패해야 한다 + let err = airlock_audit::anchor::lookup(s.root(), &session).unwrap_err(); + assert!( + matches!(err, AnchorFailure::SessionReanchored { .. }), + "{err}" + ); + + // 깨진 체인 위에 이어 붙이기도 거부된다 + let err = AnchorLog::open(s.root()).unwrap_err(); + assert!( + matches!(err, airlock_audit::Error::AnchorChainBroken { .. }), + "{err}" + ); } // ---------- 앵커의 존재 이유: 세션 체인 재계산 탐지 ---------- @@ -448,12 +543,198 @@ fn entries_appended_after_the_anchor_are_caught() { ) .unwrap_err(); assert!( - matches!(err, AnchorFailure::SessionHeadMismatch { .. }), + matches!( + err, + AnchorFailure::EntriesAfterAnchor { + anchor_head_seq: 1, + chain_head_seq: 2, + .. + } + ), "{err}" ); assert!(err.to_string().contains("덧붙이기 의심"), "{err}"); } +// ---------- H2: append 권한만으로 종료된 세션을 늘리는 공격 ---------- + +#[test] +fn appending_after_close_and_reanchoring_is_caught() { + // 공격자는 chain.jsonl 과 anchors.jsonl 에 append 만 할 수 있다. 종료된 세션에 + // 위조 엔트리를 잇고 새 head 를 가리키는 앵커 한 줄을 더한다. 세션 체인만 보면 + // 자체 정합적이고 head.json 도 맞다 + let s = Scratch::new("h2-reanchor"); + let session = SessionId::from_bytes([0xAB; 16]); + let dir = s.session_dir("s0"); + let (head_seq, head_hash) = build_session(&dir, session); + AnchorLog::open(s.root()) + .unwrap() + .append(session, head_seq, head_hash) + .unwrap(); + + let (forged_seq, forged_hash) = forge_tail(&dir, 2, true); + AnchorLog::open(s.root()) + .unwrap() + .append(session, forged_seq, forged_hash) + .unwrap(); + + let report = verify_dir(&dir).unwrap(); + assert!(report.is_clean(), "{:?}", report.warnings); + assert_eq!(report.head_seq, forged_seq); + + let err = verify_anchors(s.root()).unwrap_err(); + assert!( + matches!(err, AnchorFailure::SessionReanchored { seq: 1, .. }), + "두 번째 앵커 줄이 검증을 통과하면 종료 뒤 덧붙이기가 자유로움: {err}" + ); + let err = check_session_report(s.root(), &report).unwrap_err(); + assert!( + matches!(err, AnchorFailure::SessionReanchored { .. }), + "{err}" + ); +} + +#[test] +fn appending_after_session_end_is_caught_by_both_layers() { + let s = Scratch::new("h2-session-end"); + let session = SessionId::from_bytes([0xAB; 16]); + let dir = s.session_dir("s0"); + let (head_seq, head_hash) = build_closed_session(&dir, session, true); + AnchorLog::open(s.root()) + .unwrap() + .append(session, head_seq, head_hash) + .unwrap(); + + let (forged_seq, forged_hash) = forge_tail(&dir, 1, true); + + // 세션 체인 층. session_end 뒤의 엔트리는 앵커 유무와 무관하게 실패다 + let err = verify_dir(&dir).unwrap_err(); + assert!( + matches!( + err, + airlock_audit::Failure::EntryAfterSessionEnd { end_seq: 2, seq: 3 } + ), + "{err}" + ); + + // 앵커 층. 새 앵커 없이도 체인이 앵커보다 길다 + let err = airlock_audit::anchor::check_session(s.root(), &session, forged_seq, &forged_hash) + .unwrap_err(); + assert!( + matches!(err, AnchorFailure::EntriesAfterAnchor { .. }), + "{err}" + ); + + // 새 앵커를 더해도 그 줄 자체가 실패다 + AnchorLog::open(s.root()) + .unwrap() + .append(session, forged_seq, forged_hash) + .unwrap(); + let err = verify_anchors(s.root()).unwrap_err(); + assert!( + matches!(err, AnchorFailure::SessionReanchored { .. }), + "{err}" + ); +} + +#[test] +fn an_anchored_session_with_a_lagging_head_is_a_failure() { + // SIGKILL 로 죽어 앵커가 없는 세션. 공격자가 엔트리 하나를 잇고(head.json 은 건드리지 + // 못함) 첫 앵커를 대신 쓴다. 세션 체인만 보면 크래시 잔여 경고이고 앵커는 일치한다 + let s = Scratch::new("anchored-lag"); + let session = SessionId::from_bytes([0xAB; 16]); + let dir = s.session_dir("s0"); + build_session(&dir, session); + + let (forged_seq, forged_hash) = forge_tail(&dir, 1, false); + AnchorLog::open(s.root()) + .unwrap() + .append(session, forged_seq, forged_hash) + .unwrap(); + + let report = verify_dir(&dir).unwrap(); + assert!(report.fsync_per_entry); + assert!( + report + .warnings + .iter() + .any(|w| matches!(w, Warning::HeadLagsByOne { .. })), + "{:?}", + report.warnings + ); + // 원시 대조는 head 만 보므로 일치한다. 그래서 CLI 는 이것을 쓰면 안 된다 + let raw = airlock_audit::anchor::check_session( + s.root(), + &session, + report.head_seq, + &report.head_hash, + ) + .unwrap(); + assert!(matches!(raw, AnchorCheck::Matches { .. })); + + let err = check_session_report(s.root(), &report).unwrap_err(); + assert!( + matches!( + err, + AnchorFailure::AnchoredHeadLag { + anchor_seq: 0, + head_seq: 2, + chain_seq: 3, + .. + } + ), + "앵커는 head.json 이 fsync 된 뒤에 쓰이므로 이 상태는 정상 경로로 생길 수 없음: {err}" + ); + assert!(err.to_string().contains("크래시 잔여일 수 없음"), "{err}"); +} + +#[test] +fn a_no_fsync_session_keeps_the_head_lag_as_a_warning() { + // fsync_per_entry: false 면 엔트리와 head.json 의 내구화 순서가 보장되지 않으므로 + // 앵커가 먼저 디스크에 닿은 뒤 크래시한 것과 구분할 수 없다 + let s = Scratch::new("nofsync-lag"); + let session = SessionId::from_bytes([0xAB; 16]); + let dir = s.session_dir("s0"); + let mut log = AuditLog::create(&dir, session, Enforcement::Landlock, false, genesis()).unwrap(); + log.append(Record::new( + "pid:100 claude", + file_read("/Users/me/work/src/main.rs"), + Decision::Allow, + )) + .unwrap(); + drop(log); + + let (forged_seq, forged_hash) = forge_tail(&dir, 1, false); + AnchorLog::open(s.root()) + .unwrap() + .append(session, forged_seq, forged_hash) + .unwrap(); + + let report = verify_dir(&dir).unwrap(); + assert!(!report.fsync_per_entry); + assert!(report.head_lag().is_some(), "{:?}", report.warnings); + let check = check_session_report(s.root(), &report).unwrap(); + assert!(matches!(check, AnchorCheck::Matches { .. }), "{check:?}"); +} + +#[test] +fn a_genuinely_closed_and_anchored_session_passes() { + let s = Scratch::new("closed-ok"); + let session = SessionId::from_bytes([0xAB; 16]); + let dir = s.session_dir("s0"); + let (head_seq, head_hash) = build_closed_session(&dir, session, true); + AnchorLog::open(s.root()) + .unwrap() + .append(session, head_seq, head_hash) + .unwrap(); + + let report = verify_dir(&dir).unwrap(); + assert!(report.is_clean(), "{:?}", report.warnings); + assert!(report.session_ended); + let check = check_session_report(s.root(), &report).unwrap(); + assert_eq!(check, AnchorCheck::Matches { anchor_seq: 0 }); +} + #[test] fn deleted_session_directory_leaves_a_trace() { let s = Scratch::new("session-deleted"); diff --git a/crates/airlock-audit/tests/tamper.rs b/crates/airlock-audit/tests/tamper.rs index 5d27482..d0ba627 100644 --- a/crates/airlock-audit/tests/tamper.rs +++ b/crates/airlock-audit/tests/tamper.rs @@ -4,8 +4,9 @@ use std::path::{Path, PathBuf}; use airlock_canonical::Encoder; use airlock_audit::{ - AuditLog, CHAIN_FILE, Decision, Enforcement, Entry, Event, FileMode, GenesisInfo, Granted, - HEAD_FILE, Hash, Head, Mediation, Record, SessionId, Warning, now_unix_nanos, verify_dir, + AuditLog, CHAIN_FILE, Decision, Enforcement, Entry, Event, ExitStatus, FileMode, GenesisInfo, + Granted, HEAD_FILE, Hash, Head, Mediation, Record, SessionId, Warning, now_unix_nanos, + verify_dir, }; struct Scratch(PathBuf); @@ -628,6 +629,8 @@ fn self_referencing_approval_is_fatal() { #[test] fn anchor_lagging_by_one_is_treated_as_crash_residue() { + // 세션 체인 층만의 판정이다. 앵커가 있는 세션에서는 앵커 대조가 이것을 실패로 올린다 + // (tests/anchor.rs 의 an_anchored_session_with_a_lagging_head_is_a_failure) let s = Scratch::new("head-lag"); build_chain(s.path(), Enforcement::Landlock); @@ -646,6 +649,103 @@ fn anchor_lagging_by_one_is_treated_as_crash_residue() { "{:?}", report.warnings ); + assert_eq!(report.head_lag(), Some(3)); + assert!(report.fsync_per_entry); +} + +// ---------- session_end 이후 ---------- + +fn session_end() -> Event { + Event::SessionEnd { + status: ExitStatus::Exited { code: 0 }, + } +} + +#[test] +fn a_chain_ending_with_session_end_is_clean_and_marked_ended() { + let s = Scratch::new("ended"); + let mut log = AuditLog::create( + s.path(), + SessionId::from_bytes([0xAB; 16]), + Enforcement::Landlock, + true, + genesis(), + ) + .unwrap(); + log.append(Record::new("airlock", session_end(), Decision::Allow)) + .unwrap(); + + let report = verify_dir(s.path()).unwrap(); + assert!(report.is_clean(), "{:?}", report.warnings); + assert!(report.session_ended); + + let s2 = Scratch::new("not-ended"); + build_chain(s2.path(), Enforcement::Landlock); + let report = verify_dir(s2.path()).unwrap(); + assert!( + !report.session_ended, + "session_end 가 없는 체인이 끝난 것으로 보이면 안 됨" + ); +} + +#[test] +fn entries_after_session_end_are_fatal() { + // 브로커는 session_end 뒤의 append 를 거부하므로 이 상태는 종료 뒤 덧붙이기로만 생긴다. + // 앵커가 있든 없든 세션 체인 층에서 실패해야 한다 + let s = Scratch::new("after-end"); + let mut log = AuditLog::create( + s.path(), + SessionId::from_bytes([0xAB; 16]), + Enforcement::Landlock, + true, + genesis(), + ) + .unwrap(); + log.append(Record::new("airlock", session_end(), Decision::Allow)) + .unwrap(); + // AuditLog 자체는 닫힘을 모르므로 append 가 성공한다. 그것이 공격자가 하는 일이다 + log.append(Record::new( + "pid:100 claude", + file_read("/Users/me/.ssh/id_ed25519"), + Decision::Allow, + )) + .unwrap(); + + let err = verify_dir(s.path()).unwrap_err(); + assert!( + matches!( + err, + airlock_audit::Failure::EntryAfterSessionEnd { end_seq: 1, seq: 2 } + ), + "session_end 뒤의 엔트리가 통과하면 종료된 세션에 무엇이든 덧붙일 수 있음: {err}" + ); + assert!(err.to_string().contains("덧붙이기 의심"), "{err}"); +} + +#[test] +fn a_second_session_end_is_also_fatal() { + let s = Scratch::new("double-end"); + let mut log = AuditLog::create( + s.path(), + SessionId::from_bytes([0xAB; 16]), + Enforcement::Landlock, + true, + genesis(), + ) + .unwrap(); + log.append(Record::new("airlock", session_end(), Decision::Allow)) + .unwrap(); + log.append(Record::new("airlock", session_end(), Decision::Allow)) + .unwrap(); + + let err = verify_dir(s.path()).unwrap_err(); + assert!( + matches!( + err, + airlock_audit::Failure::EntryAfterSessionEnd { end_seq: 1, seq: 2 } + ), + "{err}" + ); } #[test] diff --git a/crates/airlock-broker/src/bpf.rs b/crates/airlock-broker/src/bpf.rs new file mode 100644 index 0000000..a22ed05 --- /dev/null +++ b/crates/airlock-broker/src/bpf.rs @@ -0,0 +1,310 @@ +//! seccomp BPF 프로그램 조립. 커널 호출이 없는 순수 로직이라 모든 플랫폼에서 컴파일되고 +//! 테스트되며, Linux 의 `notify` 모듈만 실제로 씁니다 +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SockFilter { + pub(crate) code: u16, + pub(crate) jt: u8, + pub(crate) jf: u8, + pub(crate) k: u32, +} + +pub(crate) const LD_W_ABS: u16 = 0x20; +pub(crate) const JMP_JEQ_K: u16 = 0x15; +pub(crate) const JMP_JGE_K: u16 = 0x35; +pub(crate) const RET_K: u16 = 0x06; + +pub(crate) const RET_KILL_PROCESS: u32 = 0x8000_0000; +pub(crate) const RET_ERRNO: u32 = 0x0005_0000; +pub(crate) const RET_USER_NOTIF: u32 = 0x7fc0_0000; +pub(crate) const RET_ALLOW: u32 = 0x7fff_0000; + +pub(crate) const OFF_NR: u32 = 0; +pub(crate) const OFF_ARCH: u32 = 4; + +pub(crate) const fn off_arg_lo(index: u32) -> u32 { + let base = 16 + index * 8; + if cfg!(target_endian = "big") { + base + 4 + } else { + base + } +} + +pub(crate) const fn stmt(code: u16, k: u32) -> SockFilter { + SockFilter { + code, + jt: 0, + jf: 0, + k, + } +} + +pub(crate) const fn jump(code: u16, k: u32, jt: u8, jf: u8) -> SockFilter { + SockFilter { code, jt, jf, k } +} + +fn offset(n: usize) -> u8 { + u8::try_from(n).unwrap_or(u8::MAX) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Layout { + pub(crate) native_arch: u32, + pub(crate) x32_bit: Option, + pub(crate) ioctl_nr: u32, + pub(crate) refused_ioctls: Vec, + pub(crate) refused_errno: u32, + pub(crate) mediated: Vec, +} + +pub(crate) fn assemble(layout: &Layout) -> Vec { + let guard = layout.refused_ioctls.len(); + let mediated = layout.mediated.len(); + let mut prog = Vec::with_capacity(guard.saturating_add(mediated).saturating_add(12)); + + prog.push(stmt(LD_W_ABS, OFF_ARCH)); + prog.push(jump(JMP_JEQ_K, layout.native_arch, 1, 0)); + prog.push(stmt(RET_K, RET_KILL_PROCESS)); + + prog.push(stmt(LD_W_ABS, OFF_NR)); + if let Some(bit) = layout.x32_bit { + prog.push(jump(JMP_JGE_K, bit, 0, 1)); + prog.push(stmt(RET_K, RET_KILL_PROCESS)); + } + + if guard > 0 { + // ioctl 이 아니면 인자 적재, 비교 guard 개, 거부, 번호 재적재를 모두 건너뜁니다 + prog.push(jump( + JMP_JEQ_K, + layout.ioctl_nr, + 0, + offset(guard.saturating_add(3)), + )); + prog.push(stmt(LD_W_ABS, off_arg_lo(1))); + for (i, request) in layout.refused_ioctls.iter().enumerate() { + let to_refuse = guard.saturating_sub(i).saturating_sub(1); + let last = i.saturating_add(1) == guard; + prog.push(jump(JMP_JEQ_K, *request, offset(to_refuse), u8::from(last))); + } + prog.push(stmt(RET_K, RET_ERRNO | (layout.refused_errno & 0xffff))); + // 누산기에 ioctl 인자가 남아 있으므로 번호를 다시 적재해야 아래 비교가 맞습니다 + prog.push(stmt(LD_W_ABS, OFF_NR)); + } + + for (i, nr) in layout.mediated.iter().enumerate() { + let to_notify = mediated.saturating_sub(i); + prog.push(jump(JMP_JEQ_K, *nr, offset(to_notify), 0)); + } + prog.push(stmt(RET_K, RET_ALLOW)); + if mediated > 0 { + prog.push(stmt(RET_K, RET_USER_NOTIF)); + } + prog +} + +#[cfg(test)] +mod tests { + use super::*; + + const ARCH: u32 = 0xc000_003e; + const OTHER_ARCH: u32 = 0xc000_00b7; + const X32: u32 = 0x4000_0000; + const IOCTL: u32 = 16; + const EXECVE: u32 = 59; + const CONNECT: u32 = 42; + const WRITE: u32 = 1; + const TIOCSTI: u32 = 0x5412; + const TIOCLINUX: u32 = 0x541C; + const EPERM: u32 = 1; + + fn layout(mediated: Vec) -> Layout { + Layout { + native_arch: ARCH, + x32_bit: Some(X32), + ioctl_nr: IOCTL, + refused_ioctls: vec![TIOCSTI, TIOCLINUX], + refused_errno: EPERM, + mediated, + } + } + + fn data(nr: u32, arch: u32, args: [u64; 6]) -> [u8; 64] { + let mut out = [0u8; 64]; + out[0..4].copy_from_slice(&nr.to_ne_bytes()); + out[4..8].copy_from_slice(&arch.to_ne_bytes()); + for (i, a) in args.iter().enumerate() { + let at = 16 + i * 8; + out[at..at + 8].copy_from_slice(&a.to_ne_bytes()); + } + out + } + + fn run(prog: &[SockFilter], data: &[u8; 64]) -> u32 { + let mut pc = 0usize; + let mut acc = 0u32; + for _ in 0..prog.len() + 1 { + let insn = prog[pc]; + match insn.code { + LD_W_ABS => { + let k = insn.k as usize; + let mut word = [0u8; 4]; + word.copy_from_slice(&data[k..k + 4]); + acc = u32::from_ne_bytes(word); + pc += 1; + } + JMP_JEQ_K => { + pc += 1 + usize::from(if acc == insn.k { insn.jt } else { insn.jf }); + } + JMP_JGE_K => { + pc += 1 + usize::from(if acc >= insn.k { insn.jt } else { insn.jf }); + } + RET_K => return insn.k, + other => panic!("모르는 opcode {other:#x}"), + } + } + panic!("프로그램이 끝나지 않음"); + } + + fn ioctl(request: u64) -> [u8; 64] { + data(IOCTL, ARCH, [3, request, 0, 0, 0, 0]) + } + + #[test] + fn refused_ioctls_get_eperm_at_every_level() { + for mediated in [vec![], vec![CONNECT, EXECVE]] { + let prog = assemble(&layout(mediated)); + assert_eq!(run(&prog, &ioctl(u64::from(TIOCSTI))), RET_ERRNO | EPERM); + assert_eq!(run(&prog, &ioctl(u64::from(TIOCLINUX))), RET_ERRNO | EPERM); + } + } + + #[test] + fn only_the_low_32_bits_of_the_request_are_compared() { + // 커널은 request 를 unsigned int 로 받으므로 상위 비트는 무시됩니다 + let prog = assemble(&layout(vec![EXECVE])); + let high = 0x0000_0001_0000_0000u64 | u64::from(TIOCSTI); + assert_eq!(run(&prog, &ioctl(high)), RET_ERRNO | EPERM); + let high = 0xffff_ffff_0000_0000u64 | u64::from(TIOCLINUX); + assert_eq!(run(&prog, &ioctl(high)), RET_ERRNO | EPERM); + } + + #[test] + fn other_ioctls_pass_and_the_accumulator_is_reloaded() { + let prog = assemble(&layout(vec![0x5555, EXECVE])); + assert_eq!(run(&prog, &ioctl(0x5401)), RET_ALLOW); + // 재적재가 없으면 인자 0x5555 가 중계 번호 0x5555 와 맞아 USER_NOTIF 가 됩니다 + assert_eq!(run(&prog, &ioctl(0x5555)), RET_ALLOW); + assert_eq!(run(&prog, &ioctl(0)), RET_ALLOW); + } + + #[test] + fn mediated_syscalls_notify_and_others_pass() { + let prog = assemble(&layout(vec![CONNECT, EXECVE, 322])); + for nr in [CONNECT, EXECVE, 322] { + assert_eq!(run(&prog, &data(nr, ARCH, [0; 6])), RET_USER_NOTIF); + } + for nr in [WRITE, 0, 257, 1000] { + assert_eq!(run(&prog, &data(nr, ARCH, [0; 6])), RET_ALLOW); + } + } + + #[test] + fn a_long_mediated_list_still_jumps_correctly() { + let nrs: Vec = (100..113).collect(); + let prog = assemble(&layout(nrs.clone())); + for nr in nrs { + assert_eq!(run(&prog, &data(nr, ARCH, [0; 6])), RET_USER_NOTIF); + } + assert_eq!(run(&prog, &data(113, ARCH, [0; 6])), RET_ALLOW); + assert_eq!(run(&prog, &data(99, ARCH, [0; 6])), RET_ALLOW); + assert_eq!(run(&prog, &ioctl(u64::from(TIOCSTI))), RET_ERRNO | EPERM); + } + + #[test] + fn a_foreign_architecture_is_killed_before_anything_else() { + let prog = assemble(&layout(vec![EXECVE])); + assert_eq!( + run(&prog, &data(WRITE, OTHER_ARCH, [0; 6])), + RET_KILL_PROCESS + ); + assert_eq!( + run( + &prog, + &data(IOCTL, OTHER_ARCH, [3, u64::from(TIOCSTI), 0, 0, 0, 0]) + ), + RET_KILL_PROCESS + ); + } + + #[test] + fn x32_numbers_are_killed_only_when_the_bit_is_known() { + let prog = assemble(&layout(vec![EXECVE])); + assert_eq!( + run(&prog, &data(X32 | IOCTL, ARCH, [0; 6])), + RET_KILL_PROCESS + ); + assert_eq!( + run(&prog, &data(X32 | EXECVE, ARCH, [0; 6])), + RET_KILL_PROCESS + ); + + let mut no_x32 = layout(vec![EXECVE]); + no_x32.x32_bit = None; + let prog = assemble(&no_x32); + assert_eq!(run(&prog, &data(X32 | WRITE, ARCH, [0; 6])), RET_ALLOW); + } + + #[test] + fn every_jump_lands_inside_the_program_and_it_ends_in_ret() { + for mediated in [vec![], vec![EXECVE], (1..14).collect()] { + let prog = assemble(&layout(mediated)); + for (i, insn) in prog.iter().enumerate() { + if insn.code == JMP_JEQ_K || insn.code == JMP_JGE_K { + assert!( + i + 1 + usize::from(insn.jt) < prog.len(), + "jt 가 밖으로 나감" + ); + assert!( + i + 1 + usize::from(insn.jf) < prog.len(), + "jf 가 밖으로 나감" + ); + } + } + assert_eq!(prog.last().map(|i| i.code), Some(RET_K)); + } + } + + #[test] + fn an_empty_mediated_list_has_no_user_notif() { + let prog = assemble(&layout(vec![])); + assert!( + !prog + .iter() + .any(|i| i.code == RET_K && i.k == RET_USER_NOTIF) + ); + assert_eq!(prog.last().map(|i| i.k), Some(RET_ALLOW)); + } + + #[test] + fn an_empty_refused_list_has_no_guard() { + let mut no_guard = layout(vec![EXECVE]); + no_guard.refused_ioctls.clear(); + let prog = assemble(&no_guard); + assert!( + !prog + .iter() + .any(|i| i.code == RET_K && i.k & 0xffff_0000 == RET_ERRNO) + ); + assert_eq!(run(&prog, &ioctl(u64::from(TIOCSTI))), RET_ALLOW); + } + + #[test] + fn the_argument_offset_follows_the_kernel_layout() { + assert_eq!(off_arg_lo(0) & !4, 16); + assert_eq!(off_arg_lo(1) & !4, 24); + assert_eq!(off_arg_lo(5) & !4, 56); + } +} diff --git a/crates/airlock-broker/src/landlock.rs b/crates/airlock-broker/src/landlock.rs index ddca246..5d8433e 100644 --- a/crates/airlock-broker/src/landlock.rs +++ b/crates/airlock-broker/src/landlock.rs @@ -36,6 +36,7 @@ use landlock::{ use crate::enforcer::Enforcer; use crate::error::{BrokerError, Result}; +use crate::notify::TtyGuard; use crate::profile::{self, ProfileOptions}; /// 루트 하나를 걸어 내려가며 검사할 최대 디렉토리 항목 수. @@ -1085,6 +1086,12 @@ pub struct LandlockEnforcer { plan: Option, abi: ABI, gaps: Vec, + /// 중계 수준과 무관하게 거는 tty ioctl 거부 필터. + /// + /// Landlock 은 상속된 fd 에 아무것도 하지 못하므로 자식이 물려받은 제어 터미널에 + /// `TIOCSTI` 를 부르는 것을 막을 수 없습니다. 그 경로는 승인 프롬프트 위조이고, + /// `--mediate off` 에서도 열려 있으면 안 되므로 강제 층이 seccomp 로 함께 겁니다 + tty_guard: Option, } impl Default for LandlockEnforcer { @@ -1100,6 +1107,7 @@ impl LandlockEnforcer { plan: None, abi: detect_abi(), gaps: Vec::new(), + tty_guard: TtyGuard::new(), } } @@ -1348,15 +1356,20 @@ impl Enforcer for LandlockEnforcer { } } let abi = self.abi; + let tty_guard = self.tty_guard.clone(); use std::os::unix::process::CommandExt; // # Safety // pre_exec은 fork 이후 exec 이전의 자식에서 실행됩니다. 브로커는 spawn 시점에 // 단일 스레드이므로 malloc 락 경합이 없습니다. 계획은 fork 전에 확정한 경로 목록이며 // 자식에서는 그 경로를 열어 규칙으로 거는 일만 합니다. restrict_self는 호출한 - // 스레드에만 걸리는데, exec 직전의 자식은 스레드가 하나뿐이라 전체에 걸립니다 + // 스레드에만 걸리는데, exec 직전의 자식은 스레드가 하나뿐이라 전체에 걸립니다. + // tty 필터도 fork 전에 조립해 두었고 같은 이유로 자식 전체에 걸립니다 unsafe { cmd.pre_exec(move || { + if let Some(guard) = &tty_guard { + guard.install()?; + } let status = apply(&plan, abi)?; if status == RulesetStatus::NotEnforced { return Err(std::io::Error::other(tr!( @@ -1411,6 +1424,18 @@ impl Enforcer for LandlockEnforcer { ) .to_string(), ); + if self.tty_guard.is_none() { + gaps.push( + tr!( + "이 아키텍처의 seccomp arch 값을 몰라 TIOCSTI/TIOCLINUX 거부 필터를 걸지 \ + 못함. 자식이 상속된 터미널에 입력을 밀어 넣어 승인 프롬프트를 위조할 수 있음", + "the seccomp arch value for this architecture is unknown, so the \ + TIOCSTI/TIOCLINUX refusal filter is not installed; a child can push \ + input into the inherited terminal and forge an approval prompt" + ) + .to_string(), + ); + } gaps.extend(self.gaps.iter().cloned()); gaps } @@ -1596,6 +1621,19 @@ mod tests { ); } + #[test] + fn the_tty_guard_rides_along_with_every_enforcer() { + let e = LandlockEnforcer::new(); + assert!( + e.tty_guard.is_some(), + "지원 아키텍처에서는 TIOCSTI 거부 필터가 항상 있어야 함" + ); + assert!( + !e.gaps().iter().any(|g| g.contains("TIOCSTI")), + "필터가 있는데 gap 으로 없다고 알리면 안 됨" + ); + } + #[test] fn dev_nodes_are_planned_as_non_directories() { let p = PlanPath::root("/dev/null"); diff --git a/crates/airlock-broker/src/lib.rs b/crates/airlock-broker/src/lib.rs index 1ce2a2f..d80c74b 100644 --- a/crates/airlock-broker/src/lib.rs +++ b/crates/airlock-broker/src/lib.rs @@ -1,4 +1,5 @@ pub mod approve; +pub(crate) mod bpf; pub mod egress; pub mod enforcer; pub mod error; diff --git a/crates/airlock-broker/src/notify.rs b/crates/airlock-broker/src/notify.rs index 12c2e71..7addf86 100644 --- a/crates/airlock-broker/src/notify.rs +++ b/crates/airlock-broker/src/notify.rs @@ -15,6 +15,11 @@ //! 실제로 여는 시점 사이에 링크가 바뀔 수 있어 구조적으로 TOCTOU를 안습니다 //! (`docs/policy-dsl.md` 4.2절). 실제 강제는 inode에 규칙을 거는 Landlock이 합니다. //! 그래서 여기서 내리는 거부는 방어의 층 하나일 뿐이며, 이 층만 믿어서는 안 됩니다. +//! +//! 예외가 하나 있습니다. 같은 필터가 `TIOCSTI` 와 `TIOCLINUX` 를 인자 값으로 커널에서 +//! 거부합니다. 인자가 경로가 아니라 정수라 TOCTOU 가 없고, 자식이 물려받은 제어 터미널에 +//! 입력을 밀어 넣어 승인 프롬프트를 위조하는 경로는 Landlock 이 막지 못하기 때문입니다. +//! 이 부분만은 중계가 꺼져 있어도 [`TtyGuard`] 로 걸립니다. use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::path::PathBuf; @@ -25,20 +30,24 @@ use airlock_audit::Protocol; use airlock_i18n::tr; use airlock_policy::FileMode; +use crate::bpf::{self, Layout, SockFilter}; use crate::session::{Actor, Session}; const SECCOMP_SET_MODE_FILTER: libc::c_uint = 1; const SECCOMP_FILTER_FLAG_NEW_LISTENER: libc::c_ulong = 1 << 3; -const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; -const SECCOMP_RET_USER_NOTIF: u32 = 0x7fc0_0000; -/// 프로세스 전체를 즉시 죽입니다. -/// -/// 중계할 수 없는 ABI로 들어온 syscall에 씁니다. 통과시키면 그 ABI로 부른 exec과 -/// connect가 승인 흐름을 통째로 우회하고, errno로 거부하면 프로그램이 무엇이 실패했는지 -/// 모른 채 같은 호출을 반복합니다 -const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000; const SECCOMP_USER_NOTIF_FLAG_CONTINUE: u32 = 1; +/// 어느 중계 수준에서도 커널에서 거부하는 tty ioctl. +/// +/// `TIOCSTI` 는 제어 터미널의 입력 큐에 바이트를 밀어 넣고, `TIOCLINUX` 는 가상 콘솔의 +/// 선택 영역 붙여넣기(`TIOCL_SETSEL`, `TIOCL_PASTESEL`)로 같은 일을 합니다. 자식은 제어 +/// 터미널을 상속받으므로 둘 중 하나로 `y\n` 을 넣으면 `/dev/tty` 에서 답을 읽는 승인 +/// 프롬프트가 위조됩니다. 정상 프로그램은 둘 다 쓰지 않습니다. 쉘, 에디터, TUI, 에이전트는 +/// 자기 stdin 을 읽을 뿐 자기 입력 큐에 쓰지 않으며, bubblewrap 과 flatpak 도 같은 두 개를 +/// 막습니다. 비교는 인자의 하위 32비트로 합니다. 커널이 request 를 `unsigned int` 로 +/// 받으므로 상위 비트를 세워도 같은 명령이기 때문입니다 +const REFUSED_TTY_IOCTLS: [u32; 2] = [libc::TIOCSTI as u32, libc::TIOCLINUX as u32]; + /// x86_64에서 x32 ABI로 부른 syscall은 번호에 이 비트가 켜집니다. /// /// `arch`는 여전히 `AUDIT_ARCH_X86_64`라서 아키텍처 검사를 통과하지만 번호 체계가 @@ -84,15 +93,6 @@ struct SeccompNotifResp { flags: u32, } -#[repr(C)] -#[derive(Debug, Clone, Copy)] -struct SockFilter { - code: u16, - jt: u8, - jf: u8, - k: u32, -} - #[repr(C)] #[derive(Debug)] struct SockFprog { @@ -100,19 +100,6 @@ struct SockFprog { filter: *const SockFilter, } -const fn stmt(code: u16, k: u32) -> SockFilter { - SockFilter { - code, - jt: 0, - jf: 0, - k, - } -} - -const fn jump(code: u16, k: u32, jt: u8, jf: u8) -> SockFilter { - SockFilter { code, jt, jf, k } -} - /// 이 빌드가 도는 아키텍처의 `AUDIT_ARCH_*` 값. /// /// `__AUDIT_ARCH_64BIT`(0x8000_0000)과 `__AUDIT_ARCH_LE`(0x4000_0000)에 ELF 머신 번호를 @@ -155,6 +142,35 @@ const LEGACY_OPEN: Option = Some(libc::SYS_open as i32); #[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "arm")))] const LEGACY_OPEN: Option = None; +/// dirfd 없는 이름 공간 syscall. `openat` 만 있는 아키텍처에는 없습니다 +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "arm"))] +const LEGACY_RENAME: Option = Some(libc::SYS_rename as i32); +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "arm"))] +const LEGACY_LINK: Option = Some(libc::SYS_link as i32); +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "arm"))] +const LEGACY_SYMLINK: Option = Some(libc::SYS_symlink as i32); +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "arm")))] +const LEGACY_RENAME: Option = None; +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "arm")))] +const LEGACY_LINK: Option = None; +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86", target_arch = "arm")))] +const LEGACY_SYMLINK: Option = None; + +/// `renameat`. aarch64 는 `__ARCH_WANT_RENAMEAT` 으로 38번을 두는데 glibc 의 `rename()` +/// 이 그 번호를 쓰지만 `libc` 크레이트가 gnu 타겟에 상수를 내놓지 않아 리터럴로 적습니다. +/// riscv64 와 loongarch64 는 `renameat2` 만 있습니다 +#[cfg(any(target_arch = "x86_64", target_arch = "x86", target_arch = "arm"))] +const RENAMEAT: Option = Some(libc::SYS_renameat as i32); +#[cfg(target_arch = "aarch64")] +const RENAMEAT: Option = Some(38); +#[cfg(not(any( + target_arch = "x86_64", + target_arch = "x86", + target_arch = "arm", + target_arch = "aarch64" +)))] +const RENAMEAT: Option = None; + /// 어디까지 중계할지. /// /// `openat`은 보통 프로그램 하나가 초당 수천 번 부르고, 감사 엔트리는 규격상 @@ -194,73 +210,79 @@ impl Level { /// 중계할 syscall 번호. /// /// `openat2`는 두 아키텍처에서 번호가 같아 리터럴로 적습니다. 빠뜨리면 그 경로로 -/// 파일을 여는 프로그램이 관측되지 않습니다 +/// 파일을 여는 프로그램이 관측되지 않습니다. `Full` 은 이름 공간을 바꾸는 `rename`, +/// `link`, `symlink` 계열도 중계합니다. 이것이 없으면 `openat` 으로 만들 수 없는 경로를 +/// 옆에서 만든 파일의 이름을 바꿔 만들 수 있습니다. `Off` 는 비어 있습니다 fn mediated_syscalls(level: Level) -> Vec { - let mut v: Vec = vec![ - libc::SYS_connect as u32, - libc::SYS_execve as u32, - libc::SYS_execveat as u32, - ]; + let mut v: Vec = Vec::new(); + if level == Level::Off { + return v; + } + v.push(libc::SYS_connect as u32); + v.push(libc::SYS_execve as u32); + v.push(libc::SYS_execveat as u32); if level == Level::Full { v.push(libc::SYS_openat as u32); v.push(437); // openat2 - if let Some(nr) = LEGACY_OPEN { + v.push(libc::SYS_linkat as u32); + v.push(libc::SYS_symlinkat as u32); + v.push(libc::SYS_renameat2 as u32); + for nr in [ + LEGACY_OPEN, + LEGACY_RENAME, + LEGACY_LINK, + LEGACY_SYMLINK, + RENAMEAT, + ] + .into_iter() + .flatten() + { v.push(nr as u32); } } v } -/// 중계 필터를 조립합니다. +/// 이 아키텍처와 수준에 맞는 필터 배치. +/// +/// 아키텍처가 다르면 죽입니다. 32비트 바이너리를 실행하면 syscall 번호 체계가 달라 +/// 비교가 전부 빗나가는데, 통과시키면 exec·connect 중계와 tty ioctl 거부를 통째로 +/// 우회합니다. exec ask 는 이 층에만 있으므로 그 우회는 곧 승인 우회입니다. x32 는 +/// arch 가 같으므로 번호로만 걸러 냅니다 /// /// # Errors /// 이 아키텍처의 `AUDIT_ARCH_*` 값을 모르면 `None`입니다. 아키텍처 검사를 뺀 필터는 /// 다른 ABI의 syscall 번호를 자기 것으로 착각하므로 만들지 않습니다 -fn build_filter(level: Level) -> Option> { - const LD_W_ABS: u16 = 0x20; // BPF_LD | BPF_W | BPF_ABS - const JMP_JEQ_K: u16 = 0x15; // BPF_JMP | BPF_JEQ | BPF_K - const RET_K: u16 = 0x06; // BPF_RET | BPF_K - - let native = NATIVE_ARCH?; - let nrs = mediated_syscalls(level); - let mut prog = Vec::with_capacity(nrs.len().saturating_add(8)); - - // 아키텍처가 다르면 죽입니다. 32비트 바이너리를 실행하면 syscall 번호 체계가 달라 - // 비교가 전부 빗나가는데, 통과시키면 exec·connect 중계를 통째로 우회합니다. - // exec ask 는 이 층에만 있으므로 그 우회는 곧 승인 우회입니다 - prog.push(stmt(LD_W_ABS, 4)); // seccomp_data.arch - prog.push(jump(JMP_JEQ_K, native, 1, 0)); - prog.push(stmt(RET_K, SECCOMP_RET_KILL_PROCESS)); - - prog.push(stmt(LD_W_ABS, 0)); // seccomp_data.nr - - // x32 는 arch 가 같으므로 번호로만 걸러 냅니다 +fn layout(level: Level) -> Option { #[cfg(target_arch = "x86_64")] - { - const JMP_JGE_K: u16 = 0x35; // BPF_JMP | BPF_JGE | BPF_K - prog.push(jump(JMP_JGE_K, X32_SYSCALL_BIT, 0, 1)); - prog.push(stmt(RET_K, SECCOMP_RET_KILL_PROCESS)); - } + let x32_bit = Some(X32_SYSCALL_BIT); + #[cfg(not(target_arch = "x86_64"))] + let x32_bit = None; + + Some(Layout { + native_arch: NATIVE_ARCH?, + x32_bit, + ioctl_nr: libc::SYS_ioctl as u32, + refused_ioctls: REFUSED_TTY_IOCTLS.to_vec(), + refused_errno: libc::EPERM as u32, + mediated: mediated_syscalls(level), + }) +} - // 마지막 두 명령이 ALLOW, USER_NOTIF이므로 매칭되면 그 지점으로 점프합니다 - let total_after = nrs.len(); - for (i, nr) in nrs.iter().enumerate() { - let remaining = total_after.saturating_sub(i).saturating_sub(1); - // 매칭이면 ALLOW를 건너뛰어 USER_NOTIF로, 아니면 다음 비교로 - let jt = u8::try_from(remaining.saturating_add(1)).unwrap_or(u8::MAX); - prog.push(jump(JMP_JEQ_K, *nr, jt, 0)); - } - prog.push(stmt(RET_K, SECCOMP_RET_ALLOW)); - prog.push(stmt(RET_K, SECCOMP_RET_USER_NOTIF)); - Some(prog) +/// 중계 필터를 조립합니다. `Off` 면 tty ioctl 거부만 있는 최소 필터입니다 +fn build_filter(level: Level) -> Option> { + layout(level).map(|l| bpf::assemble(&l)) } -/// 자식에서 seccomp 필터를 걸고 listener fd를 돌려줍니다. +/// 자식에서 seccomp 필터를 겁니다. /// /// # Safety /// `pre_exec` 문맥에서 호출합니다. 새로 할당하지 않도록 필터는 호출 전에 만들어 둡니다. /// `no_new_privs`를 먼저 세워야 권한 없는 프로세스가 필터를 걸 수 있습니다 -unsafe fn install_filter(prog: &[SockFilter]) -> std::io::Result { +unsafe fn seccomp_filter( + prog: &[SockFilter], + flags: libc::c_ulong, +) -> std::io::Result { // # Safety // prctl은 호출 프로세스의 플래그만 바꿉니다 if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { @@ -272,17 +294,28 @@ unsafe fn install_filter(prog: &[SockFilter]) -> std::io::Result { }; // # Safety // fprog는 이 스코프 동안 유효하고 len은 filter 배열 길이와 일치합니다 - let fd = unsafe { + let rc = unsafe { libc::syscall( libc::SYS_seccomp, SECCOMP_SET_MODE_FILTER, - SECCOMP_FILTER_FLAG_NEW_LISTENER, + flags, &raw const fprog, ) }; - if fd < 0 { + if rc < 0 { return Err(std::io::Error::last_os_error()); } + Ok(rc) +} + +/// 자식에서 중계 필터를 걸고 listener fd를 돌려줍니다. +/// +/// # Safety +/// [`seccomp_filter`] 와 같습니다 +unsafe fn install_filter(prog: &[SockFilter]) -> std::io::Result { + // # Safety + // 호출자가 pre_exec 문맥을 보장합니다 + let fd = unsafe { seccomp_filter(prog, SECCOMP_FILTER_FLAG_NEW_LISTENER)? }; RawFd::try_from(fd).map_err(|_| { std::io::Error::other(tr!( "listener fd가 범위를 벗어남", @@ -291,6 +324,38 @@ unsafe fn install_filter(prog: &[SockFilter]) -> std::io::Result { }) } +/// 중계 없이도 거는 최소 seccomp 필터. +/// +/// 중계 수준이 `off` 이거나 중계를 켤 수 없어도 [`REFUSED_TTY_IOCTLS`] 는 막혀야 합니다. +/// 승인 프롬프트가 `/dev/tty` 에서 답을 읽는 한 그 위조 경로는 중계와 무관하기 때문입니다. +/// 강제 층이 `pre_exec` 에서 겁니다. 중계 필터와 겹쳐 걸려도 seccomp 는 더 엄격한 +/// 결과를 취하므로 해가 없습니다 +#[derive(Debug, Clone)] +pub struct TtyGuard { + prog: Vec, +} + +impl TtyGuard { + /// # Errors + /// 이 아키텍처의 seccomp arch 값을 모르면 `None` 입니다 + pub fn new() -> Option { + Some(Self { + prog: build_filter(Level::Off)?, + }) + } + + /// 자식에서 필터를 겁니다. + /// + /// # Safety + /// `pre_exec` 문맥에서 호출합니다. 필터는 이미 만들어져 있어 새로 할당하지 않습니다. + /// 필터는 호출한 스레드에만 걸리는데 exec 직전의 자식은 스레드가 하나뿐입니다 + pub unsafe fn install(&self) -> std::io::Result<()> { + // # Safety + // 호출자가 pre_exec 문맥을 보장합니다 + unsafe { seccomp_filter(&self.prog, 0).map(|_| ()) } + } +} + /// SCM_RIGHTS로 fd 하나를 보냅니다. /// /// # Safety @@ -424,22 +489,114 @@ fn read_cstr(pid: u32, addr: u64) -> Option { } } +/// syscall 인자에서 dirfd 를 커널과 같은 방식으로 읽습니다. +/// +/// 커널은 dirfd 를 `int` 로 받으므로 레지스터의 하위 32비트만 봅니다. `AT_FDCWD` 는 +/// 호출 규약에 따라 부호 확장(`0xffff_ffff_ffff_ff9c`)으로도 영 확장(`0xffff_ff9c`)으로도 +/// 들어오는데, 64비트 전체를 부호 있는 값으로 읽으면 후자를 큰 양수 fd 로 착각해 기준 +/// 디렉토리를 잃습니다 +fn dirfd_of(arg: u64) -> i32 { + arg as u32 as i32 +} + /// dirfd 기준 상대 경로를 절대 경로로 만듭니다 (`docs/policy-dsl.md` 4.2절). -fn resolve_at(pid: u32, dirfd: i64, path: PathBuf) -> PathBuf { +/// +/// # Errors +/// 기준을 알 수 없으면 `None` 입니다. 상대 경로를 그대로 돌려주면 정책 엔진이 브로커의 +/// cwd 에 이어 붙여 판정하는데 자식이 `chdir` 했으면 그것은 엉뚱한 경로입니다. +/// 판단 불가는 제한 방향으로 처리합니다 +fn resolve_at(pid: u32, dirfd: i32, path: PathBuf) -> Option { if path.is_absolute() { - return path; + return Some(path); } - let base = if dirfd == libc::AT_FDCWD as i64 { - std::fs::read_link(format!("/proc/{pid}/cwd")).ok() + let base = if dirfd == libc::AT_FDCWD { + std::fs::read_link(format!("/proc/{pid}/cwd")).ok()? } else if dirfd >= 0 { - std::fs::read_link(format!("/proc/{pid}/fd/{dirfd}")).ok() + std::fs::read_link(format!("/proc/{pid}/fd/{dirfd}")).ok()? } else { - None + return None; }; - match base { - Some(b) => b.join(path), - None => path, + // 소켓이나 지워진 파일은 "socket:[..]" 같은 문자열로 읽히며 절대 경로가 아닙니다 + if !base.is_absolute() { + return None; + } + // `AT_EMPTY_PATH` 로 fd 자체를 가리키는 호출은 경로가 비어 있습니다 + if path.as_os_str().is_empty() { + return Some(base); } + Some(base.join(path)) +} + +/// 이름 공간을 바꾸는 syscall 하나의 인자 자리. +/// +/// 원본은 `Delete`, 목적지는 `Create` 로 판정합니다. 하드링크의 원본도 `Delete` 로 +/// 봅니다. 파일 자체는 남지만 그 inode 가 새 이름을 얻어 원래 경로에 걸린 규칙을 +/// 벗어나므로, 원래 이름을 지우는 것과 같은 무게로 다룹니다. 심볼릭 링크의 대상 문자열은 +/// 접근이 아니라 값이라 판정하지 않습니다. 그 링크를 통한 접근은 열리는 시점에 해소 경로로 +/// 다시 판정됩니다 +struct LinkSpec { + src: Option<(i32, u64)>, + dst: (i32, u64), + /// `RENAME_EXCHANGE` 는 양쪽이 서로의 자리를 차지하므로 두 모드를 모두 봅니다 + exchange: bool, +} + +fn link_spec(nr: i32, args: &[u64; 6]) -> Option { + let cwd = libc::AT_FDCWD; + if Some(nr) == LEGACY_RENAME || Some(nr) == LEGACY_LINK { + Some(LinkSpec { + src: Some((cwd, args[0])), + dst: (cwd, args[1]), + exchange: false, + }) + } else if Some(nr) == RENAMEAT || nr == libc::SYS_linkat as i32 { + Some(LinkSpec { + src: Some((dirfd_of(args[0]), args[1])), + dst: (dirfd_of(args[2]), args[3]), + exchange: false, + }) + } else if nr == libc::SYS_renameat2 as i32 { + Some(LinkSpec { + src: Some((dirfd_of(args[0]), args[1])), + dst: (dirfd_of(args[2]), args[3]), + exchange: (args[4] as u32) & libc::RENAME_EXCHANGE != 0, + }) + } else if Some(nr) == LEGACY_SYMLINK { + Some(LinkSpec { + src: None, + dst: (cwd, args[1]), + exchange: false, + }) + } else if nr == libc::SYS_symlinkat as i32 { + Some(LinkSpec { + src: None, + dst: (dirfd_of(args[1]), args[2]), + exchange: false, + }) + } else { + None + } +} + +/// 판정할 (경로, 모드) 목록. 원본이 먼저입니다 +/// +/// # Errors +/// 경로 하나라도 읽거나 해소하지 못하면 `None` 이며 호출자는 거부합니다 +fn link_targets(pid: u32, spec: &LinkSpec) -> Option> { + let mut out = Vec::with_capacity(4); + if let Some((dirfd, addr)) = spec.src { + let src = resolve_at(pid, dirfd, read_cstr(pid, addr)?)?; + if spec.exchange { + out.push((src.clone(), FileMode::Create)); + } + out.push((src, FileMode::Delete)); + } + let dst = resolve_at(pid, spec.dst.0, read_cstr(pid, spec.dst.1)?)?; + if spec.exchange { + out.push((dst.clone(), FileMode::Delete)); + } + out.push((dst, FileMode::Create)); + Some(out) } /// `connect`의 대상 주소를 읽은 결과. @@ -636,13 +793,12 @@ pub fn supervise(listener: OwnedFd, session: Arc>, stop: Arc { - let program = resolve_at(pid, dirfd, p); + match read_cstr(pid, path_arg).and_then(|p| resolve_at(pid, dirfd, p)) { + Some(program) => { let argv = read_argv(pid, argv_arg); let mut s = match session.lock() { Ok(s) => s, @@ -654,20 +810,41 @@ pub fn supervise(listener: OwnedFd, session: Arc>, stop: Arc false, } + } else if let Some(spec) = link_spec(nr, &args) { + // rename / link / symlink 계열. 원본이 거부되면 목적지는 묻지 않습니다 + match link_targets(pid, &spec) { + Some(targets) => { + let mut s = match session.lock() { + Ok(s) => s, + Err(_) => break, + }; + let mut ok = true; + for (path, mode) in &targets { + ok = s + .check_file(path, *mode, Actor::Observed(pid)) + .map(|o| o.permitted()) + .unwrap_or(false); + if !ok { + break; + } + } + ok + } + None => false, + } } else { // openat / openat2 / open let (dirfd, path_arg, mode) = if nr == openat_nr { - (args[0] as i64, args[1], file_mode_for(args[2])) + (dirfd_of(args[0]), args[1], file_mode_for(args[2])) } else if LEGACY_OPEN == Some(nr) { - (libc::AT_FDCWD as i64, args[0], file_mode_for(args[1])) + (libc::AT_FDCWD, args[0], file_mode_for(args[1])) } else { // openat2 는 세 번째 인자가 open_how 구조체 포인터입니다. 플래그를 따로 // 읽지 않고 읽기로 보수적으로 잡아 정책 평가만 받습니다 - (args[0] as i64, args[1], FileMode::Read) + (dirfd_of(args[0]), args[1], FileMode::Read) }; - match read_cstr(pid, path_arg) { - Some(p) => { - let path = resolve_at(pid, dirfd, p); + match read_cstr(pid, path_arg).and_then(|p| resolve_at(pid, dirfd, p)) { + Some(path) => { let mut s = match session.lock() { Ok(s) => s, Err(_) => break, @@ -836,17 +1013,21 @@ mod tests { build_filter(level).expect("이 아키텍처의 필터를 만들 수 없음") } + fn is_ret(i: &SockFilter, k: u32) -> bool { + i.code == bpf::RET_K && i.k == k + } + #[test] fn filter_program_is_well_formed() { let prog = filter(Level::Full); assert!(prog.len() >= 6, "필터가 너무 짧음"); let last = prog.last().copied().expect("빈 필터"); - assert_eq!(last.k, SECCOMP_RET_USER_NOTIF, "마지막은 USER_NOTIF여야 함"); + assert_eq!(last.k, bpf::RET_USER_NOTIF, "마지막은 USER_NOTIF여야 함"); let allow = prog .get(prog.len().saturating_sub(2)) .copied() .expect("짧음"); - assert_eq!(allow.k, SECCOMP_RET_ALLOW); + assert_eq!(allow.k, bpf::RET_ALLOW); } #[test] @@ -855,19 +1036,46 @@ mod tests { let prog = filter(Level::Full); for nr in nrs { assert!( - prog.iter().any(|i| i.k == nr && i.code == 0x15), + prog.iter().any(|i| i.k == nr && i.code == bpf::JMP_JEQ_K), "syscall {nr} 비교가 필터에 없음" ); } } + #[test] + fn full_mediates_the_rename_and_link_family() { + let nrs = mediated_syscalls(Level::Full); + for nr in [ + libc::SYS_linkat as u32, + libc::SYS_symlinkat as u32, + libc::SYS_renameat2 as u32, + ] { + assert!(nrs.contains(&nr), "syscall {nr} 이 full 중계 목록에 없음"); + } + for nr in [LEGACY_RENAME, LEGACY_LINK, LEGACY_SYMLINK, RENAMEAT] + .into_iter() + .flatten() + { + assert!( + nrs.contains(&(nr as u32)), + "syscall {nr} 이 full 중계 목록에 없음" + ); + } + let exec_net = mediated_syscalls(Level::ExecNet); + assert!( + !exec_net.contains(&(libc::SYS_renameat2 as u32)), + "기본 수준은 rename 을 중계하지 않음" + ); + assert!(mediated_syscalls(Level::Off).is_empty()); + } + #[test] fn foreign_architecture_is_killed_not_allowed() { let prog = filter(Level::ExecNet); // 첫 세 명령이 arch 적재, 비교, 불일치 처리입니다 assert_eq!( prog.first().map(|i| i.k), - Some(4), + Some(bpf::OFF_ARCH), "arch 적재가 먼저여야 함" ); assert_eq!( @@ -877,14 +1085,11 @@ mod tests { ); assert_eq!( prog.get(2).map(|i| i.k), - Some(SECCOMP_RET_KILL_PROCESS), + Some(bpf::RET_KILL_PROCESS), "아키텍처 불일치를 통과시키면 중계가 통째로 우회됨" ); assert!( - !prog - .iter() - .take(3) - .any(|i| i.k == SECCOMP_RET_ALLOW && i.code == 0x06), + !prog.iter().take(3).any(|i| is_ret(i, bpf::RET_ALLOW)), "아키텍처 검사 구간에 ALLOW가 있으면 안 됨" ); } @@ -899,10 +1104,51 @@ mod tests { .expect("x32 비트 검사가 없음. arch 검사를 통과하면서 번호가 어긋나 우회됨"); assert_eq!( prog.get(idx.saturating_add(1)).map(|i| i.k), - Some(SECCOMP_RET_KILL_PROCESS) + Some(bpf::RET_KILL_PROCESS) ); } + #[test] + fn tty_ioctls_are_refused_at_every_level() { + for level in [Level::Off, Level::ExecNet, Level::Full] { + let prog = filter(level); + assert!( + prog.iter() + .any(|i| i.code == bpf::JMP_JEQ_K && i.k == libc::SYS_ioctl as u32), + "{level:?} 필터에 ioctl 비교가 없음" + ); + for request in REFUSED_TTY_IOCTLS { + assert!( + prog.iter() + .any(|i| i.code == bpf::JMP_JEQ_K && i.k == request), + "{level:?} 필터에 ioctl {request:#x} 비교가 없음" + ); + } + assert!( + prog.iter() + .any(|i| is_ret(i, bpf::RET_ERRNO | libc::EPERM as u32)), + "{level:?} 필터가 EPERM 을 돌려주지 않음" + ); + assert!( + prog.iter() + .any(|i| i.code == bpf::LD_W_ABS && i.k == bpf::off_arg_lo(1)), + "{level:?} 필터가 ioctl 의 두 번째 인자를 읽지 않음" + ); + } + let off = filter(Level::Off); + assert!( + !off.iter().any(|i| is_ret(i, bpf::RET_USER_NOTIF)), + "off 수준의 최소 필터는 아무것도 중계하지 않음" + ); + assert!(TtyGuard::new().is_some()); + } + + #[test] + fn refused_tty_ioctl_numbers_match_the_kernel_abi() { + // x86_64 와 aarch64 는 asm-generic ioctls.h 를 공유하므로 값이 같습니다 + assert_eq!(REFUSED_TTY_IOCTLS, [0x5412, 0x541C]); + } + #[test] fn ioctl_numbers_match_the_kernel_abi() { // 커널 uapi의 _IOWR('!', 0, struct seccomp_notif) 등과 같아야 합니다 @@ -927,4 +1173,63 @@ mod tests { FileMode::Create ); } + + #[test] + fn dirfd_is_read_like_the_kernel_reads_it() { + // 부호 확장과 영 확장 모두 AT_FDCWD 로 읽혀야 합니다 + assert_eq!(dirfd_of(0xffff_ffff_ffff_ff9c), libc::AT_FDCWD); + assert_eq!(dirfd_of(0x0000_0000_ffff_ff9c), libc::AT_FDCWD); + assert_eq!(dirfd_of(3), 3); + assert_eq!(dirfd_of(0xdead_beef_0000_0003), 3); + } + + #[test] + fn an_unresolvable_base_is_a_refusal_not_a_relative_path() { + let me = std::process::id(); + assert_eq!( + resolve_at(me, libc::AT_FDCWD, PathBuf::from("/abs")), + Some(PathBuf::from("/abs")) + ); + let cwd = std::env::current_dir().expect("cwd"); + assert_eq!( + resolve_at(me, libc::AT_FDCWD, PathBuf::from("rel")), + Some(cwd.join("rel")) + ); + assert_eq!(resolve_at(me, libc::AT_FDCWD, PathBuf::new()), Some(cwd)); + assert_eq!(resolve_at(me, -2, PathBuf::from("rel")), None); + assert_eq!(resolve_at(me, 999_999, PathBuf::from("rel")), None); + } + + #[test] + fn link_specs_take_source_then_destination() { + let cwd = libc::AT_FDCWD; + let args = [7, 0x10, 9, 0x20, u64::from(libc::RENAME_EXCHANGE), 0]; + let spec = link_spec(libc::SYS_renameat2 as i32, &args).expect("renameat2"); + assert_eq!(spec.src, Some((7, 0x10))); + assert_eq!(spec.dst, (9, 0x20)); + assert!(spec.exchange); + + let spec = link_spec(libc::SYS_linkat as i32, &[7, 0x10, 9, 0x20, 0, 0]).expect("linkat"); + assert_eq!(spec.src, Some((7, 0x10))); + assert_eq!(spec.dst, (9, 0x20)); + assert!(!spec.exchange); + + let spec = + link_spec(libc::SYS_symlinkat as i32, &[0x10, 9, 0x20, 0, 0, 0]).expect("symlinkat"); + assert_eq!(spec.src, None, "심볼릭 링크 대상 문자열은 접근이 아님"); + assert_eq!(spec.dst, (9, 0x20)); + + if let Some(nr) = LEGACY_RENAME { + let spec = link_spec(nr, &[0x10, 0x20, 0, 0, 0, 0]).expect("rename"); + assert_eq!(spec.src, Some((cwd, 0x10))); + assert_eq!(spec.dst, (cwd, 0x20)); + } + if let Some(nr) = LEGACY_SYMLINK { + let spec = link_spec(nr, &[0x10, 0x20, 0, 0, 0, 0]).expect("symlink"); + assert_eq!(spec.src, None); + assert_eq!(spec.dst, (cwd, 0x20)); + } + assert!(link_spec(libc::SYS_openat as i32, &[0; 6]).is_none()); + assert!(link_spec(libc::SYS_unlinkat as i32, &[0; 6]).is_none()); + } } diff --git a/crates/airlock-broker/src/profile.rs b/crates/airlock-broker/src/profile.rs index 47183e9..fb6bdfc 100644 --- a/crates/airlock-broker/src/profile.rs +++ b/crates/airlock-broker/src/profile.rs @@ -55,6 +55,16 @@ const DEV_RW_LITERALS: &[&str] = &[ "/dev/dtracehelper", ]; +/// 프록시 모드에서 자식에게 열어 주는 유닉스 도메인 소켓. +/// +/// 이전에는 `(remote unix)` 로 모든 유닉스 소켓이 열려 ssh-agent 소켓, docker.sock, +/// 바깥의 임의 리스너가 프록시도 감사도 거치지 않고 닿았습니다. 지금은 루트 소유 시스템 +/// 데몬 두 개만 경로 리터럴로 엽니다. mDNSResponder 는 `getaddrinfo` 의 경로라 막으면 +/// 이름 해석이 전부 실패하고, syslog 는 Apple 기반 프로파일이 모든 샌드박스 프로세스에 +/// 여는 쓰기 전용 로그 싱크입니다. 항목별 근거는 `docs/egress-proxy.md` 6.1 에 있습니다 +pub const PROXY_UNIX_SOCKET_LITERALS: &[&str] = + &["/private/var/run/mDNSResponder", "/private/var/run/syslog"]; + #[derive(Debug, Clone)] pub struct ProfileOptions { pub allow_network: bool, @@ -68,7 +78,8 @@ pub struct ProfileOptions { pub program: Option, /// egress 프록시가 듣고 있는 루프백 주소. /// - /// 값이 있으면 아웃바운드를 이 주소 하나로 좁힙니다. 그래야 프록시가 유일한 + /// 값이 있으면 IP 아웃바운드를 이 주소 하나로 좁히고 유닉스 소켓은 + /// [`PROXY_UNIX_SOCKET_LITERALS`] 로 좁힙니다. 그래야 프록시가 IP 연결의 유일한 /// 출구가 되고 호스트 단위 정책이 처음으로 강제됩니다 pub proxy: Option, } @@ -343,8 +354,19 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { "(allow network-outbound (remote ip {}))\n", sbpl::quote(&format!("localhost:{}", proxy.port())) )); - // 유닉스 소켓까지 막으면 시스템 라이브러리가 대부분 동작하지 않습니다 - out.push_str("(allow network-outbound (remote unix))\n\n"); + // 유닉스 소켓은 통째로 열지 않습니다. 통째 개방은 ssh-agent 와 docker.sock 을 + // 프록시 밖의 출구로 만듭니다. 이름 해석과 로그에 필요한 시스템 소켓만 엽니다 + out.push_str(tr!( + ";; 유닉스 소켓은 아래 시스템 데몬 소켓만 열림. 통째 개방 없음\n", + ";; unix sockets open only to the system daemon sockets below; no wholesale opening\n" + )); + for p in PROXY_UNIX_SOCKET_LITERALS { + out.push_str(&format!( + "(allow network-outbound (literal {}))\n", + sbpl::quote(p) + )); + } + out.push('\n'); } else if network_allowed(policy, opts) { out.push_str(tr!( ";; 주의 호스트 단위 제어는 Seatbelt로 표현할 수 없음\n", @@ -977,6 +999,90 @@ action = "allow" assert!(!p.text.contains("network-outbound"), "{}", p.text); } + /// 프록시 모드의 유닉스 소켓은 허용 목록 리터럴만 열립니다. + /// + /// `(remote unix)` 가 한 줄이라도 남으면 ssh-agent 와 docker.sock 이 프록시 밖의 + /// 출구가 됩니다. 아웃바운드 규칙은 프록시 포트 한 줄과 허용 목록 리터럴뿐이어야 합니다 + #[test] + fn a_proxy_opens_only_the_listed_unix_sockets() { + let policy = with_egress( + r#" +[[rules]] +id = "anthropic" +kind = "egress" +host = "api.anthropic.com" +port = 443 +action = "allow" +"#, + ); + let opts = ProfileOptions::default().with_proxy(proxy_addr(18899)); + let p = generate(&policy, &opts); + assert!( + !p.text.contains("(remote unix)"), + "조건 없는 유닉스 소켓 개방이 남아 있음: {}", + p.text + ); + for sock in PROXY_UNIX_SOCKET_LITERALS { + let line = format!("(allow network-outbound (literal {}))", sbpl::quote(sock)); + assert!( + p.text.contains(&line), + "허용 목록 항목이 빠짐: {line}\n{}", + p.text + ); + } + let outbound: Vec<&str> = p + .text + .lines() + .filter(|l| l.contains("network-outbound")) + .collect(); + let expected = PROXY_UNIX_SOCKET_LITERALS.len().saturating_add(1); + assert_eq!( + outbound.len(), + expected, + "프록시 포트 한 줄과 허용 목록 리터럴 외의 아웃바운드 규칙이 있음: {outbound:?}" + ); + for line in outbound { + let listed = line.contains("(remote ip \"localhost:18899\")") + || PROXY_UNIX_SOCKET_LITERALS + .iter() + .any(|s| line.contains(&format!("(literal {})", sbpl::quote(s)))); + assert!(listed, "허용 목록 밖의 아웃바운드 규칙: {line}"); + } + } + + /// 허용 목록은 루트 소유 디렉토리 아래의 절대 경로 리터럴이어야 합니다. + /// + /// 자식이 만들 수 있는 위치의 소켓을 허용하면 그 이름으로 소켓을 미리 만들어 + /// 허용 목록을 가로챌 수 있습니다 + #[test] + fn the_unix_socket_allow_list_stays_under_var_run() { + assert!(!PROXY_UNIX_SOCKET_LITERALS.is_empty()); + for sock in PROXY_UNIX_SOCKET_LITERALS { + assert!( + sock.starts_with("/private/var/run/"), + "루트 소유 디렉토리 밖의 소켓: {sock}" + ); + assert!( + !sock.contains(['"', '\\', '\n']), + "인용을 깨는 문자가 든 경로: {sock}" + ); + assert_eq!( + sock.matches('/').count(), + 4, + "하위 디렉토리나 상대 세그먼트가 든 경로: {sock}" + ); + } + } + + #[test] + fn no_network_closes_unix_sockets_too() { + let p = generate(&baseline(), &ProfileOptions::default().with_network(false)); + assert!(!p.text.contains("network-outbound"), "{}", p.text); + for sock in PROXY_UNIX_SOCKET_LITERALS { + assert!(!p.text.contains(sock), "{}", p.text); + } + } + #[test] fn egress_allow_rule_opens_outbound_and_declares_the_gap() { let policy = with_egress( diff --git a/crates/airlock-broker/src/seatbelt.rs b/crates/airlock-broker/src/seatbelt.rs index 7622dc6..72fb076 100644 --- a/crates/airlock-broker/src/seatbelt.rs +++ b/crates/airlock-broker/src/seatbelt.rs @@ -271,8 +271,23 @@ impl Enforcer for SeatbeltEnforcer { ) .to_string() }; - let mut gaps = vec![ - egress_note, + let mut gaps = vec![egress_note]; + if self.options.proxy.is_some() && self.options.allow_network { + // 허용 목록에 남긴 소켓은 프록시도 감사도 거치지 않으므로 숨기지 않습니다 + let sockets = profile::PROXY_UNIX_SOCKET_LITERALS.join(", "); + gaps.push(tr!( + format!( + "유닉스 소켓은 시스템 데몬 소켓 {sockets} 만 열림 (DNS 와 로그). \ + 그 연결은 프록시와 감사 로그를 거치지 않음. 나머지 유닉스 소켓은 커널이 거부함" + ), + format!( + "unix sockets open only to the system daemon sockets {sockets} (DNS and \ + logging); those connections bypass the proxy and the audit log; every \ + other unix socket is refused by the kernel" + ) + )); + } + gaps.extend([ profile::ask_rules_are_denied_note().to_string(), tr!( "커널이 거부한 개별 파일·네트워크 접근은 감사 로그에 남지 않음. \ @@ -288,7 +303,7 @@ impl Enforcer for SeatbeltEnforcer { is outside the policy model and the audit log" ) .to_string(), - ]; + ]); if !self.untranslatable.is_empty() { gaps.push(tr!( format!( @@ -375,6 +390,45 @@ mod tests { assert!(gaps.iter().any(|g| g.contains("ask"))); } + /// 프록시 모드에 남긴 유닉스 소켓 허용 목록은 배너가 밝혀야 합니다 + #[test] + fn the_proxy_mode_declares_its_unix_socket_allow_list() { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 18899)); + let mut e = + SeatbeltEnforcer::new().with_options(ProfileOptions::default().with_proxy(addr)); + e.prepare(&policy()).unwrap(); + let gaps = e.gaps(); + for sock in profile::PROXY_UNIX_SOCKET_LITERALS { + assert!( + gaps.iter().any(|g| g.contains(sock)), + "허용 목록의 {sock} 이 gap 에 없음: {gaps:?}" + ); + } + + let mut plain = SeatbeltEnforcer::new(); + plain.prepare(&policy()).unwrap(); + assert!( + !plain + .gaps() + .iter() + .any(|g| g.contains("/private/var/run/mDNSResponder")), + "프록시가 없는 모드에서 프록시 전용 gap 이 나옴" + ); + + let mut off = SeatbeltEnforcer::new().with_options( + ProfileOptions::default() + .with_proxy(addr) + .with_network(false), + ); + off.prepare(&policy()).unwrap(); + assert!( + !off.gaps() + .iter() + .any(|g| g.contains("/private/var/run/mDNSResponder")), + "--no-network 에서는 유닉스 소켓이 하나도 열리지 않으므로 gap 도 없어야 함" + ); + } + #[test] fn override_relaxations_are_declared_as_a_gap() { let ctx = LoadContext::new("/Users/me", "/tmp/airlock-audit"); diff --git a/crates/airlock-broker/src/session.rs b/crates/airlock-broker/src/session.rs index a975dfa..8bd559e 100644 --- a/crates/airlock-broker/src/session.rs +++ b/crates/airlock-broker/src/session.rs @@ -720,6 +720,9 @@ impl Session { /// 않고 [`Closed::anchor`]에 담겨 올라갑니다. 이미 끝난 자식 실행을 되돌릴 수는 /// 없으므로 사실을 보고에 남기고 호출부가 판단하게 합니다 pub fn finish(&mut self, status: Option<&std::process::ExitStatus>) -> Result { + if self.closed { + return Err(BrokerError::SessionClosed); + } let audit_status = match status { Some(s) => exit_status_of(s), None => audit::ExitStatus::Unknown, @@ -826,6 +829,81 @@ pub fn which(program: &str) -> Option { None } +/// 로더 주입에 쓰이는 환경 변수 접두. +/// +/// 이 값들이 살아 있으면 정책이 경로로 허용한 프로그램 안에서 남의 코드가 돕니다. +/// `kind = "exec"` allowlist 가 통째로 무의미해지므로 전달하지 않습니다 +const INJECTION_PREFIXES: &[&str] = &["LD_", "DYLD_"]; + +/// 쉘이 시작할 때 읽어 실행하는 환경 변수. +const INJECTION_EXACT: &[&str] = &[ + "BASH_ENV", + "ENV", + "SHELLOPTS", + "BASHOPTS", + "IFS", + "PS4", + "PERL5OPT", + "PERL5LIB", + "PYTHONSTARTUP", + "PYTHONPATH", + "NODE_OPTIONS", + "RUBYOPT", + "GIT_EXTERNAL_DIFF", + "GIT_SSH_COMMAND", +]; + +/// 호스트 데몬 소켓을 가리키는 환경 변수. +/// +/// 시크릿 경로 기본 deny 의 연장입니다. `~/.ssh` 를 막으면서 같은 키로 서명해 주는 +/// ssh-agent 소켓의 주소를 건네는 것은 모순이고, `DOCKER_HOST` 가 가리키는 소켓은 +/// 호스트 루트와 다름없는 능력입니다. 완화 어휘가 정책에 생기기 전까지는 벗깁니다 +/// (`docs/limitations.md` 9.6) +const SOCKET_HANDLE_EXACT: &[&str] = &["SSH_AUTH_SOCK", "DOCKER_HOST"]; + +/// 자식에게 넘기지 않을 환경 변수인지. +/// +/// # Arguments +/// `key` - UTF-8 로 읽힌 변수 이름 +fn env_is_stripped(key: &str) -> bool { + INJECTION_PREFIXES.iter().any(|p| key.starts_with(p)) + || INJECTION_EXACT.contains(&key) + || SOCKET_HANDLE_EXACT.contains(&key) + // 감사 로그 위치를 알려 줄 이유가 없습니다 + || key == "AIRLOCK_AUDIT_DIR" +} + +/// 자식에게 넘길 환경에서 코드 주입 통로와 호스트 데몬 소켓 핸들을 걷어냅니다. +/// +/// 통째로 비우지 않는 이유는 에이전트가 `PATH`, `HOME`, `TERM` 없이는 정상 동작하지 +/// 않기 때문입니다. 대신 로더와 인터프리터가 시작 시점에 실행하는 값과 소켓을 가리키는 +/// 값만 지웁니다. +/// +/// # Arguments +/// `cmd` - 환경을 정리할 명령 +fn sanitize_env(cmd: &mut Command) { + sanitize_env_from(cmd, std::env::vars_os().map(|(k, _)| k)); +} + +/// [`sanitize_env`] 의 본체. 검사할 변수 이름을 밖에서 받아 프로세스 환경을 건드리지 +/// 않고도 검증할 수 있게 합니다. +/// +/// # Arguments +/// `cmd` - 환경을 정리할 명령 +/// `keys` - 자식에게 상속될 후보 변수 이름 +fn sanitize_env_from(cmd: &mut Command, keys: impl IntoIterator) { + for key in keys { + let Some(k) = key.to_str() else { + // UTF-8이 아닌 변수 이름은 검사할 수 없으므로 넘기지 않습니다 + cmd.env_remove(&key); + continue; + }; + if env_is_stripped(k) { + cmd.env_remove(&key); + } + } +} + #[derive(Debug)] pub struct RunReport { pub audit_dir: PathBuf, @@ -879,54 +957,6 @@ pub fn run( enforcer.set_program(&resolved); enforcer.prepare(&policy)?; - /// 로더 주입에 쓰이는 환경 변수 접두. - /// - /// 이 값들이 살아 있으면 정책이 경로로 허용한 프로그램 안에서 남의 코드가 돕니다. - /// `kind = "exec"` allowlist 가 통째로 무의미해지므로 전달하지 않습니다 - const INJECTION_PREFIXES: &[&str] = &["LD_", "DYLD_"]; - - /// 쉘이 시작할 때 읽어 실행하는 환경 변수. - const INJECTION_EXACT: &[&str] = &[ - "BASH_ENV", - "ENV", - "SHELLOPTS", - "BASHOPTS", - "IFS", - "PS4", - "PERL5OPT", - "PERL5LIB", - "PYTHONSTARTUP", - "PYTHONPATH", - "NODE_OPTIONS", - "RUBYOPT", - "GIT_EXTERNAL_DIFF", - "GIT_SSH_COMMAND", - ]; - - /// 자식에게 넘길 환경에서 코드 주입 통로를 걷어냅니다. - /// - /// 통째로 비우지 않는 이유는 에이전트가 `PATH`, `HOME`, `TERM` 없이는 정상 동작하지 - /// 않기 때문입니다. 대신 로더와 인터프리터가 시작 시점에 실행하는 값만 지웁니다. - /// - /// # Arguments - /// `cmd` - 환경을 정리할 명령 - fn sanitize_env(cmd: &mut Command) { - for (key, _) in std::env::vars_os() { - let Some(k) = key.to_str() else { - // UTF-8이 아닌 변수 이름은 검사할 수 없으므로 넘기지 않습니다 - cmd.env_remove(&key); - continue; - }; - let strip = INJECTION_PREFIXES.iter().any(|p| k.starts_with(p)) - || INJECTION_EXACT.contains(&k) - // 감사 로그 위치를 알려 줄 이유가 없습니다 - || k == "AIRLOCK_AUDIT_DIR"; - if strip { - cmd.env_remove(&key); - } - } - } - let enforcement = enforcer.kind(); let mut gaps = enforcer.gaps(); gaps.extend(mediation_gaps(config.mediation)); @@ -1278,4 +1308,55 @@ mod tests { "명시한 앵커 루트가 이겨야 분리가 가능함" ); } + + /// 소켓 핸들 변수는 시크릿 경로 기본 deny 의 연장으로 자식에게 넘기지 않습니다 + #[test] + fn socket_handles_and_injection_hooks_are_stripped_from_the_child_env() { + use std::ffi::OsString; + + let mut cmd = std::process::Command::new("/usr/bin/true"); + let keys = [ + "SSH_AUTH_SOCK", + "DOCKER_HOST", + "DYLD_INSERT_LIBRARIES", + "LD_PRELOAD", + "BASH_ENV", + "AIRLOCK_AUDIT_DIR", + "PATH", + "HOME", + "TERM", + ] + .into_iter() + .map(OsString::from); + super::sanitize_env_from(&mut cmd, keys); + + let removed: Vec = cmd + .get_envs() + .filter(|(_, v)| v.is_none()) + .map(|(k, _)| k.to_string_lossy().into_owned()) + .collect(); + for key in [ + "SSH_AUTH_SOCK", + "DOCKER_HOST", + "DYLD_INSERT_LIBRARIES", + "LD_PRELOAD", + "BASH_ENV", + "AIRLOCK_AUDIT_DIR", + ] { + assert!( + removed.iter().any(|r| r == key), + "{key} 가 자식에게 넘어감: {removed:?}" + ); + } + for key in ["PATH", "HOME", "TERM"] { + assert!( + !removed.iter().any(|r| r == key), + "{key} 는 보존해야 함: {removed:?}" + ); + } + assert!(super::env_is_stripped("SSH_AUTH_SOCK")); + assert!(super::env_is_stripped("DOCKER_HOST")); + assert!(!super::env_is_stripped("DOCKER_HOSTNAME")); + assert!(!super::env_is_stripped("SSH_AUTH_SOCKET")); + } } diff --git a/crates/airlock-broker/tests/enforce.rs b/crates/airlock-broker/tests/enforce.rs index 6ce70f6..d9194cc 100644 --- a/crates/airlock-broker/tests/enforce.rs +++ b/crates/airlock-broker/tests/enforce.rs @@ -394,6 +394,130 @@ action = "allow" drop(accepting); } +fn run_under_sandbox_with(opts: ProfileOptions, policy: &Policy, argv: &[&str]) -> bool { + let mut enforcer = SeatbeltEnforcer::new().with_options(opts); + enforcer.prepare(policy).unwrap(); + + let (program, rest) = argv.split_first().expect("빈 argv"); + let mut cmd = Command::new(program); + cmd.args(rest) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .stdin(Stdio::null()); + enforcer.wrap(&mut cmd).unwrap(); + cmd.status().map(|s| s.success()).unwrap_or(false) +} + +/// 바깥에서 띄운 유닉스 소켓 리스너. +/// +/// 짧은 경로가 필요합니다. `sockaddr_un` 의 경로 상한(104 바이트) 때문에 temp_dir +/// 아래 긴 이름은 bind 자체가 실패합니다 +fn unix_listener(tag: &str) -> (PathBuf, std::os::unix::net::UnixListener) { + let path = PathBuf::from(format!("/tmp/airlock-{tag}-{}.sock", std::process::id())); + let _ = fs::remove_file(&path); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + (path, listener) +} + +/// 프록시 모드에서 유닉스 소켓은 허용 목록 밖이면 커널이 거부해야 합니다. +/// +/// `(remote unix)` 통째 개방이 남아 있으면 ssh-agent 와 docker.sock 이 프록시도 +/// 감사도 거치지 않는 출구가 됩니다. 대조군으로 egress allow 모드에서는 같은 연결이 +/// 성공하는 것을 확인해, 실패가 프로파일 때문이지 환경 때문이 아님을 고정합니다 +#[test] +fn a_proxy_refuses_unix_sockets_outside_the_allow_list() { + let s = Scratch::new("unix-proxy"); + let (sock, listener) = unix_listener("unix-proxy"); + let accepting = std::thread::spawn(move || { + for _ in 0..2 { + if listener.accept().is_err() { + break; + } + } + }); + + let sock_s = sock.to_string_lossy().into_owned(); + // `-z` 는 유닉스 소켓에서 성공해도 1 을 돌려주므로 실제 연결 뒤 stdin EOF 로 끝냅니다 + let argv = ["/usr/bin/nc", "-U", "-w", "1", sock_s.as_str()]; + let policy = policy_with( + s.path(), + r#" +[[rules]] +id = "anthropic" +kind = "egress" +host = "api.anthropic.com" +port = 443 +action = "allow" +"#, + ); + + // 대조군. 프록시 없는 egress allow 모드는 아웃바운드가 통째로 열립니다 + assert!( + run_under_sandbox_with( + ProfileOptions::default().with_workspace(s.path()), + &policy, + &argv + ), + "대조군인 egress allow 모드에서 유닉스 소켓 연결이 실패하면 이 테스트가 무의미함" + ); + + let proxy = std::net::SocketAddr::from(([127, 0, 0, 1], 18899)); + assert!( + !run_under_sandbox_with( + ProfileOptions::default() + .with_workspace(s.path()) + .with_proxy(proxy), + &policy, + &argv + ), + "프록시 모드에서 허용 목록 밖의 유닉스 소켓에 연결됨. (remote unix) 통째 개방이 남아 있음" + ); + + drop(accepting); + let _ = fs::remove_file(&sock); +} + +/// 허용 목록의 시스템 소켓은 프록시 모드에서도 닿아야 합니다. 이름 해석이 그 위에 있습니다. +/// +/// `nc` 는 stream 소켓만 다루고 syslog 소켓은 datagram 이라 perl 로 두 타입을 다 시도합니다 +#[test] +fn a_proxy_still_reaches_the_listed_system_sockets() { + const PERL: &str = "/usr/bin/perl"; + if !Path::new(PERL).is_file() { + return; + } + let s = Scratch::new("unix-mdns"); + let policy = policy_with( + s.path(), + r#" +[[rules]] +id = "anthropic" +kind = "egress" +host = "api.anthropic.com" +port = 443 +action = "allow" +"#, + ); + let proxy = std::net::SocketAddr::from(([127, 0, 0, 1], 18899)); + let opts = ProfileOptions::default() + .with_workspace(s.path()) + .with_proxy(proxy); + let probe = "use Socket; my $p = shift; \ + for my $t (SOCK_STREAM, SOCK_DGRAM) { \ + socket(my $s, PF_UNIX, $t, 0) or next; \ + exit 0 if connect($s, sockaddr_un($p)); \ + } exit 1"; + for sock in airlock_broker::profile::PROXY_UNIX_SOCKET_LITERALS { + if !Path::new(sock).exists() { + continue; + } + assert!( + run_under_sandbox_with(opts.clone(), &policy, &[PERL, "-e", probe, sock]), + "허용 목록의 {sock} 에 연결되지 않음. 리터럴 방출이 깨졌거나 경로가 vnode 경로와 다름" + ); + } +} + // ---------- exec 화이트리스트 ---------- fn whitelist_policy(scratch: &Path, rules: &str) -> Policy { diff --git a/crates/airlock-broker/tests/landlock_enforce.rs b/crates/airlock-broker/tests/landlock_enforce.rs index 95408dc..028a8e7 100644 --- a/crates/airlock-broker/tests/landlock_enforce.rs +++ b/crates/airlock-broker/tests/landlock_enforce.rs @@ -635,3 +635,63 @@ fn the_whitelist_declares_what_it_cannot_reach() { "화이트리스트가 걸린 상태에서 미강제라고 보고함: {gaps:?}" ); } + +/// 부모가 이 이름으로 자기 자신을 다시 실행할 때만 동작하는 탐침입니다. +/// +/// 환경 변수가 없으면 아무 일도 하지 않는 빈 테스트입니다. 있으면 fd 0 에 `TIOCSTI` 를 +/// 부르고 그 errno 를 종료 코드로 돌려줍니다. fd 0 은 부모가 `/dev/null` 로 두므로 +/// 필터가 없으면 커널이 `ENOTTY` 를, 필터가 있으면 seccomp 가 `EPERM` 을 돌려줍니다. +/// 둘이 구분되므로 어느 커널의 `dev.tty.legacy_tiocsti` 값과도 무관하게 판정됩니다 +#[test] +fn tiocsti_probe() { + if std::env::var_os("AIRLOCK_TEST_TIOCSTI_PROBE").is_none() { + return; + } + let byte = b'y'; + // # Safety + // fd 0 은 열려 있고 인자는 한 바이트 버퍼의 포인터입니다 + let rc = unsafe { libc::ioctl(0, libc::TIOCSTI, std::ptr::addr_of!(byte)) }; + let code = if rc == 0 { + 0 + } else { + std::io::Error::last_os_error().raw_os_error().unwrap_or(99) + }; + std::process::exit(code); +} + +fn tiocsti_under_sandbox(tag: &str, request: &str) -> Option { + let s = Scratch::new(tag); + let me = std::env::current_exe().unwrap(); + // 작업 공간 밖의 target/ 를 통째로 열지 않도록 탐침 바이너리를 작업 공간 안으로 복사합니다 + let probe = s.path().join("probe"); + fs::copy(&me, &probe).unwrap(); + + let policy = permissive_policy(s.path()); + let opts = ProfileOptions::default().with_workspace(s.path()); + let mut enforcer = LandlockEnforcer::new().with_options(opts); + enforcer.prepare(&policy).unwrap(); + + let mut cmd = Command::new(&probe); + cmd.args(["tiocsti_probe", "--exact", "--test-threads=1"]) + .env(request, "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + enforcer.wrap(&mut cmd).unwrap(); + cmd.status().ok().and_then(|st| st.code()) +} + +/// 중계가 꺼진 상태(`run` 을 거치지 않는 순수 강제 층)에서도 TIOCSTI 는 EPERM 이어야 합니다 +#[test] +fn tiocsti_is_refused_even_without_mediation() { + if skip_if_unsupported() { + return; + } + let code = tiocsti_under_sandbox("tiocsti", "AIRLOCK_TEST_TIOCSTI_PROBE"); + assert_eq!( + code, + Some(libc::EPERM), + "TIOCSTI 가 seccomp 에서 EPERM 으로 거부되지 않음 (ENOTTY={} 면 필터가 빠진 것)", + libc::ENOTTY + ); +} diff --git a/crates/airlock-broker/tests/mediation.rs b/crates/airlock-broker/tests/mediation.rs index 0713b0c..3c85569 100644 --- a/crates/airlock-broker/tests/mediation.rs +++ b/crates/airlock-broker/tests/mediation.rs @@ -276,3 +276,164 @@ fn enforcement_field_is_recorded_on_mediated_entries() { "모든 엔트리가 강제 수준을 달고 있어야 함" ); } + +fn file_decisions( + entries: &[airlock_audit::Entry], +) -> Vec<(String, String, airlock_audit::Decision)> { + entries + .iter() + .filter_map(|e| match &e.event { + Event::FileAccess { + path_requested, + mode, + .. + } => Some(( + path_requested.clone(), + mode.as_str().to_string(), + e.decision, + )), + _ => None, + }) + .collect() +} + +/// `--mediate full` 은 rename 의 원본을 `delete` 로 판정합니다. 이름 변경은 openat 을 거치지 +/// 않으므로 이것이 없으면 삭제 금지 규칙이 `mv` 한 번에 비껴갑니다 +#[test] +fn full_mediation_refuses_a_rename_whose_source_may_not_be_deleted() { + let s = Scratch::new("rename-deny"); + let keep = s.path().join("keep.txt"); + let moved = s.path().join("moved.txt"); + fs::write(&keep, b"stay\n").unwrap(); + + let policy = policy_from( + s.path(), + &format!( + r#" +[[rules]] +id = "keep-name" +kind = "file" +path = "{}" +mode = ["delete"] +action = "deny" +reason = "테스트" +"#, + keep.display() + ), + ); + let (_, entries) = run_it( + s.path(), + policy, + Mediation::Full, + "/bin/mv", + &[keep.to_str().unwrap(), moved.to_str().unwrap()], + ); + + assert!(keep.exists(), "삭제 금지 파일이 rename 으로 사라짐"); + assert!(!moved.exists(), "삭제 금지 파일이 새 이름으로 나타남"); + let decisions = file_decisions(&entries); + assert!( + decisions.iter().any(|(p, mode, d)| p.ends_with("keep.txt") + && mode == "delete" + && *d == airlock_audit::Decision::Deny), + "원본에 대한 delete 거부가 감사에 남지 않음: {decisions:?}" + ); +} + +/// 정책 파일 후보는 아직 없어도 생성이 거부되어야 합니다. Landlock 은 없는 inode 에 규칙을 +/// 걸 수 없으므로 이 층이 rename 의 목적지를 `create` 로 판정해 막습니다 +#[test] +fn full_mediation_refuses_planting_a_policy_file_by_rename() { + let s = Scratch::new("plant"); + let evil = s.path().join("evil.toml"); + let planted = s.path().join("airlock.toml"); + fs::write(&evil, b"version = 1\n").unwrap(); + + let ctx = + LoadContext::new(s.path(), s.path().join("audit")).with_policy_files([planted.clone()]); + let src = r#" +version = 1 +name = "mediation-test" +[defaults] +file = "allow" +exec = "allow" +egress = "deny" +"#; + let policy = Policy::load_str(src, &ctx).unwrap(); + let (_, entries) = run_it( + s.path(), + policy, + Mediation::Full, + "/bin/mv", + &[evil.to_str().unwrap(), planted.to_str().unwrap()], + ); + + assert!(!planted.exists(), "에이전트가 rename 으로 정책 파일을 심음"); + assert!(evil.exists(), "거부된 rename 이 원본을 없애면 안 됨"); + let decisions = file_decisions(&entries); + assert!( + decisions + .iter() + .any(|(p, mode, d)| p.ends_with("airlock.toml") + && mode == "create" + && *d == airlock_audit::Decision::Deny), + "목적지에 대한 create 거부가 감사에 남지 않음: {decisions:?}" + ); +} + +/// 허용된 rename 은 그대로 되어야 하고 원본 delete 와 목적지 create 가 둘 다 기록됩니다 +#[test] +fn full_mediation_records_both_sides_of_an_allowed_rename() { + let s = Scratch::new("rename-ok"); + let from = s.path().join("from.txt"); + let to = s.path().join("to.txt"); + fs::write(&from, b"go\n").unwrap(); + + let policy = policy_from(s.path(), ""); + let (_, entries) = run_it( + s.path(), + policy, + Mediation::Full, + "/bin/mv", + &[from.to_str().unwrap(), to.to_str().unwrap()], + ); + + assert!(to.exists() && !from.exists(), "허용된 rename 이 되지 않음"); + let decisions = file_decisions(&entries); + assert!( + decisions + .iter() + .any(|(p, mode, _)| p.ends_with("from.txt") && mode == "delete"), + "원본 delete 판정이 없음: {decisions:?}" + ); + assert!( + decisions + .iter() + .any(|(p, mode, _)| p.ends_with("to.txt") && mode == "create"), + "목적지 create 판정이 없음: {decisions:?}" + ); +} + +/// 기본 수준은 rename 을 중계하지 않습니다. 느려지는 것은 `full` 을 고른 사람만 감수합니다 +#[test] +fn exec_net_does_not_mediate_renames() { + let s = Scratch::new("rename-execnet"); + let from = s.path().join("from.txt"); + let to = s.path().join("to.txt"); + fs::write(&from, b"go\n").unwrap(); + + let policy = policy_from(s.path(), ""); + let (_, entries) = run_it( + s.path(), + policy, + Mediation::ExecNet, + "/bin/mv", + &[from.to_str().unwrap(), to.to_str().unwrap()], + ); + + assert!(to.exists(), "exec-net 에서 rename 이 막히면 안 됨"); + assert!( + file_decisions(&entries).is_empty(), + "exec-net 은 파일 판정을 남기지 않아야 함" + ); +} diff --git a/crates/airlock-policy/src/glob.rs b/crates/airlock-policy/src/glob.rs index 68e03d9..e75474f 100644 --- a/crates/airlock-policy/src/glob.rs +++ b/crates/airlock-policy/src/glob.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use airlock_i18n::tr; use unicode_normalization::UnicodeNormalization; +use crate::path::fold_firmlink; + const MAX_DOUBLE_STARS: usize = 4; #[derive(Debug, Clone, PartialEq, Eq)] @@ -105,6 +107,14 @@ impl Pattern { } else { return Err(PatternError::NotAbsolute(raw.to_string())); }; + // 요청 경로와 같은 접기를 규칙에도 적용해야 두 표기가 하나로 수렴합니다 (4절) + let expanded: Vec = if expanded.first() == Some(&b'/') { + fold_firmlink(Path::new(std::ffi::OsStr::from_bytes(&expanded))) + .into_os_string() + .into_vec() + } else { + expanded + }; let mut segs = Vec::new(); let mut double_stars = 0usize; @@ -141,14 +151,16 @@ impl Pattern { } pub fn literal(path: &Path) -> Self { + let path = fold_firmlink(path); Self { raw: path.to_string_lossy().into_owned(), - segs: literal_segs(path), + segs: literal_segs(&path), } } pub fn literal_subtree(path: &Path) -> Self { - let mut segs = literal_segs(path); + let path = fold_firmlink(path); + let mut segs = literal_segs(&path); segs.push(Seg::DoubleStar); Self { raw: format!("{}/**", path.to_string_lossy()), @@ -223,7 +235,7 @@ impl Pattern { } let candidate = PathBuf::from(std::ffi::OsString::from_vec(prefix)); if let Ok(c) = std::fs::canonicalize(&candidate) { - break (candidate, c); + break (candidate, fold_firmlink(&c)); } taken -= 1; }; diff --git a/crates/airlock-policy/src/path.rs b/crates/airlock-policy/src/path.rs index 2775e23..250ab41 100644 --- a/crates/airlock-policy/src/path.rs +++ b/crates/airlock-policy/src/path.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::ffi::{OsStr, OsString}; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; @@ -134,34 +135,77 @@ pub fn lexical_clean(path: &Path) -> PathBuf { PathBuf::from(OsString::from_vec(out)) } +/// 커널이 따라가는 링크 홉 상한입니다. 넘으면 커널은 ELOOP 를 돌려줍니다 +const MAX_SYMLINK_HOPS: usize = 40; + +fn segments(path: &Path) -> Vec { + path.as_os_str() + .as_bytes() + .split(|b| *b == b'/') + .filter(|s| !s.is_empty() && *s != b".") + .map(|s| OsString::from_vec(s.to_vec())) + .collect() +} + +/// 심볼릭 링크를 커널과 같은 순서로 해소합니다. +/// +/// 경로가 통째로 존재하면 `canonicalize` 가 곧 커널의 답입니다. 존재하지 않으면(매달린 +/// 링크, 아직 만들어지지 않은 꼬리) 루트부터 한 구성 요소씩 걷습니다. 링크를 만나면 그 +/// 대상을 남은 구성 요소 앞에 끼워 넣고, 상대 대상은 **해소된** 부모에 붙입니다. 어휘적 +/// 부모에 붙이면 `ws/dir -> /out/dir`, `/out/dir/x -> ../id_rsa` 에서 커널은 +/// `/out/id_rsa` 를 여는데 엔진은 `ws/id_rsa` 를 판정합니다 (4절). `..` 는 그 시점까지 +/// 해소된 경로에서 한 단계 올라가며, 없는 구성 요소부터는 나머지를 어휘적으로 붙입니다. fn resolve_symlinks(absolute: &Path) -> PathBuf { if let Ok(p) = std::fs::canonicalize(absolute) { return p; } - // 존재하지 않는 경로는 존재하는 최장 접두를 해소하고 나머지를 어휘적으로 붙입니다. - // 꼬리에 `..`가 섞여 있어도 접두 해소를 포기하지 않아야 합니다. 포기하면 - // `link/없는것/../x`에서 link가 해소되지 않아 커널과 다른 경로를 판정합니다 (4절) - let mut suffix: Vec = Vec::new(); - let mut cur: &Path = absolute; - while let Some(parent) = cur.parent() { - let name = cur - .file_name() - .map(|n| n.to_os_string()) - .unwrap_or_else(|| OsString::from("..")); - suffix.push(name); - if let Ok(base) = std::fs::canonicalize(parent) { - let mut out = base; - for n in suffix.iter().rev() { - out.push(n); - } - return out; + walk_symlinks(absolute) +} + +fn walk_symlinks(absolute: &Path) -> PathBuf { + let mut pending: VecDeque = segments(absolute).into(); + let mut resolved = PathBuf::from("/"); + let mut hops = 0usize; + + // 존재하는 접두는 디스크에 적힌 대소문자로 맞춥니다. 대소문자를 구분하지 않는 볼륨에서 + // 요청 표기와 실제 표기가 다르면 그 차이가 감사에 남아야 합니다 + let append_rest = |existing: PathBuf, seg: &OsStr, rest: VecDeque| { + let mut out = std::fs::canonicalize(&existing).unwrap_or(existing); + out.push(seg); + for s in rest { + out.push(s); } - if parent == cur { - break; + out + }; + + while let Some(seg) = pending.pop_front() { + if seg.as_os_str() == OsStr::new("..") { + resolved.pop(); + continue; + } + let candidate = resolved.join(&seg); + let Ok(meta) = std::fs::symlink_metadata(&candidate) else { + return append_rest(resolved, &seg, pending); + }; + if !meta.file_type().is_symlink() { + resolved = candidate; + continue; + } + hops = hops.saturating_add(1); + if hops > MAX_SYMLINK_HOPS { + return append_rest(resolved, &seg, pending); + } + let Ok(target) = std::fs::read_link(&candidate) else { + return append_rest(resolved, &seg, pending); + }; + if target.is_absolute() { + resolved = PathBuf::from("/"); + } + for s in segments(&target).into_iter().rev() { + pending.push_front(s); } - cur = parent; } - absolute.to_path_buf() + resolved } /// 첫 NUL 바이트에서 경로를 자릅니다. @@ -177,36 +221,6 @@ fn truncate_at_nul(path: &Path) -> PathBuf { } } -/// 마지막 구성 요소가 심볼릭 링크면 그 대상까지 따라갑니다. -/// -/// `canonicalize` 는 대상이 없으면 실패하므로, 아직 만들어지지 않은 파일을 가리키는 링크는 -/// 해소되지 않은 채 남습니다. 그 상태로 `create` 를 판정하면 링크 경로만 보고 허용하게 되고 -/// 커널은 링크를 따라가 시크릿 자리에 파일을 만듭니다. -fn follow_dangling_link(path: &Path) -> PathBuf { - let mut cur = path.to_path_buf(); - // ELOOP 방지. 커널의 통상 상한과 같은 자리에서 멈춥니다 - for _ in 0..40 { - let Ok(meta) = std::fs::symlink_metadata(&cur) else { - return cur; - }; - if !meta.file_type().is_symlink() { - return cur; - } - let Ok(target) = std::fs::read_link(&cur) else { - return cur; - }; - cur = if target.is_absolute() { - target - } else { - match cur.parent() { - Some(parent) => lexical_clean(&parent.join(target)), - None => return cur, - } - }; - } - cur -} - pub fn normalize(raw: &Path, cwd: &Path, home: &Path) -> NormalizedPath { let raw = &truncate_at_nul(raw); let expanded = expand_tilde(raw, home); @@ -217,15 +231,211 @@ pub fn normalize(raw: &Path, cwd: &Path, home: &Path) -> NormalizedPath { p.push(expanded); p }; - let requested = lexical_clean(&absolute); - // 대상이 아직 없는 링크는 canonicalize 가 놓치므로 한 번 더 따라갑니다 - let resolved = lexical_clean(&resolve_symlinks(&follow_dangling_link(&absolute))); + let requested = fold_firmlink(&lexical_clean(&absolute)); + let resolved = fold_firmlink(&lexical_clean(&resolve_symlinks(&absolute))); NormalizedPath { requested, resolved, } } +/// macOS firmlink 의 Data 볼륨 표기를 루트 표기로 접습니다. +/// +/// `/Users/x/.ssh` 와 `/System/Volumes/Data/Users/x/.ssh` 는 같은 디렉토리인데 +/// `canonicalize` 는 firmlink 를 넘지 않으므로 후자를 후자로 돌려줍니다. 그대로 두면 +/// `~/.ssh/**` forbid 를 비롯한 전 티어가 후자를 놓칩니다. 요청 경로와 규칙 경로 양쪽에 +/// 같은 접기를 적용해 두 표기가 하나로 수렴하게 합니다. firmlink 표에 있는 대상에만 +/// 적용하며 다른 플랫폼에서는 아무것도 하지 않습니다. +#[cfg(target_os = "macos")] +pub fn fold_firmlink(path: &Path) -> PathBuf { + firmlink::fold(path, firmlink::table()) +} + +#[cfg(not(target_os = "macos"))] +pub fn fold_firmlink(path: &Path) -> PathBuf { + path.to_path_buf() +} + +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod firmlink { + use std::ffi::OsString; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + use std::path::{Path, PathBuf}; + + pub const TABLE_PATH: &str = "/usr/share/firmlinks"; + const DATA_VOLUME: &[&[u8]] = &[b"System", b"Volumes", b"Data"]; + const MAX_TABLE_BYTES: u64 = 64 * 1024; + + /// macOS 10.15 이후 시스템 볼륨의 기본 firmlink 목록입니다. 표를 읽지 못할 때만 씁니다 + const BUILTIN: &[(&str, &str)] = &[ + ("/AppleInternal", "AppleInternal"), + ("/Applications", "Applications"), + ("/Library", "Library"), + ("/System/Library/Caches", "System/Library/Caches"), + ("/System/Library/Assets", "System/Library/Assets"), + ( + "/System/Library/PreinstalledAssets", + "System/Library/PreinstalledAssets", + ), + ("/System/Library/AssetsV2", "System/Library/AssetsV2"), + ( + "/System/Library/PreinstalledAssetsV2", + "System/Library/PreinstalledAssetsV2", + ), + ( + "/System/Library/CoreServices/CoreTypes.bundle/Contents/Library", + "System/Library/CoreServices/CoreTypes.bundle/Contents/Library", + ), + ("/System/Library/Speech", "System/Library/Speech"), + ("/Users", "Users"), + ("/Volumes", "Volumes"), + ("/cores", "cores"), + ("/opt", "opt"), + ("/pkg", "pkg"), + ("/private", "private"), + ("/usr/local", "usr/local"), + ("/usr/libexec/cups", "usr/libexec/cups"), + ("/usr/share/snmp", "usr/share/snmp"), + ]; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct Firmlink { + pub root: Vec>, + pub data: Vec>, + } + + fn split_segments(raw: &[u8]) -> Option>> { + let mut out = Vec::new(); + for seg in raw.split(|b| *b == b'/') { + if seg.is_empty() { + continue; + } + if seg == b"." || seg == b".." || seg.contains(&0) { + return None; + } + out.push(seg.to_vec()); + } + if out.is_empty() { + return None; + } + Some(out) + } + + fn starts_with_ci(segs: &[Vec], prefix: &[&[u8]]) -> bool { + segs.len() >= prefix.len() + && segs + .iter() + .zip(prefix) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) + } + + /// 표의 한 줄을 검증합니다. 루트 열은 절대 경로, Data 열은 상대 경로여야 하며 둘 다 + /// `.`/`..`/NUL 이 없어야 합니다. 루트가 Data 볼륨 아래이면 접기가 되돌아가므로 버립니다 + pub fn parse_entry(root: &str, data: &str) -> Option { + let root = root.trim(); + let data = data.trim(); + if !root.starts_with('/') || data.starts_with('/') { + return None; + } + let root = split_segments(root.as_bytes())?; + let data = split_segments(data.as_bytes())?; + if starts_with_ci(&root, DATA_VOLUME) { + return None; + } + Some(Firmlink { root, data }) + } + + pub fn parse_table(src: &str) -> Vec { + let mut out: Vec = Vec::new(); + for line in src.lines() { + let line = line.trim_end_matches('\r'); + if line.trim().is_empty() || line.trim_start().starts_with('#') { + continue; + } + let mut cols = line.split('\t'); + let (Some(root), Some(data), None) = (cols.next(), cols.next(), cols.next()) else { + continue; + }; + if let Some(entry) = parse_entry(root, data) + && !out.contains(&entry) + { + out.push(entry); + } + } + out + } + + pub fn builtin() -> Vec { + BUILTIN + .iter() + .filter_map(|(root, data)| parse_entry(root, data)) + .collect() + } + + /// 표 파일을 출처 확인과 함께 읽습니다. root 소유이고 다른 사용자가 쓸 수 없어야 하며 + /// 심볼릭 링크면 거부합니다. 내용은 데이터로만 다룹니다 + fn load_table() -> Option> { + use std::io::Read; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(TABLE_PATH) + .ok()?; + let meta = file.metadata().ok()?; + if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 { + return None; + } + let mut src = String::new(); + file.take(MAX_TABLE_BYTES).read_to_string(&mut src).ok()?; + let parsed = parse_table(&src); + if parsed.is_empty() { + return None; + } + Some(parsed) + } + + pub fn table() -> &'static [Firmlink] { + static TABLE: std::sync::OnceLock> = std::sync::OnceLock::new(); + TABLE.get_or_init(|| load_table().unwrap_or_else(builtin)) + } + + /// `/System/Volumes/Data//rest` 를 `/rest` 로 접습니다. + /// + /// 표에 있는 대상에만 적용하며 가장 긴 대상을 고릅니다. APFS 시스템 볼륨은 대소문자를 + /// 구분하지 않으므로 접두와 대상은 ASCII 대소문자를 무시하고 맞춥니다. 나머지는 그대로 + /// 둡니다. + pub fn fold(path: &Path, table: &[Firmlink]) -> PathBuf { + let Some(segs) = split_segments(path.as_os_str().as_bytes()) else { + return path.to_path_buf(); + }; + if !starts_with_ci(&segs, DATA_VOLUME) { + return path.to_path_buf(); + } + let rest = &segs[DATA_VOLUME.len()..]; + let best = table + .iter() + .filter(|f| { + rest.len() >= f.data.len() + && f.data + .iter() + .zip(rest) + .all(|(a, b)| a.eq_ignore_ascii_case(b)) + }) + .max_by_key(|f| f.data.len()); + let Some(best) = best else { + return path.to_path_buf(); + }; + let mut out: Vec = Vec::with_capacity(path.as_os_str().len()); + for seg in best.root.iter().chain(rest[best.data.len()..].iter()) { + out.push(b'/'); + out.extend_from_slice(seg); + } + PathBuf::from(OsString::from_vec(out)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -438,4 +648,225 @@ mod tests { let n = normalize(&s.path().join("f"), &cwd(), &home()); assert!(!n.diverges()); } + + #[test] + fn relative_link_target_is_resolved_against_the_resolved_parent() { + let s = Scratch::new("rel-link"); + let outside = s.path().join("outside"); + fs::create_dir_all(outside.join("dir")).unwrap(); + fs::write(outside.join("id_rsa"), b"key").unwrap(); + std::os::unix::fs::symlink("../id_rsa", outside.join("dir/x")).unwrap(); + let ws = s.path().join("ws"); + fs::create_dir_all(&ws).unwrap(); + std::os::unix::fs::symlink(outside.join("dir"), ws.join("dir")).unwrap(); + + let n = normalize(&ws.join("dir/x"), &cwd(), &home()); + assert_eq!(n.requested, ws.join("dir/x")); + assert_eq!( + n.resolved, + outside.join("id_rsa"), + "커널은 ws/dir 를 outside/dir 로 해소한 뒤 ../id_rsa 를 붙임. 어휘적 부모에 붙이면 ws/id_rsa 가 됨" + ); + } + + #[test] + fn dangling_relative_link_is_resolved_against_the_resolved_parent() { + let s = Scratch::new("rel-dangling"); + let outside = s.path().join("outside"); + fs::create_dir_all(outside.join("dir")).unwrap(); + std::os::unix::fs::symlink("../id_rsa", outside.join("dir/x")).unwrap(); + let ws = s.path().join("ws"); + fs::create_dir_all(&ws).unwrap(); + std::os::unix::fs::symlink(outside.join("dir"), ws.join("dir")).unwrap(); + + let n = normalize(&ws.join("dir/x"), &cwd(), &home()); + assert_eq!( + n.resolved, + outside.join("id_rsa"), + "대상이 아직 없어도 해소된 부모 기준이어야 함" + ); + } + + #[test] + fn dangling_link_chain_keeps_the_resolved_parent_at_every_hop() { + let s = Scratch::new("rel-chain"); + let ws = s.path().join("ws"); + fs::create_dir_all(&ws).unwrap(); + std::os::unix::fs::symlink("b", ws.join("a")).unwrap(); + std::os::unix::fs::symlink("../outside/c", ws.join("b")).unwrap(); + + let n = normalize(&ws.join("a"), &cwd(), &home()); + assert_eq!(n.resolved, s.path().join("outside/c")); + } + + #[test] + fn dangling_link_below_a_missing_tail_is_appended_lexically() { + let s = Scratch::new("rel-tail"); + let ws = s.path().join("ws"); + fs::create_dir_all(&ws).unwrap(); + std::os::unix::fs::symlink("../elsewhere", ws.join("dir")).unwrap(); + + let n = normalize(&ws.join("dir/sub/../f"), &cwd(), &home()); + assert_eq!(n.resolved, s.path().join("elsewhere/f")); + } + + #[test] + fn symlink_loops_terminate() { + let s = Scratch::new("loop"); + std::os::unix::fs::symlink("b", s.path().join("a")).unwrap(); + std::os::unix::fs::symlink("a", s.path().join("b")).unwrap(); + + let n = normalize(&s.path().join("a/x"), &cwd(), &home()); + assert!(n.resolved.starts_with(s.path())); + } + + #[test] + fn firmlink_table_ignores_malformed_lines() { + let src = "\ +# comment +/Users\tUsers + +/private\tprivate\r +no-tab-here +/three\tcolumns\there +/dots\tUsers/../etc +/dot\t./Users +/nul\tUs\0ers +/empty\t +\tUsers +/Users\tUsers +/usr/local\tusr/local +"; + let table = firmlink::parse_table(src); + let names: Vec = table + .iter() + .map(|f| String::from_utf8_lossy(&f.data.concat()).into_owned()) + .collect(); + assert_eq!(names, vec!["Users", "private", "usrlocal"]); + assert_eq!(table[2].root, vec![b"usr".to_vec(), b"local".to_vec()]); + } + + #[test] + fn firmlink_table_rejects_entries_with_the_wrong_absoluteness() { + assert!( + firmlink::parse_entry("Users", "Users").is_none(), + "루트는 절대 경로여야 함" + ); + assert!( + firmlink::parse_entry("/Users", "/Users").is_none(), + "Data 열은 상대 경로여야 함" + ); + assert!( + firmlink::parse_entry("/", "Users").is_none(), + "루트가 / 이면 접기가 무의미함" + ); + assert!( + firmlink::parse_entry("/System/Volumes/Data/Users", "Users").is_none(), + "루트가 Data 볼륨 아래이면 접기가 되돌아감" + ); + assert!(firmlink::parse_entry("/Users", "Users").is_some()); + assert!(firmlink::parse_entry(" /Users ", " Users ").is_some()); + } + + #[test] + fn builtin_firmlink_table_is_valid_and_covers_home() { + let table = firmlink::builtin(); + assert!(table.len() >= 19); + assert!(table.iter().any(|f| f.data == vec![b"Users".to_vec()])); + assert!(table.iter().any(|f| f.data == vec![b"private".to_vec()])); + } + + fn sample_table() -> Vec { + firmlink::parse_table("/Users\tUsers\n/usr/local\tusr/local\n/alias\tdata/real\n") + } + + #[test] + fn firmlink_fold_rewrites_only_listed_targets() { + let t = sample_table(); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data/Users/me/.ssh/id_rsa"), &t), + PathBuf::from("/Users/me/.ssh/id_rsa") + ); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data/Users"), &t), + PathBuf::from("/Users") + ); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data/Usersx/me"), &t), + PathBuf::from("/System/Volumes/Data/Usersx/me"), + "세그먼트 경계를 지켜야 함" + ); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data/etc/shadow"), &t), + PathBuf::from("/System/Volumes/Data/etc/shadow"), + "표에 없는 대상은 그대로" + ); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data"), &t), + PathBuf::from("/System/Volumes/Data") + ); + assert_eq!( + firmlink::fold(Path::new("/Users/me/.ssh/id_rsa"), &t), + PathBuf::from("/Users/me/.ssh/id_rsa") + ); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data/data/real/x"), &t), + PathBuf::from("/alias/x"), + "루트 열과 Data 열이 다르면 표대로 옮겨야 함" + ); + } + + #[test] + fn firmlink_fold_prefers_the_longest_target_and_ignores_ascii_case() { + let t = firmlink::parse_table("/usr\tusr\n/usr/local\tusr/local\n"); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data/usr/local/bin/x"), &t), + PathBuf::from("/usr/local/bin/x") + ); + assert_eq!( + firmlink::fold(Path::new("/system/volumes/data/USR/Local/bin/x"), &t), + PathBuf::from("/usr/local/bin/x") + ); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data/usr/bin/x"), &t), + PathBuf::from("/usr/bin/x") + ); + } + + #[test] + fn firmlink_fold_leaves_dot_segments_alone() { + let t = sample_table(); + assert_eq!( + firmlink::fold(Path::new("/System/Volumes/Data/Users/../etc"), &t), + PathBuf::from("/System/Volumes/Data/Users/../etc"), + "접기는 어휘적 정리 뒤에 오므로 .. 가 남은 경로는 건드리지 않음" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn data_volume_spelling_of_home_is_folded_on_macos() { + assert_eq!( + fold_firmlink(Path::new("/System/Volumes/Data/Users/me/.ssh/id_rsa")), + PathBuf::from("/Users/me/.ssh/id_rsa") + ); + assert_eq!( + fold_firmlink(Path::new("/System/Volumes/Data/private/etc/sudoers")), + PathBuf::from("/private/etc/sudoers") + ); + let n = normalize( + Path::new("/System/Volumes/Data/Users/me/.ssh/id_rsa"), + &cwd(), + &home(), + ); + assert_eq!(n.requested, PathBuf::from("/Users/me/.ssh/id_rsa")); + assert_eq!(n.resolved, PathBuf::from("/Users/me/.ssh/id_rsa")); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn fold_is_identity_off_macos() { + let p = Path::new("/System/Volumes/Data/Users/me/.ssh/id_rsa"); + assert_eq!(fold_firmlink(p), p.to_path_buf()); + } } diff --git a/crates/airlock-policy/tests/bypass.rs b/crates/airlock-policy/tests/bypass.rs index 58ee8bf..f3f0aa1 100644 --- a/crates/airlock-policy/tests/bypass.rs +++ b/crates/airlock-policy/tests/bypass.rs @@ -2216,6 +2216,284 @@ action = "allow" ); } +// ---------- 심볼릭 링크된 디렉토리 아래의 상대 링크 (H3) ---------- + +#[test] +fn relative_link_under_a_symlinked_directory_resolves_against_the_link_target() { + // work/dir 은 .ssh/sub 로 가는 디렉토리 링크이고 .ssh/sub/x 는 ../id_ed25519 를 + // 가리킵니다. 커널은 .ssh/sub 를 먼저 해소한 뒤 .. 를 적용해 .ssh/id_ed25519 를 + // 엽니다. 어휘적 부모(work/dir)에 .. 를 붙이면 work/id_ed25519 가 되어 작업 공간 + // 규칙으로 allow 됩니다 + let h = Home::new("rel-link-in-linked-dir"); + let key = h.make_secret(); + let sub = h.join(".ssh/sub"); + fs::create_dir_all(&sub).unwrap(); + std::os::unix::fs::symlink("../id_ed25519", sub.join("x")).unwrap(); + let ws = h.join("work"); + fs::create_dir_all(&ws).unwrap(); + std::os::unix::fs::symlink(&sub, ws.join("dir")).unwrap(); + + let src = format!( + r#" +version = 1 +[defaults] +file = "deny" +[[rules]] +id = "workspace" +kind = "file" +path = "{}/work/**" +action = "allow" +"#, + h.path().display() + ); + let p = Policy::load_str(&src, &h.ctx()).unwrap(); + let ev = p.evaluate_file(&ws.join("dir/x"), FileMode::Read, h.path()); + let np = ev.path.clone().unwrap(); + assert_eq!( + np.resolved, key, + "상대 링크 타겟은 해소된 부모(.ssh/sub)를 기준으로 붙여야 함" + ); + assert_eq!( + ev.action, + Action::Forbid, + "링크 두 단계를 거쳐 도달하는 개인키가 작업 공간 allow 로 열리면 안 됨" + ); +} + +#[test] +fn relative_link_under_a_symlinked_directory_lands_outside_the_workspace() { + // 시크릿이 아니어도 작업 공간 밖이면 기본값(deny)으로 떨어져야 합니다 + let h = Home::new("rel-link-outside"); + let outside = h.join("outside"); + fs::create_dir_all(outside.join("dir")).unwrap(); + fs::write(outside.join("id_rsa"), b"key").unwrap(); + std::os::unix::fs::symlink("../id_rsa", outside.join("dir/x")).unwrap(); + let ws = h.join("work"); + fs::create_dir_all(&ws).unwrap(); + std::os::unix::fs::symlink(outside.join("dir"), ws.join("dir")).unwrap(); + + let src = format!( + r#" +version = 1 +[defaults] +file = "deny" +[[rules]] +id = "workspace" +kind = "file" +path = "{}/work/**" +action = "allow" +"#, + h.path().display() + ); + let p = Policy::load_str(&src, &h.ctx()).unwrap(); + let ev = p.evaluate_file(&ws.join("dir/x"), FileMode::Read, h.path()); + assert_eq!(ev.path.clone().unwrap().resolved, outside.join("id_rsa")); + assert_eq!( + ev.action, + Action::Deny, + "커널이 여는 outside/id_rsa 는 작업 공간 밖이므로 기본값이 적용되어야 함" + ); +} + +#[test] +fn dangling_relative_link_resolves_against_the_resolved_parent() { + // 대상이 아직 없는 상대 링크도 해소된 부모 기준입니다. create 는 대상이 없는 것이 + // 정상이므로 여기서 틀리면 시크릿 자리에 파일이 만들어집니다 + let h = Home::new("dangling-rel-link"); + let ssh = h.join(".ssh"); + let sub = ssh.join("sub"); + fs::create_dir_all(&sub).unwrap(); + std::os::unix::fs::symlink("../authorized_keys", sub.join("ak")).unwrap(); + let ws = h.join("work"); + fs::create_dir_all(&ws).unwrap(); + std::os::unix::fs::symlink(&sub, ws.join("dir")).unwrap(); + + let src = format!( + r#" +version = 1 +[defaults] +file = "deny" +[[rules]] +id = "workspace" +kind = "file" +path = "{}/work/**" +action = "allow" +"#, + h.path().display() + ); + let p = Policy::load_str(&src, &h.ctx()).unwrap(); + let raw = ws.join("dir/ak"); + for m in [FileMode::Create, FileMode::Write, FileMode::Read] { + let ev = p.evaluate_file(&raw, m, h.path()); + assert_eq!( + ev.path.clone().unwrap().resolved, + ssh.join("authorized_keys"), + "{m:?}: 매달린 상대 링크는 해소된 부모(.ssh/sub) 기준으로 계산되어야 함" + ); + assert_eq!( + ev.action, + Action::Forbid, + "{m:?}: 매달린 링크를 따라가면 ~/.ssh 이므로 forbid 여야 함" + ); + } +} + +// ---------- macOS firmlink Data 볼륨 표기 (H5) ---------- + +#[cfg(target_os = "macos")] +fn data_volume_spelling(path: &Path) -> Option { + if !Path::new("/System/Volumes/Data").is_dir() { + return None; + } + let rel = path.strip_prefix("/").ok()?; + let spelled = Path::new("/System/Volumes/Data").join(rel); + if !spelled.exists() { + return None; + } + Some(spelled) +} + +#[cfg(target_os = "macos")] +#[test] +fn data_volume_spelling_of_a_secret_hits_the_baseline_forbid() { + // /Users/x/.ssh/id_rsa 와 /System/Volumes/Data/Users/x/.ssh/id_rsa 는 firmlink 로 + // 같은 파일입니다. canonicalize 는 후자를 후자로 돌려주므로 정규화가 접두를 벗기지 + // 않으면 ~/.ssh/** forbid 가 통째로 비켜 갑니다 + let h = Home::new("data-volume-secret"); + let key = h.make_secret(); + let Some(spelled) = data_volume_spelling(&key) else { + return; + }; + + let p = Policy::baseline_only(&h.ctx()).unwrap(); + let ev = p.evaluate_file(&spelled, FileMode::Read, h.path()); + assert_eq!( + ev.path.clone().unwrap().resolved, + key, + "Data 볼륨 표기는 firmlink 루트 표기로 수렴해야 함" + ); + assert_eq!( + ev.action, + Action::Forbid, + "Data 볼륨 표기의 개인키가 baseline forbid 에 걸려야 함" + ); + assert_eq!( + ev.rule.as_ref().map(|r| r.id.as_str()), + Some("ssh-private-keys") + ); + + // 아직 없는 파일(create)도 접두가 벗겨져야 합니다 + let fresh = + Path::new("/System/Volumes/Data").join(h.join(".ssh/new_key").strip_prefix("/").unwrap()); + let ev = p.evaluate_file(&fresh, FileMode::Create, h.path()); + assert_eq!(ev.action, Action::Forbid); +} + +#[cfg(target_os = "macos")] +#[test] +fn data_volume_spelling_of_a_self_protect_candidate_is_tier_zero() { + // 자기보호 후보가 Data 표기로 들어와도, 요청이 어느 표기로 오든 티어 0 이 잡아야 합니다 + let h = Home::new("data-volume-tier0"); + let ws = h.join("work"); + fs::create_dir_all(&ws).unwrap(); + let Some(ws_spelled) = data_volume_spelling(&ws) else { + return; + }; + let planted = ws.join("airlock.toml"); + let planted_spelled = ws_spelled.join("airlock.toml"); + + let ctx = LoadContext::new(h.path(), h.join(".local/share/airlock")) + .with_policy_files(vec![planted_spelled.clone()]); + let p = Policy::baseline_only(&ctx).unwrap(); + + for raw in [&planted, &planted_spelled] { + for m in [FileMode::Write, FileMode::Create, FileMode::Delete] { + let ev = p.evaluate_file(raw, m, h.path()); + assert!( + ev.action.is_restrictive(), + "{}: {m:?}로 정책을 심을 수 있으면 안 됨", + raw.display() + ); + assert_eq!( + ev.rule.as_ref().map(|r| r.tier), + Some(Tier::SelfProtect), + "{}: 자기보호 티어가 잡아야 함", + raw.display() + ); + } + } + + // 감사 로그 루트도 마찬가지입니다 + let chain_spelled = Path::new("/System/Volumes/Data").join( + h.join(".local/share/airlock/sessions/a/chain.jsonl") + .strip_prefix("/") + .unwrap(), + ); + let ev = p.evaluate_file(&chain_spelled, FileMode::Write, h.path()); + assert_eq!(ev.rule.as_ref().map(|r| r.tier), Some(Tier::SelfProtect)); +} + +#[cfg(target_os = "macos")] +#[test] +fn data_volume_spelling_in_a_user_rule_converges_with_the_root_spelling() { + // 규칙 경로에도 같은 정규화가 적용되어 두 표기가 하나로 수렴해야 합니다 + let h = Home::new("data-volume-rule"); + let ws = h.join("work"); + fs::create_dir_all(&ws).unwrap(); + fs::write(ws.join("f"), b"x").unwrap(); + let Some(ws_spelled) = data_volume_spelling(&ws) else { + return; + }; + + let src = format!( + r#" +version = 1 +[defaults] +file = "deny" +[[rules]] +id = "workspace" +kind = "file" +path = "{}/**" +action = "allow" +"#, + ws_spelled.display() + ); + let p = Policy::load_str(&src, &h.ctx()).unwrap(); + assert_eq!( + p.evaluate_file(&ws.join("f"), FileMode::Read, h.path()) + .action, + Action::Allow, + "Data 표기로 적은 allow 가 루트 표기 요청에 걸려야 함" + ); + assert_eq!( + p.evaluate_file(&ws_spelled.join("f"), FileMode::Read, h.path()) + .action, + Action::Allow, + "Data 표기로 적은 allow 가 Data 표기 요청에도 걸려야 함" + ); + + let src = format!( + r#" +version = 1 +[defaults] +file = "deny" +[[rules]] +id = "workspace" +kind = "file" +path = "{}/**" +action = "allow" +"#, + ws.display() + ); + let p = Policy::load_str(&src, &h.ctx()).unwrap(); + assert_eq!( + p.evaluate_file(&ws_spelled.join("f"), FileMode::Read, h.path()) + .action, + Action::Allow, + "루트 표기로 적은 allow 가 Data 표기 요청에 걸려야 함" + ); +} + // ---------- 총량 한도 (max_bytes_out) ---------- fn quota_policy(h: &Home, limit: u64) -> Policy { diff --git a/crates/airlock-proxy/src/http.rs b/crates/airlock-proxy/src/http.rs index bb53a88..eda5c86 100644 --- a/crates/airlock-proxy/src/http.rs +++ b/crates/airlock-proxy/src/http.rs @@ -80,13 +80,22 @@ impl Request { /// /// # Errors /// 규격을 벗어나거나 상한을 넘으면 [`Reject`]를 냅니다. 호출부는 그 상태 라인을 -/// 그대로 응답하고 연결을 닫아야 합니다 +/// 그대로 응답하고 연결을 닫아야 합니다. CRLF 쌍이 아닌 개행, 제어 문자, obs-fold, +/// 둘 이상의 Host, `HTTP/1.0` 과 `HTTP/1.1` 외의 버전, 비ASCII 호스트는 전부 400 입니다 pub fn parse_head(head: &[u8]) -> Result { if head.len() > MAX_HEADERS { return Err(Reject::TooLarge); } + // 짝이 아닌 CR 이나 LF 가 하나라도 있으면 앞뒤 서버가 줄 경계를 다르게 봅니다 + // 이 검사를 지나면 어떤 줄에도 CR 과 LF 가 남지 않습니다 + if !crlf_pairs_only(head) { + return Err(Reject::Malformed); + } let text = std::str::from_utf8(head).map_err(|_| Reject::Malformed)?; - let mut lines = text.split("\r\n"); + // 헤드는 빈 줄 하나로 끝나야 합니다 + // 읽기 층이 첫 빈 줄에서 끊으므로 그 앞에 빈 줄이 또 있으면 두 층이 다른 헤드를 봅니다 + let body = text.strip_suffix("\r\n\r\n").ok_or(Reject::Malformed)?; + let mut lines = body.split("\r\n"); let request_line = lines.next().ok_or(Reject::Malformed)?; if request_line.len() > MAX_REQUEST_LINE { return Err(Reject::TooLarge); @@ -102,24 +111,18 @@ pub fn parse_head(head: &[u8]) -> Result { if parts.next().is_some() { return Err(Reject::Malformed); } - if !is_token(method) || !version.starts_with("HTTP/1.") { + // 버전 문자열은 그대로 재방출되므로 정확히 아는 둘만 받습니다 + if !is_token(method) || !valid_request_target(raw_target) || !is_supported_version(version) { return Err(Reject::Malformed); } + // 터널의 헤더는 목적지로 넘기지 않지만 형태는 똑같이 확인합니다 + // 규격을 벗어난 헤드를 받아 주면 이 프록시가 무엇을 받는지의 기준이 요청 종류마다 + // 달라지고 그 차이가 곧 앞뒤 서버의 해석 차이가 됩니다 + let headers = parse_headers(lines)?; + if method == "CONNECT" { let target = split_authority(raw_target, None)?; - // 터널의 헤더는 목적지로 넘기지 않지만 형태는 그대로 확인합니다. 규격을 - // 벗어난 헤드를 받아 주면 이 프록시가 무엇을 받는지의 기준이 요청 종류마다 - // 달라지고, 그 차이가 곧 앞뒤 서버의 해석 차이가 됩니다 - for line in lines { - if line.is_empty() { - break; - } - let (name, _) = line.split_once(':').ok_or(Reject::Malformed)?; - if !is_token(name) { - return Err(Reject::Malformed); - } - } return Ok(Request::Connect(target)); } @@ -140,20 +143,13 @@ pub fn parse_head(head: &[u8]) -> Result { out.push_str(&target.to_string()); out.push_str("\r\n"); - for line in lines { - if line.is_empty() { - break; - } - let (name, _) = line.split_once(':').ok_or(Reject::Malformed)?; - // 이름과 콜론 사이의 공백은 앞뒤 서버가 다르게 읽는 대표적인 스머글링 - // 벡터입니다. 이름 자체가 토큰이 아닌 경우도 같이 걸립니다 - if !is_token(name) { - return Err(Reject::Malformed); - } + for (name, value) in headers { if DROPPED_HEADERS.contains(&name.to_ascii_lowercase().as_str()) { continue; } - out.push_str(line); + out.push_str(name); + out.push(':'); + out.push_str(value); out.push_str("\r\n"); } // 요청 하나마다 연결을 닫아 파이프라인된 뒷 요청이 판정 없이 같은 연결로 @@ -166,6 +162,79 @@ pub fn parse_head(head: &[u8]) -> Result { }) } +/// 모든 CR 과 LF 가 CRLF 쌍의 일부인지 봅니다. +/// +/// 홀로 선 LF 를 줄 끝으로 읽는 서버와 그렇지 않은 서버가 있어, 쌍이 아닌 개행은 +/// 그 자체로 앞뒤 서버의 헤더 경계를 갈라놓는 스머글링 벡터입니다 +fn crlf_pairs_only(head: &[u8]) -> bool { + let mut after_cr = false; + for &b in head { + match (b, after_cr) { + (b'\n', true) => after_cr = false, + (b'\n', false) | (b'\r', true) => return false, + (b'\r', false) => after_cr = true, + (_, true) => return false, + (_, false) => {} + } + } + !after_cr +} + +/// 헤더 줄들을 이름과 값으로 가릅니다. +/// +/// 이름은 RFC 9110 token 이어야 하고 값에는 HTAB 외의 제어 문자가 없어야 합니다. +/// obs-fold 와 빈 줄과 둘 이상의 Host 는 거부합니다 (RFC 9112 3.2, 5.2) +/// +/// # Errors +/// 위 규칙 중 하나라도 어긋나면 [`Reject::Malformed`] 입니다 +fn parse_headers<'a>( + lines: impl Iterator, +) -> Result, Reject> { + let mut out = Vec::new(); + let mut host_seen = false; + for line in lines { + // 빈 줄이 여기 오면 헤드 안에 빈 줄이 둘이라는 뜻이고 + // SP 나 HTAB 로 시작하는 줄은 obs-fold 입니다 + // 둘 다 앞뒤 서버가 헤더 경계를 다르게 읽습니다 + if line.is_empty() || line.starts_with([' ', '\t']) { + return Err(Reject::Malformed); + } + let (name, value) = line.split_once(':').ok_or(Reject::Malformed)?; + // 이름과 콜론 사이의 공백은 앞뒤 서버가 다르게 읽는 대표적인 스머글링 벡터입니다 + // 이름 자체가 토큰이 아닌 경우도 같이 걸립니다 + if !is_token(name) || !valid_field_value(value) { + return Err(Reject::Malformed); + } + if name.eq_ignore_ascii_case("host") { + if host_seen { + return Err(Reject::Malformed); + } + host_seen = true; + } + out.push((name, value)); + } + Ok(out) +} + +fn is_supported_version(version: &str) -> bool { + matches!(version, "HTTP/1.0" | "HTTP/1.1") +} + +/// request-target 에 제어 문자가 없는지 봅니다. +/// +/// HTAB 도 거부합니다. 요청 라인에서 HTAB 를 공백으로 읽는 서버가 있어 목적지가 +/// 어디서 끝나는지가 갈립니다 +fn valid_request_target(target: &str) -> bool { + !target.is_empty() && target.bytes().all(|b| b > 0x20 && b != 0x7f) +} + +/// field-value 에 HTAB 외의 제어 문자가 없는지 봅니다 (RFC 9110 5.5). +fn valid_field_value(value: &str) -> bool { + value + .bytes() + .all(|b| b == b'\t' || (b >= 0x20 && b != 0x7f)) +} + /// `http://`만 벗겨 냅니다. /// /// `https://`를 받지 않는 이유는 그 경우 프록시가 TLS를 직접 맺어야 하는데 v1이 @@ -229,17 +298,15 @@ fn split_authority(raw: &str, default_port: Option) -> Result bool { if host.is_empty() || host.len() > MAX_HOST { return false; } - host.bytes().all(|b| { - !b.is_ascii_control() - && !b.is_ascii_whitespace() - && !matches!(b, b'/' | b'\\' | b'@' | b'[' | b']' | b'#' | b'?' | b'%') - }) + host.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b':')) } fn is_token(s: &str) -> bool { @@ -425,4 +492,173 @@ mod tests { assert!(Reject::NoTarget.status_line().contains("400")); assert!(Reject::TooLarge.status_line().contains("431")); } + + #[test] + fn a_bare_lf_cannot_inject_a_second_host_header() { + // CRLF 로만 자르면 줄 안의 LF 가 값의 일부로 남고 업스트림은 두 번째 Host 를 봅니다 + let h = b"GET http://example.com/x HTTP/1.1\r\nX-A: x\nHost: evil.test\r\n\r\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + } + + #[test] + fn a_bare_lf_in_a_connect_head_is_refused() { + let h = b"CONNECT example.com:443 HTTP/1.1\r\nX-A: x\nHost: evil.test\r\n\r\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + } + + #[test] + fn a_bare_lf_in_the_request_target_is_refused() { + // 공백 수는 셋이라 요청 라인 검사는 지나지만 경로에 개행이 들어가 재방출됩니다 + let h = b"GET http://example.com/x?a=b\nX:y HTTP/1.1\r\n\r\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + } + + #[test] + fn a_bare_cr_is_refused() { + let h = b"GET http://example.com/x HTTP/1.1\r\nX-A: x\rHost: evil.test\r\n\r\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + let h = b"GET http://example.com/x HTTP/1.1\r\r\nX-A: x\r\n\r\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + } + + #[test] + fn lf_only_line_endings_are_refused() { + let h = b"GET http://example.com/x HTTP/1.1\nHost: example.com\n\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + } + + #[test] + fn a_folded_line_with_a_colon_is_still_refused() { + // 접힌 줄에 콜론이 있어도 SP 나 HTAB 로 시작하면 obs-fold 입니다 (RFC 9112 5.2) + let h = head("GET http://example.com/x HTTP/1.1\nX-A: 1\n\tHost: evil.test\n\n"); + assert_eq!(parse_head(&h), Err(Reject::Malformed)); + let h = head("GET http://example.com/x HTTP/1.1\nX-A: 1\n Host: evil.test\n\n"); + assert_eq!(parse_head(&h), Err(Reject::Malformed)); + } + + #[test] + fn duplicate_host_headers_are_refused() { + let h = head("GET http://example.com/x HTTP/1.1\nHost: example.com\nhost: evil.test\n\n"); + assert_eq!(parse_head(&h), Err(Reject::Malformed)); + } + + #[test] + fn duplicate_host_headers_on_connect_are_refused() { + let h = + head("CONNECT example.com:443 HTTP/1.1\nHost: example.com:443\nHost: evil.test\n\n"); + assert_eq!(parse_head(&h), Err(Reject::Malformed)); + } + + #[test] + fn only_http_1_0_and_1_1_are_accepted() { + for v in [ + "HTTP/1.x", + "HTTP/1.10", + "HTTP/1.", + "HTTP/1.1x", + "HTTP/2", + "HTTP/0.9", + "http/1.1", + "HTTP/1.1\t", + ] { + let h = head(&format!("CONNECT example.com:443 {v}\n\n")); + assert_eq!(parse_head(&h), Err(Reject::Malformed), "{v:?}"); + } + for v in ["HTTP/1.0", "HTTP/1.1"] { + let h = head(&format!("GET http://example.com/x {v}\n\n")); + let Request::Forward { head, .. } = parse_head(&h).expect("파싱 성공") else { + panic!("Forward 여야 함"); + }; + let text = String::from_utf8(head).expect("utf8"); + assert!(text.starts_with(&format!("GET /x {v}\r\n")), "{text}"); + } + } + + #[test] + fn control_characters_in_a_header_value_are_refused() { + for c in [ + '\u{0}', '\u{1}', '\u{8}', '\u{b}', '\u{c}', '\u{1b}', '\u{1f}', '\u{7f}', + ] { + let h = head(&format!( + "GET http://example.com/x HTTP/1.1\nX-A: a{c}b\n\n" + )); + assert_eq!(parse_head(&h), Err(Reject::Malformed), "{c:?}"); + let h = head(&format!("CONNECT example.com:443 HTTP/1.1\nX-A: a{c}b\n\n")); + assert_eq!(parse_head(&h), Err(Reject::Malformed), "{c:?}"); + } + } + + #[test] + fn a_horizontal_tab_inside_a_header_value_is_kept() { + // HTAB 는 field-value 의 정당한 공백입니다 (RFC 9110 5.5) + let h = head("GET http://example.com/x HTTP/1.1\nX-A: a\tb\n\n"); + let Request::Forward { head, .. } = parse_head(&h).expect("파싱 성공") else { + panic!("Forward 여야 함"); + }; + let text = String::from_utf8(head).expect("utf8"); + assert!(text.contains("X-A: a\tb\r\n"), "{text}"); + } + + #[test] + fn control_characters_in_the_request_target_are_refused() { + for c in ['\u{0}', '\t', '\u{1f}', '\u{7f}'] { + let h = head(&format!("GET http://example.com/x{c}y HTTP/1.1\n\n")); + assert_eq!(parse_head(&h), Err(Reject::Malformed), "{c:?}"); + let h = head(&format!("CONNECT exam{c}ple.com:443 HTTP/1.1\n\n")); + assert_eq!(parse_head(&h), Err(Reject::Malformed), "{c:?}"); + } + } + + #[test] + fn a_non_ascii_host_is_refused() { + let h = head("CONNECT 例え.jp:443 HTTP/1.1\n\n"); + assert_eq!(parse_head(&h), Err(Reject::Malformed)); + let h = head("GET http://exämple.com/x HTTP/1.1\n\n"); + assert_eq!(parse_head(&h), Err(Reject::Malformed)); + let h = head("CONNECT exa\u{a0}mple.com:443 HTTP/1.1\n\n"); + assert_eq!(parse_head(&h), Err(Reject::Malformed)); + } + + #[test] + fn hosts_are_limited_to_name_and_v6_characters() { + for bad in [ + "ex*ample.com", + "ex;ample.com", + "ex\"ample.com", + "ex(ample).com", + "ex=ample.com", + "ex~ample.com", + ] { + let h = head(&format!("CONNECT {bad}:443 HTTP/1.1\n\n")); + assert_eq!(parse_head(&h), Err(Reject::Malformed), "{bad}"); + } + for good in ["a-b_c.example.com", "127.0.0.1", "EXAMPLE.com", "[::1]"] { + let h = head(&format!("CONNECT {good}:443 HTTP/1.1\n\n")); + assert!(parse_head(&h).is_ok(), "{good}"); + } + } + + #[test] + fn the_head_must_end_with_exactly_one_blank_line() { + // 읽기 층은 첫 빈 줄에서 끊으므로 그 앞뒤에 빈 줄이 더 있으면 두 층이 다른 헤드를 봅니다 + let h = b"CONNECT example.com:443 HTTP/1.1\r\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + let h = b"CONNECT example.com:443 HTTP/1.1\r\n\r\nX: y\r\n\r\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + let h = b"GET http://example.com/x HTTP/1.1\r\n\r\nHost: evil.test\r\n\r\n"; + assert_eq!(parse_head(h), Err(Reject::Malformed)); + } + + #[test] + fn the_gated_host_is_the_host_that_is_dialed_and_recorded() { + // 정상 요청에서 판정과 접속과 기록에 쓰이는 값은 하나의 Target 입니다 + let h = head("GET http://Example.COM:8080/x HTTP/1.1\nHost: other.test\n\n"); + let r = parse_head(&h).expect("파싱 성공"); + assert_eq!(r.target(), &Target::new("Example.COM", 8080)); + let Request::Forward { head, target } = r else { + panic!("Forward 여야 함"); + }; + let text = String::from_utf8(head).expect("utf8"); + assert!(text.contains(&format!("Host: {target}\r\n")), "{text}"); + } } diff --git a/crates/airlock-proxy/src/server.rs b/crates/airlock-proxy/src/server.rs index e4a4147..c538ae2 100644 --- a/crates/airlock-proxy/src/server.rs +++ b/crates/airlock-proxy/src/server.rs @@ -489,10 +489,14 @@ mod tests { } fn send(addr: SocketAddr, req: &str) -> String { + send_raw(addr, req.replace('\n', "\r\n").as_bytes()) + } + + /// 바이트를 고치지 않고 그대로 보냅니다. 개행 조작 시험용 + fn send_raw(addr: SocketAddr, req: &[u8]) -> String { let mut s = TcpStream::connect(addr).expect("프록시 연결"); s.set_read_timeout(Some(Duration::from_secs(5))).ok(); - s.write_all(req.replace('\n', "\r\n").as_bytes()) - .expect("요청 전송"); + s.write_all(req).expect("요청 전송"); let mut out = String::new(); let mut buf = [0u8; 4096]; while let Ok(n) = s.read(&mut buf) { @@ -511,6 +515,135 @@ mod tests { resp.lines().next().unwrap_or_default().trim().to_string() } + /// 연결이 왔는지만 보는 목적지. 거부 경로에서 접속이 없음을 확인하는 용도 + fn silent_origin() -> (SocketAddr, TcpListener) { + let l = TcpListener::bind(("127.0.0.1", 0)).expect("바인드"); + l.set_nonblocking(true).expect("nonblocking"); + let addr = l.local_addr().expect("주소"); + (addr, l) + } + + fn was_dialed(l: &TcpListener) -> bool { + thread::sleep(Duration::from_millis(50)); + match l.accept() { + Ok(_) => true, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => false, + Err(e) => panic!("accept 오류 {e}"), + } + } + + /// 거부되는 헤드는 판정에 닿지 않고 접속도 기록도 남기지 않아야 합니다. + /// 세 가지가 함께 없어야 판정과 접속과 기록이 같은 사실을 말합니다 + fn assert_refused_without_side_effects(label: &str, build: impl FnOnce(u16) -> Vec) { + let (origin, l) = silent_origin(); + let gate = Recorder::new(true); + let run = start(gate.clone()); + let resp = send_raw(run.addr, &build(origin.port())); + assert_eq!(status(&resp), "HTTP/1.1 400 Bad Request", "{label}"); + assert!(gate.calls().is_empty(), "{label}: 판정에 닿으면 안 됨"); + assert!(!was_dialed(&l), "{label}: 업스트림에 접속하면 안 됨"); + assert!( + gate.finished_calls().is_empty(), + "{label}: 결과 기록이 남으면 안 됨" + ); + } + + #[test] + fn a_bare_lf_host_injection_is_refused_without_touching_the_gate_or_the_origin() { + assert_refused_without_side_effects("bare LF Host 주입", |port| { + format!("GET http://127.0.0.1:{port}/x HTTP/1.1\r\nX-A: x\nHost: evil.test\r\n\r\n") + .into_bytes() + }); + assert_refused_without_side_effects("CONNECT bare LF", |port| { + format!("CONNECT 127.0.0.1:{port} HTTP/1.1\r\nX-A: x\nHost: evil.test\r\n\r\n") + .into_bytes() + }); + } + + #[test] + fn a_bare_cr_is_refused_without_touching_the_gate_or_the_origin() { + assert_refused_without_side_effects("bare CR", |port| { + format!("GET http://127.0.0.1:{port}/x HTTP/1.1\r\nX-A: x\rHost: evil.test\r\n\r\n") + .into_bytes() + }); + } + + #[test] + fn an_obs_fold_is_refused_without_touching_the_gate_or_the_origin() { + assert_refused_without_side_effects("obs-fold", |port| { + format!("GET http://127.0.0.1:{port}/x HTTP/1.1\r\nX-A: 1\r\n\tHost: evil.test\r\n\r\n") + .into_bytes() + }); + } + + #[test] + fn duplicate_host_headers_are_refused_without_touching_the_gate_or_the_origin() { + assert_refused_without_side_effects("중복 Host", |port| { + format!( + "GET http://127.0.0.1:{port}/x HTTP/1.1\r\nHost: a.test\r\nHost: b.test\r\n\r\n" + ) + .into_bytes() + }); + } + + #[test] + fn an_unknown_minor_version_is_refused_without_touching_the_gate_or_the_origin() { + assert_refused_without_side_effects("HTTP/1.x", |port| { + format!("GET http://127.0.0.1:{port}/x HTTP/1.x\r\n\r\n").into_bytes() + }); + assert_refused_without_side_effects("CONNECT HTTP/1.x", |port| { + format!("CONNECT 127.0.0.1:{port} HTTP/1.x\r\n\r\n").into_bytes() + }); + } + + #[test] + fn a_control_character_in_a_value_is_refused_without_touching_the_gate_or_the_origin() { + assert_refused_without_side_effects("제어 문자 값", |port| { + format!("GET http://127.0.0.1:{port}/x HTTP/1.1\r\nX-A: a\u{1}b\r\n\r\n").into_bytes() + }); + assert_refused_without_side_effects("DEL 값", |port| { + format!("GET http://127.0.0.1:{port}/x HTTP/1.1\r\nX-A: a\u{7f}b\r\n\r\n").into_bytes() + }); + } + + #[test] + fn a_non_ascii_host_is_refused_before_the_gate() { + // 루프백으로 유도할 수 없는 호스트라 접속 여부 대신 판정 도달 여부를 봅니다 + let gate = Recorder::new(true); + let run = start(gate.clone()); + let resp = send(run.addr, "CONNECT 例え.jp:443 HTTP/1.1\n\n"); + assert_eq!(status(&resp), "HTTP/1.1 400 Bad Request"); + assert!(gate.calls().is_empty()); + let resp = send(run.addr, "GET http://exämple.com/x HTTP/1.1\n\n"); + assert_eq!(status(&resp), "HTTP/1.1 400 Bad Request"); + assert!(gate.calls().is_empty()); + assert!(gate.finished_calls().is_empty()); + } + + #[test] + fn a_tab_inside_a_header_value_reaches_the_origin() { + let (origin, origin_h) = echo_origin("HTTP/1.1 204 No Content\r\n\r\n"); + let gate = Recorder::new(true); + let run = start(gate.clone()); + let req = format!( + "GET http://127.0.0.1:{}/a HTTP/1.0\nX-A: a\tb\n\n", + origin.port() + ); + let resp = send(run.addr, &req); + assert_eq!(status(&resp), "HTTP/1.1 204 No Content"); + let raw = origin_h + .join() + .expect("origin 스레드") + .expect("origin 수신"); + let seen = String::from_utf8(raw).expect("utf8"); + assert!(seen.starts_with("GET /a HTTP/1.0\r\n"), "{seen}"); + assert!(seen.contains("X-A: a\tb\r\n"), "{seen}"); + assert_eq!( + gate.calls(), + vec![("127.0.0.1".to_string(), origin.port(), Protocol::Http)] + ); + } + #[test] fn a_denied_target_gets_403_and_is_never_dialed() { let gate = Recorder::new(false); diff --git a/crates/airlock/Cargo.toml b/crates/airlock/Cargo.toml index 4139115..c7d14a4 100644 --- a/crates/airlock/Cargo.toml +++ b/crates/airlock/Cargo.toml @@ -16,6 +16,7 @@ airlock-policy.workspace = true airlock-proxy.workspace = true airlock-setup.workspace = true clap.workspace = true +libc.workspace = true serde_json.workspace = true [lints] diff --git a/crates/airlock/src/cmd_audit.rs b/crates/airlock/src/cmd_audit.rs index fe81708..7db0a39 100644 --- a/crates/airlock/src/cmd_audit.rs +++ b/crates/airlock/src/cmd_audit.rs @@ -1,8 +1,8 @@ use std::path::{Path, PathBuf}; use airlock_audit::{ - AnchorCheck, AnchorFailure, Entry, Event, ReviewLog, Warning, check_session, verify_anchors, - verify_dir, + AnchorCheck, AnchorFailure, Entry, Event, ReviewLog, Warning, check_session_report, + verify_anchors, verify_dir, }; use airlock_canonical::display::sanitize; use airlock_i18n::tr; @@ -539,11 +539,14 @@ fn report_anchor_chain(anchors: &Path) -> i32 { /// 세션 체인의 head가 앵커 기록과 맞는지 대조해 한 줄로 보고합니다. /// +/// 원시 `check_session` 이 아니라 `check_session_report` 를 씁니다. 앵커가 있는 세션의 +/// head.json 뒤처짐은 크래시 잔여가 아니라 덧붙이기이므로, 그것을 보는 쪽이어야 합니다. +/// /// # Arguments /// `anchors` - 앵커 루트 /// `report` - 이미 통과한 세션 체인 검증 결과 fn report_session_anchor(anchors: &Path, report: &airlock_audit::VerifyReport) -> i32 { - match check_session(anchors, &report.session, report.head_seq, &report.head_hash) { + match check_session_report(anchors, report) { Ok(AnchorCheck::Matches { anchor_seq }) => { println!( "{}", @@ -569,13 +572,17 @@ fn report_session_anchor(anchors: &Path, report: &airlock_audit::VerifyReport) - } Err(AnchorFailure::FileAbsent) => 0, Err(failure) => { - println!( - "{}", - tr!( - format!(" \x1b[1;31m앵커 불일치\x1b[0m {failure}"), - format!(" \x1b[1;31manchor mismatch\x1b[0m {failure}") - ) - ); + let label = match &failure { + AnchorFailure::EntriesAfterAnchor { .. } + | AnchorFailure::SessionReanchored { .. } => { + tr!("종료 후 덧붙이기", "append after close") + } + AnchorFailure::AnchoredHeadLag { .. } => { + tr!("앵커 head 뒤처짐", "anchored head lag") + } + _ => tr!("앵커 불일치", "anchor mismatch"), + }; + println!(" \x1b[1;31m{label}\x1b[0m {failure}"); 2 } } diff --git a/crates/airlock/src/cmd_policy.rs b/crates/airlock/src/cmd_policy.rs index 5d09120..9e8a40f 100644 --- a/crates/airlock/src/cmd_policy.rs +++ b/crates/airlock/src/cmd_policy.rs @@ -2,9 +2,10 @@ use std::path::PathBuf; use airlock_canonical::display::sanitize; use airlock_i18n::tr; -use airlock_policy::{Action, FileMode, LoadContext, PLAINTEXT_FLOOR_ID, Policy, Protocol}; +use airlock_policy::{Action, FileMode, PLAINTEXT_FLOOR_ID, Policy, Protocol}; use crate::paths; +use crate::trust; #[derive(Debug, clap::Subcommand)] pub enum PolicyCommand { @@ -87,6 +88,32 @@ pub enum PolicyCommand { #[arg(long, value_name = "DIR")] workspace: Option, }, + + #[command( + about = tr!( + "정책 파일을 사람이 승인한 것으로 기록함. 처음 보는 정책은 이 기록이 없으면 \ + 터미널 없는 airlock run 에서 거부됨", + "record a policy file as approved by a person; a policy seen for the first \ + time is refused by a terminal-less airlock run without this record" + ) + )] + Trust { + #[arg( + value_name = "PATH", + help = tr!( + "정책 파일 경로. 생략하면 탐색 후보 중 첫 파일", + "policy file path; defaults to the first discovered candidate" + ) + )] + path: Option, + + #[arg(long, help = tr!("기록 목록을 출력함", "print the recorded entries"))] + list: bool, + }, +} + +fn resolve_audit_root(audit_root: Option, cwd: &std::path::Path) -> PathBuf { + paths::absolutize(&audit_root.unwrap_or_else(paths::audit_root), cwd) } fn load( @@ -94,26 +121,15 @@ fn load( audit_root: Option, ) -> Result<(Policy, Option), i32> { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let audit_root = paths::absolutize(&audit_root.unwrap_or_else(paths::audit_root), &cwd); + let audit_root = resolve_audit_root(audit_root, &cwd); let path = paths::discover_policy(explicit, &cwd).map(|p| paths::absolutize_lexical(&p, &cwd)); - let mut candidates: Vec = paths::policy_candidates(&cwd) - .iter() - .flat_map(|p| paths::protect_forms(p, &cwd)) - .collect(); - if let Some(p) = &path { - for form in paths::protect_forms(p, &cwd) { - if !candidates.contains(&form) { - candidates.push(form); - } - } - } - - let mut ctx = LoadContext::new(airlock_policy::path::home_dir(), &audit_root) - .with_policy_files(candidates); - if let Ok(exe) = std::env::current_exe() { - ctx = ctx.with_binary(exe); - } + let ctx = paths::load_context( + airlock_policy::path::home_dir(), + &audit_root, + &cwd, + path.as_deref(), + ); let policy = match &path { Some(p) => Policy::load_file(p, &ctx).map_err(|e| { eprintln!("airlock: {e}"); @@ -298,6 +314,29 @@ pub fn exec(cmd: PolicyCommand, audit_root: Option) -> i32 { } } + PolicyCommand::Trust { path, list } => { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let root = resolve_audit_root(audit_root.clone(), &cwd); + if list { + return trust::list_command(&root); + } + let (policy, source) = match load(path.as_deref(), audit_root) { + Ok(v) => v, + Err(code) => return code, + }; + let Some(source) = source else { + eprintln!( + "airlock: {}", + tr!( + "신뢰할 정책 파일을 찾지 못함. 경로를 지정할 것", + "no policy file found to trust; pass a path" + ) + ); + return 64; + }; + trust::trust_command(&source, &policy, &root) + } + PolicyCommand::Profile { policy, workspace } => { let (policy, _) = match load(policy.as_deref(), audit_root) { Ok(v) => v, diff --git a/crates/airlock/src/cmd_run.rs b/crates/airlock/src/cmd_run.rs index f48f63b..f8ab514 100644 --- a/crates/airlock/src/cmd_run.rs +++ b/crates/airlock/src/cmd_run.rs @@ -4,10 +4,12 @@ use airlock_broker::{ ApproveAll, Approver, Enforcer, ObserveEnforcer, ProfileOptions, RefuseAll, SessionConfig, TtyApprover, }; +use airlock_canonical::display::sanitize; use airlock_i18n::tr; -use airlock_policy::{LoadContext, LoadWarning, Policy}; +use airlock_policy::{LoadWarning, Policy}; use crate::paths; +use crate::trust; #[derive(Debug, clap::Args)] pub struct RunArgs { @@ -157,19 +159,6 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { let policy_path = paths::discover_policy(args.policy.as_deref(), &cwd) .map(|p| paths::absolutize_lexical(&p, &cwd)); - // 실제로 읽은 파일뿐 아니라 탐색 후보 전체를 자기보호 대상으로 둡니다 - let mut candidates: Vec = paths::policy_candidates(&cwd) - .iter() - .flat_map(|p| paths::protect_forms(p, &cwd)) - .collect(); - if let Some(p) = &policy_path { - for form in paths::protect_forms(p, &cwd) { - if !candidates.contains(&form) { - candidates.push(form); - } - } - } - // HOME이 없거나 상대 경로면 ~/ 앵커 forbid가 전부 엉뚱한 곳을 가리킵니다. 시크릿 // 보호가 사라진 채로 도는 것보다 중단이 낫습니다 let Some(home) = airlock_policy::path::home_dir_checked() else { @@ -190,10 +179,7 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { return 78; }; - let mut ctx = LoadContext::new(home, &audit_root).with_policy_files(candidates); - if let Ok(exe) = std::env::current_exe() { - ctx = ctx.with_binary(exe); - } + let ctx = paths::load_context(home, &audit_root, &cwd, policy_path.as_deref()); let policy = match &policy_path { Some(p) => match Policy::load_file(p, &ctx) { @@ -225,6 +211,17 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { }, }; + // 정책 파일을 읽었으면 그 파일이 사람이 둔 것인지 브로커를 띄우기 전에 확인합니다. + // --yes 는 이 관문을 건너뛰지 못합니다. 에이전트가 심은 정책이 바로 다음 --yes + // 실행에서 읽히는 것이 이 위협의 시나리오이기 때문입니다 (docs/design.md 9.6) + let trust_gate = match &policy_path { + Some(p) => match trust::gate_run(p, &policy, &audit_root) { + Ok(gate) => Some(gate), + Err(code) => return code, + }, + None => None, + }; + let proxy_planned = args.egress_proxy && !args.no_network; for w in policy.warnings() { // 프록시가 붙으면 호스트도 프로토콜도 실제로 판정됩니다. 그때까지 경고를 @@ -368,11 +365,13 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { &policy, enforcer.as_ref(), approver.as_ref(), + &audit_root, &session_dir, &workspace, mediation, proxy.as_ref().map(|p| p.addr()), config.anchor_dir.as_deref(), + trust_gate.as_ref(), ); let report = match airlock_broker::run( @@ -652,11 +651,13 @@ fn print_banner( policy: &Policy, enforcer: &dyn Enforcer, approver: &dyn Approver, + audit_root: &std::path::Path, session_dir: &std::path::Path, workspace: &std::path::Path, mediation: airlock_broker::Mediation, proxy: Option, anchor_dir: Option<&std::path::Path>, + trust_gate: Option<&trust::Gate>, ) { let digest = airlock_audit::Hash::from_bytes(policy.digest()); let short: String = digest.to_hex().chars().take(12).collect(); @@ -677,6 +678,15 @@ fn print_banner( ) ) ); + if let Some(gate) = trust_gate { + eprintln!( + "{}", + tr!( + format!(" 신뢰 {}", trust::describe_gate(gate)), + format!(" trust {}", trust::describe_gate(gate)) + ) + ); + } eprintln!( "{}", tr!( @@ -775,6 +785,28 @@ fn print_banner( ) ); } + // 기록 파일이 감사 루트에 있으므로, 감사 루트가 작업 공간 안이면 정책 신뢰 기록도 + // 에이전트 손이 닿는 곳에 놓입니다. 그 사실을 감추면 없는 보증을 믿게 됩니다 + if trust::is_inside(audit_root, workspace) { + eprintln!( + "{}", + tr!( + format!( + " \x1b[33m한계\x1b[0m 감사 루트 {} 가 작업 공간 안에 있음. 정책 신뢰 기록({})도 \ + 에이전트가 쓸 수 있는 곳에 놓임. --audit-dir 를 작업 공간 밖으로 둘 것", + sanitize(&audit_root.display().to_string()), + trust::TRUST_FILE + ), + format!( + " \x1b[33mlimit\x1b[0m the audit root {} is inside the workspace, so the \ + policy trust record ({}) is also where the agent can write; move \ + --audit-dir outside the workspace", + sanitize(&audit_root.display().to_string()), + trust::TRUST_FILE + ) + ) + ); + } for gap in enforcer .gaps() .into_iter() diff --git a/crates/airlock/src/cmd_setup.rs b/crates/airlock/src/cmd_setup.rs index 12b07a9..a66b94e 100644 --- a/crates/airlock/src/cmd_setup.rs +++ b/crates/airlock/src/cmd_setup.rs @@ -1,8 +1,12 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use airlock_canonical::display::sanitize; use airlock_i18n::tr; +use airlock_policy::Policy; use airlock_setup::{Outcome, SetupOptions}; +use crate::{paths, trust}; + #[derive(Debug, clap::Args)] pub struct SetupArgs { #[arg( @@ -30,14 +34,20 @@ pub fn exec(args: SetupArgs, global_audit_root: Option) -> i32 { return 2; } }; + let audit_root = paths::absolutize(&global_audit_root.unwrap_or_else(paths::audit_root), &cwd); let opts = SetupOptions { out: args.out, cwd, home: airlock_policy::path::home_dir(), - audit_root: global_audit_root.unwrap_or_else(crate::paths::audit_root), + audit_root, }; match airlock_setup::run(&opts) { - Ok(Outcome::Written(_)) => 0, + Ok(Outcome::Written(out)) => { + // 사용자가 방금 생성에 동의한 파일이므로 첫 실행에서 다시 묻지 않게 기록합니다. + // 기록 실패는 마법사의 결과를 뒤집을 일이 아니라 첫 실행에서 확인을 받게 될 뿐입니다 + record_written(&out, &opts); + 0 + } Ok(Outcome::Cancelled) => 1, Err(e) => { eprintln!("airlock setup: {e}"); @@ -45,3 +55,47 @@ pub fn exec(args: SetupArgs, global_audit_root: Option) -> i32 { } } } + +fn record_written(out: &Path, opts: &SetupOptions) { + let path = paths::absolutize_lexical(out, &opts.cwd); + let shown = sanitize(&path.display().to_string()); + let ctx = paths::load_context(opts.home.clone(), &opts.audit_root, &opts.cwd, Some(&path)); + let result = Policy::load_file(&path, &ctx) + .map_err(|e| e.to_string()) + .and_then(|policy| trust::record_approval(&path, &policy, &opts.audit_root)); + match result { + Ok((rec, _)) => eprintln!( + "airlock setup: {}", + tr!( + format!( + "정책 신뢰 기록에 추가함 (다이제스트 {})", + rec.digest.chars().take(12).collect::() + ), + format!( + "added to the policy trust record (digest {})", + rec.digest.chars().take(12).collect::() + ) + ) + ), + Err(why) => { + eprintln!( + "airlock setup: {}", + tr!( + format!("경고 정책 신뢰 기록에 추가하지 못함: {why}"), + format!("warning: could not add to the policy trust record: {why}") + ) + ); + eprintln!( + "airlock setup: {}", + tr!( + format!( + "첫 airlock run 에서 확인을 받거나 airlock policy trust {shown} 로 승인할 것" + ), + format!( + "confirm on the first airlock run or approve with airlock policy trust {shown}" + ) + ) + ); + } + } +} diff --git a/crates/airlock/src/main.rs b/crates/airlock/src/main.rs index c0c825f..84eabb8 100644 --- a/crates/airlock/src/main.rs +++ b/crates/airlock/src/main.rs @@ -5,6 +5,7 @@ mod cmd_setup; mod paths; mod render; mod report; +mod trust; use std::path::PathBuf; diff --git a/crates/airlock/src/paths.rs b/crates/airlock/src/paths.rs index 4b603eb..fcabb46 100644 --- a/crates/airlock/src/paths.rs +++ b/crates/airlock/src/paths.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +use airlock_policy::LoadContext; use airlock_policy::path::home_dir; pub const POLICY_FILE_NAMES: &[&str] = &["airlock.toml", ".airlock.toml"]; @@ -89,6 +90,40 @@ pub fn discover_policy(explicit: Option<&Path>, cwd: &Path) -> Option { policy_candidates(cwd).into_iter().find(|c| c.is_file()) } +/// 정책 로드 문맥을 만듭니다. +/// +/// 실제로 읽을 파일뿐 아니라 탐색 후보 전체를 자기보호 대상으로 넣습니다. 비어 있는 +/// 후보 자리에 대상이 정책을 만들어 두면 다음 실행이 그것을 읽기 때문입니다. +/// +/// # Arguments +/// `home` - 홈 디렉토리 +/// `audit_root` - 감사 루트 +/// `cwd` - 현재 디렉토리 +/// `policy_path` - 실제로 읽을 정책 파일. 없으면 베이스라인만 적용 +pub fn load_context( + home: PathBuf, + audit_root: &Path, + cwd: &Path, + policy_path: Option<&Path>, +) -> LoadContext { + let mut candidates: Vec = policy_candidates(cwd) + .iter() + .flat_map(|p| protect_forms(p, cwd)) + .collect(); + if let Some(p) = policy_path { + for form in protect_forms(p, cwd) { + if !candidates.contains(&form) { + candidates.push(form); + } + } + } + let mut ctx = LoadContext::new(home, audit_root).with_policy_files(candidates); + if let Ok(exe) = std::env::current_exe() { + ctx = ctx.with_binary(exe); + } + ctx +} + pub fn latest_session(root: &Path) -> Option { let sessions = root.join("sessions"); let mut best: Option<(String, PathBuf)> = None; diff --git a/crates/airlock/src/report.rs b/crates/airlock/src/report.rs index 7b32bc9..c2697ba 100644 --- a/crates/airlock/src/report.rs +++ b/crates/airlock/src/report.rs @@ -822,7 +822,8 @@ fn build_session( } let mut warnings: Vec = Vec::new(); - let integrity = match airlock_audit::verify_dir(dir) { + let verified = airlock_audit::verify_dir(dir); + let integrity = match &verified { Ok(v) => { for w in &v.warnings { warnings.push(w.to_string()); @@ -858,47 +859,49 @@ fn build_session( } }; - let anchor = match (chain, session, &integrity) { - (ChainState::Absent, _, _) => AnchorState::ChainAbsent, - (ChainState::Broken { .. }, _, _) => AnchorState::ChainBroken, - ( - ChainState::Ok { .. }, - Some(id), - Integrity::Ok { - head_seq, - head_hash, - .. - }, - ) => match airlock_audit::check_session(anchor_dir, &id, *head_seq, head_hash) { - Ok(AnchorCheck::Matches { anchor_seq }) => AnchorState::Matches { anchor_seq }, - Ok(AnchorCheck::Missing) => { - anomalies.push(Anomaly { - severity: Severity::Evidence, - kind: "anchor_missing", - session: Some(name.clone()), - detail: tr!( - "이 세션의 앵커 줄이 없음. 삭제와 재계산을 탐지할 수 없으며 통과가 아님", - "this session has no anchor line; deletion and recomputation \ - cannot be detected, which is not a pass" - ) - .to_string(), - }); - AnchorState::Missing - } - Err(failure) => { - let detail = failure.to_string(); - anomalies.push(Anomaly { - severity: Severity::Evidence, - kind: "anchor_mismatch", - session: Some(name.clone()), - detail: detail.clone(), - }); - AnchorState::Mismatch { detail } + let anchor = match (chain, &verified) { + (ChainState::Absent, _) => AnchorState::ChainAbsent, + (ChainState::Broken { .. }, _) => AnchorState::ChainBroken, + // 원시 check_session 이 아니라 check_session_report 를 쓴다 + // 앵커가 있는 세션의 head.json 뒤처짐은 크래시 잔여가 아니라 덧붙이기다 + (ChainState::Ok { .. }, Ok(v)) => { + match airlock_audit::check_session_report(anchor_dir, v) { + Ok(AnchorCheck::Matches { anchor_seq }) => AnchorState::Matches { anchor_seq }, + Ok(AnchorCheck::Missing) => { + anomalies.push(Anomaly { + severity: Severity::Evidence, + kind: "anchor_missing", + session: Some(name.clone()), + detail: tr!( + "이 세션의 앵커 줄이 없음. 삭제와 재계산을 탐지할 수 없으며 통과가 아님", + "this session has no anchor line; deletion and recomputation \ + cannot be detected, which is not a pass" + ) + .to_string(), + }); + AnchorState::Missing + } + Err(failure) => { + // 종류는 로케일 무관 고정이며 덧붙이기와 재계산은 대응이 다르므로 나눈다 + let kind = match &failure { + AnchorFailure::EntriesAfterAnchor { .. } => "entries_after_anchor", + AnchorFailure::AnchoredHeadLag { .. } => "anchored_head_lag", + _ => "anchor_mismatch", + }; + let detail = failure.to_string(); + anomalies.push(Anomaly { + severity: Severity::Evidence, + kind, + session: Some(name.clone()), + detail: detail.clone(), + }); + AnchorState::Mismatch { detail } + } } - }, + } // 체인을 읽지 못한 세션은 대조할 head 가 없습니다. 무결성 실패가 이미 이상으로 // 올라가 있으므로 여기서 다시 세지 않습니다 - (ChainState::Ok { .. }, _, _) => AnchorState::Missing, + (ChainState::Ok { .. }, Err(_)) => AnchorState::Missing, }; let mut decisions = Decisions::default(); diff --git a/crates/airlock/src/trust.rs b/crates/airlock/src/trust.rs new file mode 100644 index 0000000..a611dfb --- /dev/null +++ b/crates/airlock/src/trust.rs @@ -0,0 +1,1049 @@ +use std::fs::OpenOptions; +use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; + +use airlock_canonical::display::sanitize; +use airlock_i18n::tr; +use airlock_policy::Policy; + +pub const TRUST_FILE: &str = "trusted-policies.jsonl"; +pub const TRUST_VERSION: u64 = 1; + +const DIGEST_HEX_LEN: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustRecord { + pub path: PathBuf, + pub digest: String, + pub trusted_at: String, + pub uid: u32, +} + +#[derive(Debug)] +pub enum TrustState { + Absent, + Loaded(Vec), + Distrusted(String), +} + +#[derive(Debug)] +pub struct TrustStore { + path: PathBuf, + state: TrustState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Gate { + Matched(TrustRecord), + Approved(TrustRecord), +} + +impl Gate { + pub fn record(&self) -> &TrustRecord { + match self { + Self::Matched(r) | Self::Approved(r) => r, + } + } +} + +/// 실제 uid 입니다. 정책 파일에 적용하는 `read_trusted` 와 같은 축을 봅니다. +/// +/// # Safety +/// `getuid(2)` 는 인자가 없고 메모리를 건드리지 않으며 POSIX 가 항상 성공을 보장합니다 +fn real_uid() -> u32 { + unsafe { libc::getuid() } +} + +pub fn digest_hex(policy: &Policy) -> String { + airlock_audit::Hash::from_bytes(policy.digest()).to_hex() +} + +/// 기록의 키가 되는 경로입니다. +/// +/// 로드는 링크를 따라가지 않는 어휘적 경로로 하지만, 기록은 `airlock policy trust` 와 +/// `airlock run` 이 같은 파일을 같은 이름으로 불러야 하므로 해소된 절대 경로를 씁니다 +pub fn trust_key(policy_path: &Path) -> PathBuf { + std::fs::canonicalize(policy_path).unwrap_or_else(|_| policy_path.to_path_buf()) +} + +fn is_lower_hex(s: &str) -> bool { + s.len() == DIGEST_HEX_LEN && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +fn parse_line(line: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(line).map_err(|e| e.to_string())?; + let Some(obj) = value.as_object() else { + return Err(tr!("객체가 아님", "not an object").to_string()); + }; + for key in obj.keys() { + if !matches!(key.as_str(), "v" | "path" | "digest" | "trusted_at" | "uid") { + return Err(tr!( + format!("모르는 필드 {}", sanitize(key)), + format!("unknown field {}", sanitize(key)) + )); + } + } + match obj.get("v").and_then(serde_json::Value::as_u64) { + Some(TRUST_VERSION) => {} + _ => return Err(tr!("v 가 1 이 아님", "v is not 1").to_string()), + } + let path = obj + .get("path") + .and_then(serde_json::Value::as_str) + .filter(|p| Path::new(p).is_absolute()) + .ok_or_else(|| tr!("path 가 절대 경로가 아님", "path is not absolute").to_string())?; + let digest = obj + .get("digest") + .and_then(serde_json::Value::as_str) + .filter(|d| is_lower_hex(d)) + .ok_or_else(|| { + tr!("digest 가 64자 hex 가 아님", "digest is not 64 hex chars").to_string() + })?; + let trusted_at = obj + .get("trusted_at") + .and_then(serde_json::Value::as_str) + .filter(|t| !t.is_empty() && t.is_ascii()) + .ok_or_else(|| tr!("trusted_at 이 비어 있음", "trusted_at is empty").to_string())?; + let uid = obj + .get("uid") + .and_then(serde_json::Value::as_u64) + .and_then(|u| u32::try_from(u).ok()) + .ok_or_else(|| tr!("uid 가 정수가 아님", "uid is not an integer").to_string())?; + Ok(TrustRecord { + path: PathBuf::from(path), + digest: digest.to_string(), + trusted_at: trusted_at.to_string(), + uid, + }) +} + +/// 기록 파일 전체를 해석합니다. +/// +/// # Errors +/// 한 줄이라도 해석할 수 없으면 실패합니다. 잘못된 줄을 건너뛰면 그 뒤의 줄이 정당해 +/// 보이므로 파일 전체를 불신합니다 +pub fn parse_records(text: &str) -> Result, String> { + if !text.is_empty() && !text.ends_with('\n') { + return Err(tr!("마지막 줄이 잘림", "the final line is truncated").to_string()); + } + let mut out = Vec::new(); + for (i, line) in text.lines().enumerate() { + let no = i.saturating_add(1); + if line.trim().is_empty() { + return Err(tr!( + format!("{no}번째 줄이 비어 있음"), + format!("line {no} is blank") + )); + } + let rec = parse_line(line) + .map_err(|why| tr!(format!("{no}번째 줄: {why}"), format!("line {no}: {why}")))?; + out.push(rec); + } + Ok(out) +} + +fn check_owner(meta: &std::fs::Metadata) -> Result<(), String> { + if !meta.is_file() { + return Err(tr!("일반 파일이 아님", "not a regular file").to_string()); + } + let uid = real_uid(); + if meta.uid() != uid { + return Err(tr!( + format!("uid {}의 소유임. 호출자는 uid {uid}", meta.uid()), + format!("owned by uid {}; the caller is uid {uid}", meta.uid()) + )); + } + if meta.mode() & 0o022 != 0 { + return Err(tr!( + format!( + "권한이 {:04o}로 다른 사용자가 쓸 수 있음", + meta.mode() & 0o7777 + ), + format!( + "mode {:04o} lets other users write to it", + meta.mode() & 0o7777 + ) + )); + } + Ok(()) +} + +fn create_dir_private(dir: &Path) -> std::io::Result<()> { + if dir.exists() { + return Ok(()); + } + if let Some(parent) = dir.parent() { + create_dir_private(parent)?; + } + match std::fs::DirBuilder::new().mode(0o700).create(dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(e), + } +} + +fn json_str(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string()) +} + +impl TrustStore { + /// 감사 루트의 기록을 읽습니다. + /// + /// 실패하지 않습니다. 파일이 없으면 기록이 없는 것이고, 읽을 수 없거나 출처가 + /// 의심스럽거나 한 줄이라도 깨져 있으면 파일 전체를 불신 상태로 둡니다 + pub fn open(audit_root: &Path) -> Self { + let path = audit_root.join(TRUST_FILE); + let state = match Self::read(&path) { + Ok(None) => TrustState::Absent, + Ok(Some(records)) => TrustState::Loaded(records), + Err(why) => TrustState::Distrusted(why), + }; + Self { path, state } + } + + fn read(path: &Path) -> Result>, String> { + let mut file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + { + Ok(f) => f, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(tr!(format!("열 수 없음: {e}"), format!("cannot open: {e}"))); + } + }; + let meta = file.metadata().map_err(|e| { + tr!( + format!("메타데이터 실패: {e}"), + format!("metadata failed: {e}") + ) + })?; + check_owner(&meta)?; + let mut text = String::new(); + file.read_to_string(&mut text) + .map_err(|e| tr!(format!("읽기 실패: {e}"), format!("read failed: {e}")))?; + parse_records(&text).map(Some) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn state(&self) -> &TrustState { + &self.state + } + + pub fn distrust_reason(&self) -> Option<&str> { + match &self.state { + TrustState::Distrusted(why) => Some(why), + _ => None, + } + } + + /// 같은 경로의 마지막 줄입니다. + pub fn lookup(&self, key: &Path) -> Option<&TrustRecord> { + match &self.state { + TrustState::Loaded(records) => records.iter().rev().find(|r| r.path == key), + _ => None, + } + } + + /// 경로마다 마지막 줄만 모읍니다. 처음 등장한 순서를 유지합니다. + pub fn latest(&self) -> Vec<&TrustRecord> { + let TrustState::Loaded(records) = &self.state else { + return Vec::new(); + }; + let mut out: Vec<&TrustRecord> = Vec::new(); + for rec in records { + match out.iter_mut().find(|r| r.path == rec.path) { + Some(slot) => *slot = rec, + None => out.push(rec), + } + } + out + } + + /// 승인 한 줄을 잇습니다. + /// + /// # Errors + /// 불신 상태의 파일에는 잇지 않습니다. 잇더라도 다음 읽기가 파일 전체를 불신하므로 + /// 사람이 먼저 파일을 확인해야 합니다. 열기와 출처 검사와 쓰기와 fsync 실패도 실패입니다 + pub fn record(&mut self, key: &Path, digest: &str) -> Result { + if let Some(why) = self.distrust_reason() { + return Err(tr!( + format!("기록 파일을 불신하므로 잇지 않음: {why}"), + format!("refusing to append to a distrusted record file: {why}") + )); + } + let Some(key_str) = key.to_str() else { + return Err(tr!( + "정책 경로가 UTF-8 이 아니라 기록할 수 없음", + "the policy path is not UTF-8 and cannot be recorded" + ) + .to_string()); + }; + if !is_lower_hex(digest) { + return Err(tr!("다이제스트 형식이 틀림", "malformed digest").to_string()); + } + if let Some(dir) = self.path.parent() { + create_dir_private(dir).map_err(|e| { + tr!( + format!( + "감사 루트 {} 를 만들지 못함: {e}", + sanitize(&dir.display().to_string()) + ), + format!( + "cannot create the audit root {}: {e}", + sanitize(&dir.display().to_string()) + ) + ) + })?; + } + let mut file = OpenOptions::new() + .append(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_APPEND) + .open(&self.path) + .map_err(|e| tr!(format!("열 수 없음: {e}"), format!("cannot open: {e}")))?; + let meta = file.metadata().map_err(|e| { + tr!( + format!("메타데이터 실패: {e}"), + format!("metadata failed: {e}") + ) + })?; + check_owner(&meta)?; + + let rec = TrustRecord { + path: key.to_path_buf(), + digest: digest.to_string(), + trusted_at: airlock_audit::format_rfc3339_nanos(airlock_audit::now_unix_nanos()), + uid: real_uid(), + }; + let line = format!( + "{{\"v\":{TRUST_VERSION},\"path\":{},\"digest\":{},\"trusted_at\":{},\"uid\":{}}}\n", + json_str(key_str), + json_str(&rec.digest), + json_str(&rec.trusted_at), + rec.uid + ); + file.write_all(line.as_bytes()) + .map_err(|e| tr!(format!("쓰기 실패: {e}"), format!("write failed: {e}")))?; + file.sync_all() + .map_err(|e| tr!(format!("fsync 실패: {e}"), format!("fsync failed: {e}")))?; + + match &mut self.state { + TrustState::Loaded(records) => records.push(rec.clone()), + state => *state = TrustState::Loaded(vec![rec.clone()]), + } + Ok(rec) + } +} + +fn short(digest: &str) -> String { + digest.chars().take(12).collect() +} + +fn describe_previous(previous: Option<&TrustRecord>) -> String { + match previous { + Some(p) => format!("{} ({})", p.digest, p.trusted_at), + None => tr!("없음", "none").to_string(), + } +} + +fn render_prompt( + key: &Path, + digest: &str, + previous: Option<&TrustRecord>, + policy: &Policy, +) -> String { + let mut out = String::new(); + out.push_str(tr!( + "\n\x1b[1;33m┌─ airlock 정책 신뢰 확인 ────────────────────────\x1b[0m\n", + "\n\x1b[1;33m┌─ airlock policy trust check ────────────────────\x1b[0m\n" + )); + let headline = match previous { + Some(_) => tr!( + "정책 파일이 기록된 다이제스트와 다름", + "the policy file differs from the recorded digest" + ), + None => tr!( + "처음 보는 정책 파일", + "a policy file seen for the first time" + ), + }; + out.push_str(&format!( + "\x1b[1;33m│\x1b[0m {headline}\n\x1b[1;33m│\x1b[0m\n" + )); + let rows: Vec<(String, String)> = vec![ + ( + tr!("경로", "path").to_string(), + sanitize(&key.display().to_string()), + ), + (tr!("다이제스트", "digest").to_string(), digest.to_string()), + ( + tr!("이전 기록", "recorded").to_string(), + describe_previous(previous), + ), + (tr!("이름", "name").to_string(), sanitize(policy.name())), + ( + tr!("규칙", "rules").to_string(), + tr!( + format!( + "사용자 {} / 베이스라인 {} / tier0 {}", + policy.user_rules().len(), + policy.baseline_rules().len(), + policy.self_protect_rules().len() + ), + format!( + "user {} / baseline {} / tier0 {}", + policy.user_rules().len(), + policy.baseline_rules().len(), + policy.self_protect_rules().len() + ) + ), + ), + ]; + let width = rows + .iter() + .map(|(l, _)| l.chars().count()) + .max() + .unwrap_or(0); + for (label, value) in &rows { + let pad = " ".repeat(width.saturating_sub(label.chars().count())); + out.push_str(&format!("\x1b[1;33m│\x1b[0m {label}{pad} {value}\n")); + } + out.push_str("\x1b[1;33m│\x1b[0m\n"); + out.push_str(tr!( + "\x1b[1;33m│\x1b[0m \x1b[2m이 파일을 사람이 두었는지 확인할 것. 에이전트가 작업 공간에 심은 정책일 수 있음\x1b[0m\n", + "\x1b[1;33m│\x1b[0m \x1b[2mconfirm that a person placed this file; an agent may have planted it in the workspace\x1b[0m\n" + )); + out.push_str("\x1b[1;33m└─────────────────────────────────────────────────\x1b[0m\n"); + out.push_str(tr!( + "이 정책을 신뢰하고 기록하겠습니까? [y/N] ", + "trust this policy and record it? [y/N] " + )); + out +} + +pub fn answer_is_yes(line: &str) -> bool { + matches!(line.trim(), "y" | "Y") +} + +/// `/dev/tty` 를 새로 열어 사람에게 묻습니다. +/// +/// 터미널이 없으면 `None` 입니다. 에이전트의 stdin 과 분리된 채널이어야 하므로 +/// 표준 입력은 쓰지 않습니다 +fn confirm_on_tty( + key: &Path, + digest: &str, + previous: Option<&TrustRecord>, + policy: &Policy, +) -> Option { + let mut tty = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + if tty + .write_all(render_prompt(key, digest, previous, policy).as_bytes()) + .is_err() + { + return Some(false); + } + let _ = tty.flush(); + let read_side = tty.try_clone().ok()?; + let mut line = String::new(); + let granted = match BufReader::new(read_side).read_line(&mut line) { + Ok(n) if n > 0 => answer_is_yes(&line), + _ => false, + }; + let _ = tty.write_all( + if granted { + tr!( + "\x1b[32m신뢰 기록됨\x1b[0m\n\n", + "\x1b[32mtrusted and recorded\x1b[0m\n\n" + ) + } else { + tr!("\x1b[31m거부\x1b[0m\n\n", "\x1b[31mrefused\x1b[0m\n\n") + } + .as_bytes(), + ); + Some(granted) +} + +fn refuse(key: &Path, digest: &str, previous: Option<&TrustRecord>, asked: bool) -> i32 { + let shown = sanitize(&key.display().to_string()); + match previous { + Some(p) => eprintln!( + "airlock: {}", + tr!( + format!( + "정책 파일 {shown} 의 다이제스트 {digest} 가 기록된 {} 와 다름", + p.digest + ), + format!( + "policy file {shown} has digest {digest}, which differs from the recorded {}", + p.digest + ) + ) + ), + None => eprintln!( + "airlock: {}", + tr!( + format!("정책 파일 {shown} (다이제스트 {digest}) 은 신뢰 기록에 없음"), + format!("policy file {shown} (digest {digest}) is not in the trust record") + ) + ), + } + if !asked { + eprintln!( + "airlock: {}", + tr!( + "/dev/tty 가 없어 확인을 물을 수 없음", + "cannot ask for confirmation because /dev/tty is unavailable" + ) + ); + } + eprintln!( + "airlock: {}", + tr!( + format!("사람이 둔 파일이 맞으면 airlock policy trust {shown} 로 승인할 것"), + format!("if a person placed this file, approve it with airlock policy trust {shown}") + ) + ); + eprintln!( + "airlock: {}", + tr!( + "정책 출처를 확인하지 못했으므로 실행을 중단함", + "aborting because the policy source was not confirmed" + ) + ); + 78 +} + +fn refuse_distrusted(store: &TrustStore, why: &str) -> i32 { + let shown = sanitize(&store.path().display().to_string()); + eprintln!( + "airlock: {}", + tr!( + format!("경고 정책 신뢰 기록 {shown} 을 불신함: {why}"), + format!("warning: distrusting the policy trust record {shown}: {why}") + ) + ); + eprintln!( + "airlock: {}", + tr!( + "파일을 직접 확인하고 지우거나 권한을 고친 뒤 airlock policy trust 로 다시 기록할 것", + "inspect the file yourself, delete it or fix its permissions, then record again with \ + airlock policy trust" + ) + ); + eprintln!( + "airlock: {}", + tr!( + "정책 출처를 확인하지 못했으므로 실행을 중단함", + "aborting because the policy source was not confirmed" + ) + ); + 78 +} + +/// `airlock run` 의 로드 시점 관문입니다. +/// +/// 기록과 일치하면 조용히 통과합니다. 아니면 `/dev/tty` 로 묻고, 터미널이 없거나 답이 +/// `y` 가 아니면 거부합니다. `--yes` 는 이 관문을 건너뛰지 못합니다 +/// +/// # Errors +/// 거부하면 종료 코드를 돌려줍니다 +pub fn gate_run(policy_path: &Path, policy: &Policy, audit_root: &Path) -> Result { + let mut store = TrustStore::open(audit_root); + if let Some(why) = store.distrust_reason() { + return Err(refuse_distrusted(&store, why)); + } + let key = trust_key(policy_path); + let digest = digest_hex(policy); + let previous = store.lookup(&key).cloned(); + if let Some(p) = &previous + && p.digest == digest + { + return Ok(Gate::Matched(p.clone())); + } + match confirm_on_tty(&key, &digest, previous.as_ref(), policy) { + Some(true) => match store.record(&key, &digest) { + Ok(rec) => Ok(Gate::Approved(rec)), + Err(why) => { + eprintln!( + "airlock: {}", + tr!( + format!("정책 신뢰 기록에 쓰지 못함: {why}"), + format!("failed to write the policy trust record: {why}") + ) + ); + eprintln!( + "airlock: {}", + tr!( + "승인을 남기지 못했으므로 실행을 중단함", + "aborting because the approval could not be recorded" + ) + ); + Err(78) + } + }, + Some(false) => Err(refuse(&key, &digest, previous.as_ref(), true)), + None => Err(refuse(&key, &digest, previous.as_ref(), false)), + } +} + +pub fn describe_gate(gate: &Gate) -> String { + let rec = gate.record(); + match gate { + Gate::Matched(_) => tr!( + format!("기록 일치 ({}, uid {})", rec.trusted_at, rec.uid), + format!("record matches ({}, uid {})", rec.trusted_at, rec.uid) + ), + Gate::Approved(_) => tr!( + "이번 실행에서 승인하고 기록함", + "approved and recorded in this run" + ) + .to_string(), + } +} + +/// 사람이 직접 내린 승인을 기록합니다. `airlock policy trust` 와 `airlock setup` 이 씁니다. +/// +/// # Errors +/// 기록 파일이 불신 상태거나 쓰지 못하면 실패합니다 +pub fn record_approval( + policy_path: &Path, + policy: &Policy, + audit_root: &Path, +) -> Result<(TrustRecord, bool), String> { + let mut store = TrustStore::open(audit_root); + if let Some(why) = store.distrust_reason() { + return Err(tr!( + format!( + "정책 신뢰 기록 {} 을 불신함: {why}. 파일을 직접 확인하고 지우거나 권한을 고칠 것", + sanitize(&store.path().display().to_string()) + ), + format!( + "distrusting the policy trust record {}: {why}. Inspect the file yourself and \ + delete it or fix its permissions", + sanitize(&store.path().display().to_string()) + ) + )); + } + let key = trust_key(policy_path); + let digest = digest_hex(policy); + if let Some(existing) = store.lookup(&key) + && existing.digest == digest + { + return Ok((existing.clone(), false)); + } + store.record(&key, &digest).map(|r| (r, true)) +} + +pub fn trust_command(policy_path: &Path, policy: &Policy, audit_root: &Path) -> i32 { + let store_path = audit_root.join(TRUST_FILE); + match record_approval(policy_path, policy, audit_root) { + Ok((rec, appended)) => { + println!( + "{}", + if appended { + tr!( + "\x1b[32m정책 신뢰 기록됨\x1b[0m", + "\x1b[32mpolicy trusted and recorded\x1b[0m" + ) + } else { + tr!( + "\x1b[32m이미 같은 다이제스트로 기록되어 있음\x1b[0m", + "\x1b[32malready recorded with the same digest\x1b[0m" + ) + } + ); + println!( + "{}", + tr!( + format!(" 경로 {}", sanitize(&rec.path.display().to_string())), + format!(" path {}", sanitize(&rec.path.display().to_string())) + ) + ); + println!( + "{}", + tr!( + format!(" 다이제스트 {}", rec.digest), + format!(" digest {}", rec.digest) + ) + ); + println!( + "{}", + tr!( + format!(" 시각 {}", rec.trusted_at), + format!(" at {}", rec.trusted_at) + ) + ); + println!( + "{}", + tr!( + format!( + " 기록 {}", + sanitize(&store_path.display().to_string()) + ), + format!( + " record {}", + sanitize(&store_path.display().to_string()) + ) + ) + ); + 0 + } + Err(why) => { + eprintln!("airlock: {why}"); + 78 + } + } +} + +pub fn list_command(audit_root: &Path) -> i32 { + let store = TrustStore::open(audit_root); + let shown = sanitize(&store.path().display().to_string()); + match store.state() { + TrustState::Absent => { + println!( + "{}", + tr!( + format!("정책 신뢰 기록 없음 ({shown})"), + format!("no policy trust record ({shown})") + ) + ); + 0 + } + TrustState::Distrusted(why) => { + eprintln!( + "airlock: {}", + tr!( + format!("경고 정책 신뢰 기록 {shown} 을 불신함: {why}"), + format!("warning: distrusting the policy trust record {shown}: {why}") + ) + ); + 78 + } + TrustState::Loaded(_) => { + println!( + "{}", + tr!( + format!("정책 신뢰 기록 {shown}"), + format!("policy trust record {shown}") + ) + ); + for rec in store.latest() { + println!( + " {} {} {} uid {}", + short(&rec.digest), + rec.trusted_at, + sanitize(&rec.path.display().to_string()), + rec.uid + ); + println!(" {}", rec.digest); + } + 0 + } + } +} + +/// 감사 루트가 작업 공간 안에 있는지 봅니다. +/// +/// 아직 없는 감사 루트는 해소되지 않아 `work/../audit` 같은 형태로 오므로, 접두 비교 전에 +/// `..` 을 어휘적으로 정리합니다. 정리하지 않으면 작업 공간 밖의 루트를 안이라고 오판합니다 +pub fn is_inside(audit_root: &Path, workspace: &Path) -> bool { + airlock_policy::path::lexical_clean(audit_root) + .starts_with(airlock_policy::path::lexical_clean(workspace)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + struct Scratch(PathBuf); + + impl Scratch { + fn new(tag: &str) -> Self { + let mut p = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + p.push(format!( + "airlock-trust-{tag}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&p).unwrap(); + Self(std::fs::canonicalize(&p).unwrap()) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + const D1: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const D2: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + + fn line(path: &str, digest: &str) -> String { + format!( + "{{\"v\":1,\"path\":\"{path}\",\"digest\":\"{digest}\",\"trusted_at\":\"2026-01-01T00:00:00.000000000Z\",\"uid\":501}}\n" + ) + } + + fn policy() -> Policy { + let ctx = airlock_policy::LoadContext::new("/Users/me", "/Users/me/.local/share/airlock"); + Policy::baseline_only(&ctx).unwrap() + } + + #[test] + fn a_well_formed_line_parses() { + let recs = parse_records(&line("/w/airlock.toml", D1)).unwrap(); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].path, PathBuf::from("/w/airlock.toml")); + assert_eq!(recs[0].digest, D1); + assert_eq!(recs[0].uid, 501); + } + + #[test] + fn the_last_line_for_a_path_wins() { + let s = Scratch::new("last"); + let text = format!( + "{}{}{}", + line("/w/a.toml", D1), + line("/w/b.toml", D1), + line("/w/a.toml", D2) + ); + std::fs::write(s.path().join(TRUST_FILE), text).unwrap(); + let store = TrustStore::open(s.path()); + assert_eq!(store.lookup(Path::new("/w/a.toml")).unwrap().digest, D2); + assert_eq!(store.lookup(Path::new("/w/b.toml")).unwrap().digest, D1); + assert!(store.lookup(Path::new("/w/c.toml")).is_none()); + let latest = store.latest(); + assert_eq!(latest.len(), 2, "경로마다 한 줄만 남아야 함"); + assert_eq!( + latest[0].digest, D2, + "처음 등장한 순서를 지키되 값은 마지막 줄" + ); + } + + #[test] + fn one_bad_line_distrusts_the_whole_file() { + let s = Scratch::new("badline"); + let text = format!( + "{}not json\n{}", + line("/w/a.toml", D1), + line("/w/b.toml", D1) + ); + std::fs::write(s.path().join(TRUST_FILE), text).unwrap(); + let store = TrustStore::open(s.path()); + assert!( + store.distrust_reason().is_some(), + "잘못된 줄 하나가 있으면 파일 전체를 불신해야 함" + ); + assert!( + store.lookup(Path::new("/w/a.toml")).is_none(), + "불신한 파일의 정상 줄이 유효하게 보이면 안 됨" + ); + } + + #[test] + fn unknown_fields_and_bad_values_are_malformed() { + for bad in [ + "{\"v\":2,\"path\":\"/w/a\",\"digest\":\"DIGEST\",\"trusted_at\":\"t\",\"uid\":1}\n", + "{\"v\":1,\"path\":\"w/a\",\"digest\":\"DIGEST\",\"trusted_at\":\"t\",\"uid\":1}\n", + "{\"v\":1,\"path\":\"/w/a\",\"digest\":\"abc\",\"trusted_at\":\"t\",\"uid\":1}\n", + "{\"v\":1,\"path\":\"/w/a\",\"digest\":\"DIGEST\",\"trusted_at\":\"\",\"uid\":1}\n", + "{\"v\":1,\"path\":\"/w/a\",\"digest\":\"DIGEST\",\"trusted_at\":\"t\",\"uid\":-1}\n", + "{\"v\":1,\"path\":\"/w/a\",\"digest\":\"DIGEST\",\"trusted_at\":\"t\",\"uid\":1,\"x\":1}\n", + "[1]\n", + "\n", + ] { + let text = bad.replace("DIGEST", D1); + assert!( + parse_records(&text).is_err(), + "해석되면 안 되는 줄이 통과함: {text}" + ); + } + } + + #[test] + fn a_truncated_final_line_is_malformed() { + let text = line("/w/a.toml", D1); + let cut = &text[..text.len() - 1]; + assert!( + parse_records(cut).is_err(), + "개행 없는 마지막 줄은 잘린 쓰기임" + ); + } + + #[test] + fn group_or_world_writable_files_are_distrusted() { + let s = Scratch::new("perm"); + let file = s.path().join(TRUST_FILE); + std::fs::write(&file, line("/w/a.toml", D1)).unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o620)).unwrap(); + let store = TrustStore::open(s.path()); + assert!( + store.distrust_reason().is_some(), + "그룹 쓰기 비트가 있으면 불신" + ); + assert!(store.lookup(Path::new("/w/a.toml")).is_none()); + + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap(); + let store = TrustStore::open(s.path()); + assert!(store.distrust_reason().is_none(), "0600 은 신뢰"); + assert!(store.lookup(Path::new("/w/a.toml")).is_some()); + } + + #[test] + fn a_symlinked_record_file_is_distrusted() { + let s = Scratch::new("symlink"); + let real = s.path().join("real.jsonl"); + std::fs::write(&real, line("/w/a.toml", D1)).unwrap(); + std::os::unix::fs::symlink(&real, s.path().join(TRUST_FILE)).unwrap(); + let store = TrustStore::open(s.path()); + assert!(store.distrust_reason().is_some(), "링크를 따라가면 안 됨"); + } + + #[test] + fn a_missing_file_means_no_records() { + let s = Scratch::new("absent"); + let store = TrustStore::open(&s.path().join("nope")); + assert!(matches!(store.state(), TrustState::Absent)); + assert!(store.lookup(Path::new("/w/a.toml")).is_none()); + assert!(store.latest().is_empty()); + } + + #[test] + fn recording_creates_a_private_file_and_reads_back() { + let s = Scratch::new("record"); + let root = s.path().join("audit"); + let mut store = TrustStore::open(&root); + let rec = store.record(Path::new("/w/a.toml"), D1).unwrap(); + assert_eq!(rec.uid, real_uid()); + assert!(rec.trusted_at.ends_with('Z')); + + let meta = std::fs::metadata(root.join(TRUST_FILE)).unwrap(); + assert_eq!(meta.mode() & 0o777, 0o600, "새 기록 파일은 0600 이어야 함"); + assert_eq!( + std::fs::metadata(&root).unwrap().mode() & 0o777, + 0o700, + "새 감사 루트는 0700 이어야 함" + ); + + store.record(Path::new("/w/a.toml"), D2).unwrap(); + let again = TrustStore::open(&root); + assert!(again.distrust_reason().is_none()); + assert_eq!(again.lookup(Path::new("/w/a.toml")).unwrap().digest, D2); + assert_eq!(again.latest().len(), 1); + } + + #[test] + fn recording_refuses_a_distrusted_file() { + let s = Scratch::new("record-distrusted"); + std::fs::write(s.path().join(TRUST_FILE), "garbage\n").unwrap(); + let mut store = TrustStore::open(s.path()); + assert!( + store.record(Path::new("/w/a.toml"), D1).is_err(), + "불신한 파일에 잇는 것은 무의미하고 위험함" + ); + let body = std::fs::read_to_string(s.path().join(TRUST_FILE)).unwrap(); + assert_eq!(body, "garbage\n", "불신한 파일이 바뀌면 안 됨"); + } + + #[test] + fn recording_rejects_a_malformed_digest() { + let s = Scratch::new("record-digest"); + let mut store = TrustStore::open(s.path()); + assert!(store.record(Path::new("/w/a.toml"), "ABC").is_err()); + assert!(!s.path().join(TRUST_FILE).exists()); + } + + #[test] + fn approval_is_idempotent_for_the_same_digest() { + let s = Scratch::new("idempotent"); + let root = s.path().join("audit"); + let policy_file = s.path().join("airlock.toml"); + std::fs::write(&policy_file, "version = 1\n").unwrap(); + let p = policy(); + let (_, first) = record_approval(&policy_file, &p, &root).unwrap(); + let (_, second) = record_approval(&policy_file, &p, &root).unwrap(); + assert!(first, "첫 승인은 기록되어야 함"); + assert!(!second, "같은 다이제스트를 다시 잇지 않음"); + let body = std::fs::read_to_string(root.join(TRUST_FILE)).unwrap(); + assert_eq!(body.lines().count(), 1); + assert!(body.contains(&digest_hex(&p))); + } + + #[test] + fn the_digest_is_the_genesis_representation() { + let p = policy(); + let hex = digest_hex(&p); + assert!(is_lower_hex(&hex)); + assert_eq!( + hex, + airlock_audit::Hash::from_bytes(p.digest()).to_hex(), + "감사 제네시스의 policy_digest 와 같은 표기여야 함" + ); + } + + #[test] + fn only_y_approves() { + assert!(answer_is_yes("y\n")); + assert!(answer_is_yes(" Y \n")); + for no in ["", "\n", "n\n", "yes\n", "N\n", "1\n", "y y\n"] { + assert!(!answer_is_yes(no), "{no:?} 가 승인으로 읽히면 안 됨"); + } + } + + #[test] + fn the_prompt_shows_only_observed_facts_and_defaults_to_refusal() { + let p = policy(); + let key = Path::new("/w/\u{1b}[2Jairlock.toml"); + let prev = TrustRecord { + path: key.to_path_buf(), + digest: D2.to_string(), + trusted_at: "2026-01-01T00:00:00.000000000Z".to_string(), + uid: 501, + }; + let text = render_prompt(key, D1, Some(&prev), &p); + assert!(text.contains(D1)); + assert!(text.contains(D2), "이전 기록 다이제스트가 보여야 함"); + assert!( + !text.contains("\x1b[2J"), + "경로의 제어 문자가 살아남으면 안 됨" + ); + assert!(text.ends_with("[y/N] "), "기본값이 거부여야 함"); + let fresh = render_prompt(key, D1, None, &p); + assert!(fresh.contains(tr!("없음", "none"))); + } + + #[test] + fn workspace_containment_is_prefix_based() { + assert!(is_inside(Path::new("/w/audit"), Path::new("/w"))); + assert!(!is_inside(Path::new("/other"), Path::new("/w"))); + assert!(!is_inside(Path::new("/wx"), Path::new("/w"))); + assert!( + !is_inside(Path::new("/w/work/../audit"), Path::new("/w/work")), + "아직 없는 루트의 .. 이 정리되지 않으면 작업 공간 밖을 안이라고 오판함" + ); + assert!(is_inside( + Path::new("/w/work/x/../audit"), + Path::new("/w/work") + )); + } +} diff --git a/crates/airlock/tests/cli.rs b/crates/airlock/tests/cli.rs index 4648cb7..621dd5d 100644 --- a/crates/airlock/tests/cli.rs +++ b/crates/airlock/tests/cli.rs @@ -5,6 +5,7 @@ //! 정책 로드 실패 시 fail-closed, 작업 공간 안전장치가 대상입니다. 강제 층 동작은 //! 플랫폼별 통합 테스트가 따로 검사합니다. +use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -58,21 +59,70 @@ impl Drop for Scratch { /// 격리된 환경에서 airlock 을 부릅니다. /// -/// `HOME`을 스크래치 안으로 옮겨 실행하는 사람의 실제 홈과 정책 파일을 건드리지 않습니다 +/// `HOME`을 스크래치 안으로 옮겨 실행하는 사람의 실제 홈과 정책 파일을 건드리지 않습니다. +/// 자식은 새 세션에서 돌려 제어 터미널을 떼어 냅니다. 터미널에서 `cargo test` 를 돌리면 +/// 자식이 `/dev/tty` 를 열 수 있어 ask 나 정책 신뢰 확인이 사람 입력을 기다리며 멈추는데, +/// CI 와 같은 조건(터미널 없음)으로 고정해야 결과가 결정적입니다 +/// +/// # Safety +/// `setsid(2)` 는 fork 뒤 exec 전의 자식에서 부르며 async-signal-safe 입니다. 방금 fork 된 +/// 자식은 프로세스 그룹 리더가 아니므로 실패하지 않고, 실패하더라도 exec 은 계속됩니다 fn airlock(s: &Scratch, cwd: &Path, args: &[&str]) -> Output { let home = s.path().join("home"); std::fs::create_dir_all(&home).unwrap(); - Command::new(bin()) - .args(args) + let mut cmd = Command::new(bin()); + cmd.args(args) .current_dir(cwd) .env("HOME", &home) .env("AIRLOCK_LANG", "ko") .env_remove("AIRLOCK_AUDIT_DIR") - .env_remove("XDG_DATA_HOME") - .output() - .expect("airlock 실행 실패") + .env_remove("XDG_DATA_HOME"); + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + cmd.output().expect("airlock 실행 실패") } +/// 정책 파일을 사람이 승인한 것으로 기록합니다. +/// +/// 정책 파일을 넘기는 `airlock run` 은 이 기록이 없으면 터미널 없는 테스트에서 거부되므로 +/// 실행 전에 부릅니다 +fn trust(s: &Scratch, cwd: &Path, policy: &Path) -> Output { + let out = airlock( + s, + cwd, + &[ + "policy", + "trust", + "--audit-root", + s.audit().to_str().unwrap(), + policy.to_str().unwrap(), + ], + ); + assert_eq!(code(&out), 0, "{}", printed(&out)); + out +} + +const ALLOW_ALL_EXEC: &str = r#" +version = 1 +name = "allow-exec" +[defaults] +file = "allow" +exec = "allow" +egress = "deny" +"#; + +const EXTRA_RULE: &str = r#" +[[rules]] +id = "open-tmp" +kind = "file" +path = "/tmp/**" +action = "allow" +"#; + fn code(out: &Output) -> i32 { out.status.code().unwrap_or(-1) } @@ -194,6 +244,7 @@ egress = "deny" "#, ) .unwrap(); + trust(&s, &ws, &ws.join("airlock.toml")); let out = airlock( &s, @@ -334,6 +385,274 @@ fn an_unknown_mediation_level_is_rejected() { assert!(!s.audit().exists(), "인자가 틀렸는데 세션이 시작되었음"); } +// ---------- 정책 출처 신뢰 ---------- + +fn run_echo(s: &Scratch, ws: &Path, extra: &[&str]) -> Output { + let audit = s.audit(); + let mut args = vec!["run", "--audit-dir", audit.to_str().unwrap()]; + args.extend_from_slice(extra); + args.extend_from_slice(&["--", "/bin/echo", "hi"]); + airlock(s, ws, &args) +} + +#[test] +fn an_untrusted_policy_is_refused_without_a_tty() { + let s = Scratch::new("trust-none"); + let ws = work_dir(&s); + std::fs::write(ws.join("airlock.toml"), ALLOW_ALL_EXEC).unwrap(); + + let out = run_echo(&s, &ws, &[]); + assert_eq!( + code(&out), + 78, + "기록 없는 정책이 터미널 없이 실행되면 에이전트가 심은 정책이 그대로 읽힘: {}", + stderr(&out) + ); + let err = stderr(&out); + assert!( + err.contains("airlock policy trust"), + "힌트가 있어야 함: {err}" + ); + assert!(err.contains("실행을 중단함"), "{err}"); + assert!( + !s.audit().join("sessions").exists(), + "정책 출처를 확인하지 못했는데 브로커가 세션을 시작했음" + ); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "", + "자식이 실행되었음" + ); +} + +#[test] +fn a_trusted_policy_runs_and_is_reported_in_the_banner() { + let s = Scratch::new("trust-ok"); + let ws = work_dir(&s); + let policy = ws.join("airlock.toml"); + std::fs::write(&policy, ALLOW_ALL_EXEC).unwrap(); + + let recorded = trust(&s, &ws, &policy); + let text = printed(&recorded); + assert!(text.contains("정책 신뢰 기록됨"), "{text}"); + assert!( + s.audit().join("trusted-policies.jsonl").is_file(), + "기록 파일이 감사 루트에 생겨야 함" + ); + + let out = run_echo(&s, &ws, &[]); + assert_eq!(code(&out), 0, "{}", stderr(&out)); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi"); + assert!( + stderr(&out).contains("신뢰 기록 일치"), + "배너가 기록 일치를 보여야 함: {}", + stderr(&out) + ); +} + +#[test] +fn a_changed_policy_is_refused_again_but_comments_are_not() { + let s = Scratch::new("trust-changed"); + let ws = work_dir(&s); + let policy = ws.join("airlock.toml"); + std::fs::write(&policy, ALLOW_ALL_EXEC).unwrap(); + trust(&s, &ws, &policy); + assert_eq!(code(&run_echo(&s, &ws, &[])), 0); + + // 주석만 바뀐 정책은 의미가 같으므로 다시 묻지 않습니다 + std::fs::write( + &policy, + format!( + "# comment +{ALLOW_ALL_EXEC}" + ), + ) + .unwrap(); + let out = run_echo(&s, &ws, &[]); + assert_eq!( + code(&out), + 0, + "주석은 다이제스트를 바꾸지 않아야 함: {}", + stderr(&out) + ); + + // 규칙이 바뀌면 다른 정책입니다 + std::fs::write(&policy, format!("{ALLOW_ALL_EXEC}\n{EXTRA_RULE}")).unwrap(); + let out = run_echo(&s, &ws, &[]); + assert_eq!(code(&out), 78, "{}", stderr(&out)); + assert!( + stderr(&out).contains("기록된"), + "이전에 기록된 다이제스트와 다르다는 사실이 드러나야 함: {}", + stderr(&out) + ); +} + +#[test] +fn yes_and_observe_do_not_bypass_the_trust_check() { + let s = Scratch::new("trust-yes"); + let ws = work_dir(&s); + std::fs::write(ws.join("airlock.toml"), ALLOW_ALL_EXEC).unwrap(); + + for flags in [ + &["--yes"][..], + &["--observe"][..], + &["--yes", "--observe"][..], + ] { + let out = run_echo(&s, &ws, flags); + assert_eq!( + code(&out), + 78, + "{flags:?} 가 정책 신뢰 확인을 우회함. 심긴 정책이 바로 --yes 실행에서 읽히는 것이 \ + 이 위협의 시나리오임: {}", + stderr(&out) + ); + assert!(!s.audit().join("sessions").exists()); + } +} + +#[test] +fn a_distrusted_record_file_refuses_the_run() { + use std::os::unix::fs::PermissionsExt; + + let s = Scratch::new("trust-distrusted"); + let ws = work_dir(&s); + let policy = ws.join("airlock.toml"); + std::fs::write(&policy, ALLOW_ALL_EXEC).unwrap(); + trust(&s, &ws, &policy); + + let record = s.audit().join("trusted-policies.jsonl"); + std::fs::set_permissions(&record, std::fs::Permissions::from_mode(0o666)).unwrap(); + let out = run_echo(&s, &ws, &[]); + assert_eq!( + code(&out), + 78, + "다른 사용자가 쓸 수 있는 기록 파일을 믿으면 안 됨: {}", + stderr(&out) + ); + assert!(stderr(&out).contains("불신"), "{}", stderr(&out)); + + let list = airlock( + &s, + &ws, + &[ + "policy", + "trust", + "--list", + "--audit-root", + s.audit().to_str().unwrap(), + ], + ); + assert_eq!(code(&list), 78, "{}", printed(&list)); +} + +#[test] +fn policy_trust_list_shows_the_latest_record_per_path() { + let s = Scratch::new("trust-list"); + let ws = work_dir(&s); + let policy = ws.join("airlock.toml"); + let list_args = |s: &Scratch| { + let audit = s.audit(); + vec![ + "policy".to_string(), + "trust".to_string(), + "--list".to_string(), + "--audit-root".to_string(), + audit.to_str().unwrap().to_string(), + ] + }; + + let args = list_args(&s); + let empty = airlock( + &s, + &ws, + &args.iter().map(String::as_str).collect::>(), + ); + assert_eq!(code(&empty), 0, "{}", printed(&empty)); + assert!(printed(&empty).contains("기록 없음"), "{}", printed(&empty)); + + std::fs::write(&policy, ALLOW_ALL_EXEC).unwrap(); + let first = trust(&s, &ws, &policy); + let first_digest = printed(&first) + .lines() + .find_map(|l| l.trim().strip_prefix("다이제스트 ")) + .map(str::to_string) + .expect("다이제스트 줄이 없음"); + + std::fs::write(&policy, format!("{ALLOW_ALL_EXEC}\n{EXTRA_RULE}")).unwrap(); + let second = trust(&s, &ws, &policy); + let second_digest = printed(&second) + .lines() + .find_map(|l| l.trim().strip_prefix("다이제스트 ")) + .map(str::to_string) + .expect("다이제스트 줄이 없음"); + assert_ne!(first_digest, second_digest); + + let args = list_args(&s); + let list = airlock( + &s, + &ws, + &args.iter().map(String::as_str).collect::>(), + ); + assert_eq!(code(&list), 0, "{}", printed(&list)); + let text = printed(&list); + assert!(text.contains(policy.to_str().unwrap()), "{text}"); + assert!( + text.contains(&second_digest), + "마지막 기록이 보여야 함: {text}" + ); + assert!( + !text.contains(&first_digest), + "덮인 기록은 목록에 나오면 안 됨: {text}" + ); + + // 같은 다이제스트를 다시 기록하면 줄을 늘리지 않습니다 + let again = trust(&s, &ws, &policy); + assert!(printed(&again).contains("이미"), "{}", printed(&again)); + let body = std::fs::read_to_string(s.audit().join("trusted-policies.jsonl")).unwrap(); + assert_eq!(body.lines().count(), 2, "{body}"); +} + +#[test] +fn an_explicit_policy_path_is_also_gated() { + let s = Scratch::new("trust-explicit"); + let ws = work_dir(&s); + let policy = s.path().join("elsewhere.toml"); + std::fs::write(&policy, ALLOW_ALL_EXEC).unwrap(); + + let out = run_echo(&s, &ws, &["--policy", policy.to_str().unwrap()]); + assert_eq!(code(&out), 78, "{}", stderr(&out)); + + trust(&s, &ws, &policy); + let out = run_echo(&s, &ws, &["--policy", policy.to_str().unwrap()]); + assert_eq!(code(&out), 0, "{}", stderr(&out)); +} + +#[test] +fn an_audit_root_inside_the_workspace_is_called_out() { + let s = Scratch::new("trust-inside"); + let ws = work_dir(&s); + let inside = ws.join("audit"); + let out = airlock( + &s, + &ws, + &[ + "run", + "--audit-dir", + inside.to_str().unwrap(), + "--yes", + "--", + "/bin/echo", + "hi", + ], + ); + assert_eq!(code(&out), 0, "{}", stderr(&out)); + assert!( + stderr(&out).contains("trusted-policies.jsonl"), + "기록 파일이 에이전트 손이 닿는 곳에 있다는 경고가 있어야 함: {}", + stderr(&out) + ); +} + // ---------- 작업 공간 안전장치 ---------- #[test] @@ -578,6 +897,232 @@ fn a_deleted_session_directory_is_caught_by_the_anchor_chain() { ); } +/// append 권한만 가진 공격자가 하는 일. 세션 체인 끝에 자체 정합적인 엔트리 `count` 개를 +/// 잇고, `fix_head` 면 head.json 도 새 끝으로 맞춥니다. 마지막 엔트리의 (seq, hash) 를 +/// 돌려줍니다 +fn forge_tail(dir: &Path, count: u64, fix_head: bool) -> (u64, airlock_audit::Hash) { + use airlock_audit::{Decision, Entry, Event, FileMode, Head, Record}; + use std::io::Write; + + let chain = dir.join("chain.jsonl"); + let entries: Vec = std::fs::read_to_string(&chain) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let mut last = entries.last().unwrap().clone(); + let mut body = String::new(); + for _ in 0..count { + let forged = Entry::seal( + last.seq + 1, + last.ts + 1, + last.session, + last.enforcement, + last.hash, + Record::new( + "pid:1 test", + Event::FileAccess { + path_requested: "/Users/me/.ssh/id_ed25519".into(), + path_resolved: "/Users/me/.ssh/id_ed25519".into(), + mode: FileMode::Read, + }, + Decision::Allow, + ), + ); + body.push_str(&serde_json::to_string(&forged).unwrap()); + body.push('\n'); + last = forged; + } + std::fs::OpenOptions::new() + .append(true) + .open(&chain) + .unwrap() + .write_all(body.as_bytes()) + .unwrap(); + if fix_head { + std::fs::write( + dir.join("head.json"), + serde_json::to_string_pretty(&Head { + version: 1, + seq: last.seq, + hash: last.hash, + session: last.session, + }) + .unwrap(), + ) + .unwrap(); + } + (last.seq, last.hash) +} + +#[test] +fn appending_after_close_with_a_fresh_anchor_fails_verify_and_report() { + // H2. chain.jsonl 과 anchors.jsonl 에 append 만 할 수 있는 공격자가 종료된 세션에 위조 + // 엔트리를 잇고 새 head 를 가리키는 앵커 한 줄을 더한다. 재앵커링을 "체크포인트" 로 + // 허용하면 verify 는 0, report 는 이상 없음이 된다 + let s = Scratch::new("report-reanchor"); + let ws = work_dir(&s); + one_session(&s, &ws); + + let session_dir = s.session(); + let first: airlock_audit::Entry = + serde_json::from_str(s.chain().lines().next().unwrap()).unwrap(); + let (seq, hash) = forge_tail(&session_dir, 2, true); + airlock_audit::AnchorLog::open(s.audit()) + .unwrap() + .append(first.session, seq, hash) + .unwrap(); + + let verify = airlock( + &s, + &ws, + &[ + "audit", + "verify", + "--anchor-dir", + s.audit().to_str().unwrap(), + session_dir.to_str().unwrap(), + ], + ); + assert_eq!(code(&verify), 2, "{}", printed(&verify)); + assert!( + printed(&verify).contains("다시 앵커함"), + "두 번째 앵커 줄이 실패로 보고되어야 함: {}", + printed(&verify) + ); + + let out = report(&s, &ws, &[]); + assert_eq!(code(&out), 2, "{}", printed(&out)); + assert!( + printed(&out).contains("anchor_chain_broken"), + "{}", + printed(&out) + ); + + let json = report_json(&s, &ws); + let kinds: Vec<&str> = json["anomalies"] + .as_array() + .unwrap() + .iter() + .filter_map(|a| a["kind"].as_str()) + .collect(); + assert!(kinds.contains(&"anchor_chain_broken"), "{kinds:?}"); + // session_end 뒤의 엔트리는 세션 체인 층에서도 따로 걸린다 + assert!(kinds.contains(&"integrity"), "{kinds:?}"); + assert_eq!(json["exit_code"], 2, "{json}"); +} + +#[test] +fn appending_after_session_end_without_a_new_anchor_fails() { + let s = Scratch::new("report-after-end"); + let ws = work_dir(&s); + one_session(&s, &ws); + + let session_dir = s.session(); + forge_tail(&session_dir, 1, true); + + let verify = airlock( + &s, + &ws, + &[ + "audit", + "verify", + "--anchor-dir", + s.audit().to_str().unwrap(), + session_dir.to_str().unwrap(), + ], + ); + assert_eq!(code(&verify), 2, "{}", printed(&verify)); + assert!( + printed(&verify).contains("session_end"), + "{}", + printed(&verify) + ); + + let out = report(&s, &ws, &[]); + assert_eq!(code(&out), 2, "{}", printed(&out)); +} + +#[test] +fn an_anchored_session_with_a_lagging_head_is_an_evidence_anomaly() { + // SIGKILL 로 죽어 앵커가 없는 세션에 공격자가 엔트리 하나를 잇고(head.json 은 못 건드림) + // 첫 앵커를 대신 쓴다. 세션 체인만 보면 크래시 잔여 경고이고 앵커는 head 와 일치한다 + use airlock_audit::{ + AnchorLog, AuditLog, Decision, Enforcement, Event, FileMode, GenesisInfo, Hash, Mediation, + Record, SessionId, + }; + + let s = Scratch::new("report-anchored-lag"); + let ws = work_dir(&s); + let dir = s.audit().join("sessions").join("1700000000000000000-1"); + let id = SessionId::from_bytes([4; 16]); + { + let mut log = AuditLog::create( + &dir, + id, + Enforcement::Observe, + true, + GenesisInfo { + airlock_version: "0.0.0-test".into(), + argv: vec!["airlock".into(), "run".into()], + cwd: "/tmp".into(), + policy_digest: Hash::ZERO, + policy_source: None, + mediation: Mediation::Off, + operator: None, + policy_signer: None, + }, + ) + .unwrap(); + log.append(Record::new( + "pid:1 test", + Event::FileAccess { + path_requested: "/tmp/a".into(), + path_resolved: "/tmp/a".into(), + mode: FileMode::Read, + }, + Decision::Allow, + )) + .unwrap(); + } + let (seq, hash) = forge_tail(&dir, 1, false); + AnchorLog::open(s.audit()) + .unwrap() + .append(id, seq, hash) + .unwrap(); + + let verify = airlock( + &s, + &ws, + &[ + "audit", + "verify", + "--anchor-dir", + s.audit().to_str().unwrap(), + dir.to_str().unwrap(), + ], + ); + assert_eq!(code(&verify), 2, "{}", printed(&verify)); + assert!( + printed(&verify).contains("크래시 잔여일 수 없음"), + "{}", + printed(&verify) + ); + + let out = report(&s, &ws, &[]); + assert_eq!(code(&out), 2, "{}", printed(&out)); + assert!( + printed(&out).contains("anchored_head_lag"), + "{}", + printed(&out) + ); + let json = report_json(&s, &ws); + assert_eq!( + json["sessions"][0]["anchor"]["status"], "mismatch", + "{json}" + ); +} + #[test] fn a_tampered_review_chain_makes_the_report_exit_non_zero() { let s = Scratch::new("report-review-tamper"); diff --git a/docs/audit-format.md b/docs/audit-format.md index e017c65..321fb87 100644 --- a/docs/audit-format.md +++ b/docs/audit-format.md @@ -244,7 +244,8 @@ SHA-256을 택한 이유는 FIPS 180-4 승인 알고리즘이고 어떤 언어 6. 각 엔트리의 `hash`를 6.2로 재계산한 값과 일치하는가 7. `ts`가 단조 비감소인가. 감소하면 경고로 보고하며, 벽시계 조정으로 정상 발생할 수 있으므로 실패로 처리하지 않습니다 8. 모든 `approval` 엔트리의 `for_seq`가 존재하고, 그 엔트리의 `decision`이 `ask`인가 -9. `head.json`의 `seq`와 `hash`가 마지막 엔트리와 일치하는가 +9. `session_end` 뒤에 엔트리가 없는가. 체인은 `session_end`로 끝나야 하며, 그 뒤의 엔트리는 앵커 유무와 무관하게 종료 뒤 덧붙이기이므로 실패입니다. `session_end`가 없는 체인(크래시)은 이 검사에 걸리지 않습니다 +10. `head.json`의 `seq`와 `hash`가 마지막 엔트리와 일치하는가 검증기는 체인을 스트리밍으로 처리합니다. 전체를 메모리에 올리면 큰 로그에서 검증 자체가 불가능해집니다. @@ -275,9 +276,11 @@ v1 체인은 변환하지 않고 거부합니다. 검증자는 "이 체인은 `head.json`은 잘라내기 탐지 전용입니다. 4절 순서상 엔트리 append와 `head.json` 갱신 사이에 크래시가 나면 `head.json`이 한 칸 뒤처질 수 있으므로, 검증기는 `head.seq`가 마지막 엔트리보다 정확히 1 작고 체인 자체가 온전한 경우를 크래시 잔여로 보고 경고로 처리합니다. 그 외의 불일치는 실패입니다. +이 예외는 **앵커 줄이 없는 세션에만** 적용됩니다. 4절 순서상 앵커는 엔트리와 `head.json`이 모두 `fsync`된 뒤에 쓰이므로, 앵커가 있는데 `head.json`이 뒤처진 상태는 정상 경로로 만들어질 수 없습니다. 앵커 대조 단계가 그 상태를 실패로 올립니다 (8.3). 제네시스가 `fsync_per_entry: false`인 세션만 예외입니다. 그 세션은 엔트리와 `head.json`의 내구화 순서가 보장되지 않아 앵커가 먼저 디스크에 닿을 수 있으므로 경고를 유지합니다. + ## 8. 세션 상위 앵커 체인 -세션은 서로 독립된 체인이고 제네시스의 `prev`는 항상 0입니다. 그래서 세션 디렉토리를 통째로 지우면 남은 파일 어디에도 흔적이 없습니다. 앵커 체인은 세션 바깥에 세션마다 한 줄을 남겨 그 구멍을 좁힙니다. +세션은 서로 독립된 체인이고 제네시스의 `prev`는 항상 0입니다. 그래서 세션 디렉토리를 통째로 지우면 남은 파일 어디에도 흔적이 없습니다. 앵커 체인은 세션 바깥에 세션마다 **정확히 한 줄**을 남겨 그 구멍을 좁힙니다. 브로커는 `session_end`를 쓴 뒤 세션마다 한 번만 앵커하며 중간 체크포인트 앵커는 존재하지 않습니다. ### 8.1 파일과 스키마 @@ -318,14 +321,21 @@ hash = SHA-256( ### 8.3 검증 -앵커 체인 자체의 검증은 감사 체인과 같은 순서를 따릅니다. 파싱 -> 버전 -> `seq` 무결번 -> 첫 줄 `prev == 0` -> `prev` 연결 -> 해시 재계산. 여기에 앵커 고유 검사 둘을 더합니다. +앵커 체인 자체의 검증은 감사 체인과 같은 순서를 따릅니다. 파싱 -> 버전 -> `seq` 무결번 -> 첫 줄 `prev == 0` -> `prev` 연결 -> 해시 재계산. 여기에 앵커 고유 검사 하나를 더합니다. + +**같은 세션이 두 번 이상 나타나면 실패입니다.** 세션당 앵커는 정확히 하나입니다. 사유는 두 번째 줄의 `head_seq`에 따라 갈라 보고하되 셋 다 실패입니다. -- **같은 세션이 같은 `head_seq`로 두 번 나타나면 실패입니다.** 앵커 줄의 재기록이나 복제를 뜻합니다. -- **같은 세션이 다시 나타났는데 `head_seq`가 이전보다 크지 않으면 실패입니다.** 세션 체인은 자라기만 하므로, 더 짧은 상태로 다시 앵커되는 것은 되감기입니다. +- 같은 `head_seq`면 앵커 줄의 재기록이나 복제입니다 (`SessionDuplicated`). +- 더 작은 `head_seq`면 되감기입니다 (`HeadSeqRegressed`). 세션 체인은 자라기만 합니다. +- 더 큰 `head_seq`면 종료 뒤 덧붙이기입니다 (`SessionReanchored`). -같은 세션의 `head_seq`가 이전보다 크면 통과이며 경고로 보고합니다. 세션 중간 체크포인트 앵커를 허용하기 위한 것입니다. +두 번째 줄을 경고로 통과시키면 `chain.jsonl`과 `anchors.jsonl`에 append만 할 수 있는 주체가 종료된 세션에 엔트리를 덧붙이고 새 head를 잇는 앵커 한 줄을 더하는 것만으로 검증을 통과시킵니다. 그 경로를 "체크포인트"라는 이름으로 열어 두면 8.4의 약속이 무효가 됩니다. -세션 체인과 앵커의 대조는 별도 단계입니다. 세션의 실제 `(head_seq, head_hash)`를 앵커 기록과 비교해 다르면 실패로 보고합니다. 이때 **체인이 앵커보다 길어도 실패**입니다. 앵커는 세션 종료 시점에 쓰므로, 앵커 이후에 자란 체인은 종료 뒤에 덧붙인 엔트리를 뜻합니다. +세션 체인과 앵커의 대조는 별도 단계입니다. 앵커의 `head_seq`는 그 세션 체인의 **마지막** `seq`와 같아야 하고, `head_hash`는 그 엔트리의 `hash`와 같아야 합니다. + +- **체인이 앵커보다 길면 실패입니다** (`EntriesAfterAnchor`). 앵커는 세션 종료 시점에 쓰므로, 앵커 이후에 자란 체인은 종료 뒤에 덧붙인 엔트리를 뜻합니다. +- 체인이 앵커보다 짧거나, 같은 `seq`에서 해시가 다르면 실패입니다 (`SessionHeadMismatch`). 잘라내기이거나 체인 재계산입니다. +- **앵커가 있는 세션에서 `head.json`이 체인보다 한 칸 뒤처지면 실패입니다** (`AnchoredHeadLag`). 7.2의 크래시 잔여 예외는 앵커 없는 세션에만 적용됩니다. 브로커는 엔트리와 `head.json`을 `fsync`한 뒤에야 앵커를 쓰므로, 앵커가 있는데 `head.json`이 뒤처진 상태는 앵커되지 않은 세션에 엔트리 하나와 앵커 줄을 덧붙인 흔적입니다. 제네시스가 `fsync_per_entry: false`인 세션만 예외이며 그 경우 경고로 남습니다. 앵커 파일이 아예 없으면 "탐지 불가"로 보고합니다. 통과가 아닙니다. 없는 앵커를 조용히 넘기면 앵커를 지우는 것이 가장 값싼 공격이 됩니다. @@ -337,7 +347,8 @@ hash = SHA-256( - 세션 디렉토리 통째 삭제. 앵커 줄이 남아 있으므로 "이 세션이 있었고 head가 무엇이었다"가 남습니다. - 세션 체인 전체 재계산. `head.json`까지 다시 써도 앵커의 `head_hash`와 어긋납니다. -- 세션 종료 뒤 엔트리 덧붙이기. `head_seq`가 앵커보다 앞서 나갑니다. +- 세션 종료 뒤 엔트리 덧붙이기. 체인이 앵커보다 길어지고(`EntriesAfterAnchor`), `session_end` 뒤에 엔트리가 오며(7절 9단계), 새 head를 잇는 앵커 줄을 덧붙여도 그 두 번째 줄 자체가 실패입니다(`SessionReanchored`). `chain.jsonl`과 `anchors.jsonl` 양쪽에 append할 수 있는 주체도 이 셋을 동시에 피하지 못합니다. +- 앵커되지 않은 세션에 엔트리 하나와 앵커 줄을 덧붙이기. `head.json`이 뒤처진 채 앵커가 있으면 실패입니다(`AnchoredHeadLag`). 둘 이상이면 `head.json` 불일치로 실패입니다. - 앵커 줄 자체의 수정·삭제·재배치. **막지 못하는 것.** @@ -345,6 +356,8 @@ hash = SHA-256( - **앵커 파일이 같은 디렉토리 트리에 있고 대상이 거기에 쓸 수 있으면 아무것도 막지 못합니다.** 세션 체인을 다시 계산할 수 있는 주체는 앵커 체인도 같은 비용으로 다시 계산합니다. 키도 서명도 없으므로 재계산에 필요한 비밀이 없습니다. - 앵커 파일 자체의 삭제. 검증자는 "탐지 불가"라고 말할 수 있을 뿐 원래 무엇이 있었는지 복원하지 못합니다. - 애초에 앵커되지 않은 세션. SIGKILL로 죽은 세션은 앵커 줄이 없고, 그 사실만으로는 크래시와 삭제를 구분하지 못합니다. +- **앵커되지 않은 세션의 첫 앵커.** 앵커는 브로커가 앵커한 세션에 대해서만 증언합니다. `anchors.jsonl`에 append할 수 있는 주체는 크래시로 앵커가 없는 세션의 첫 앵커를 대신 쓸 수 있고, 그 주체가 `head.json`까지 다시 쓸 수 있으면 덧붙인 엔트리와 가짜 `session_end`도 함께 정당해 보입니다. `head.json`을 건드리지 못하는 주체는 위의 `AnchoredHeadLag`에 걸립니다. +- `fsync_per_entry: false` 세션의 한 칸 뒤처짐. 그 세션에서는 앵커가 있어도 8.3의 예외가 유지되므로 엔트리 하나의 위조가 경고로 남습니다. - 시각. `ts`는 벽시계이며 앵커도 같은 시계를 씁니다. 곧 앵커의 실질 탐지력은 **저장 위치의 분리**에서만 나옵니다. 다른 볼륨, 다른 계정 소유, 원격 append-only 마운트 중 하나가 아니면 이 파일은 크래시 탐지기이지 변조 탐지기가 아닙니다. 문서가 이 구분을 흐리면 사용자는 있지도 않은 보장을 믿게 됩니다. @@ -380,9 +393,10 @@ airlock audit report [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--json] 증거 이상(`2`)으로 세는 것. -- 세션 체인의 무결성 실패 (해시·`seq`·`prev`·`head` 불일치) +- 세션 체인의 무결성 실패 (해시·`seq`·`prev`·`head` 불일치, `session_end` 뒤의 엔트리) - `FormatVersionUnsupported`. 옛 포맷은 변조가 아니지만 이 검증자가 판정할 수 없으므로 통과로 보고하지 않습니다 -- 앵커 대조 실패 +- 앵커 대조 실패. 체인이 앵커보다 긴 것과 앵커가 있는데 `head.json` 이 뒤처진 것을 포함합니다 +- 앵커 체인 자체의 검증 실패. 같은 세션의 두 번째 앵커 줄을 포함합니다 - **앵커 없음.** 앵커 파일이 아예 없는 경우와 그 세션의 앵커 줄이 없는 경우를 모두 포함합니다 - **앵커에는 있는데 디렉토리가 없는 세션.** 남은 세션만 훑으면 세션 통째 삭제가 "이상 없음" 으로 보고됩니다 - 어떤 이유로든 세션을 읽지 못함 @@ -401,6 +415,23 @@ airlock audit report [--since YYYY-MM-DD] [--until YYYY-MM-DD] [--json] JSON 최상위에 `locale` 필드가 있습니다. 이상 `detail` 같은 산문 필드는 출력 로케일(`docs/i18n.md`)을 따르므로 어느 로케일로 생성된 보고인지를 남깁니다. `kind` · `status` · `verdict` 와 JSON 키는 로케일과 무관하게 고정이며, 기계 분류는 그 필드로 해야 합니다. +이상의 `kind` 는 아래 고정 목록입니다. `detail` 은 산문이며 로케일을 따르지만 `kind` 는 바뀌지 않습니다. + +| `kind` | 등급 | 뜻 | +|------------------------|----|----------------------------------------------------------| +| `integrity` | 증거 | 세션 체인 무결성 실패. `session_end` 뒤의 엔트리 포함 | +| `anchor_absent` | 증거 | `anchors.jsonl` 없음 | +| `anchor_chain_broken` | 증거 | 앵커 체인 자체의 검증 실패. 같은 세션의 두 번째 앵커 줄 포함 | +| `anchor_missing` | 증거 | 그 세션의 앵커 줄 없음 | +| `anchor_mismatch` | 증거 | 앵커의 head 와 체인의 head 가 다름. 재계산이거나 잘라내기 | +| `entries_after_anchor` | 증거 | 체인이 앵커보다 김. 종료 뒤 덧붙이기 | +| `anchored_head_lag` | 증거 | 앵커가 있는 세션의 `head.json` 이 체인보다 뒤처짐. 앵커 없는 세션에 엔트리와 앵커를 덧붙임 | +| `session_missing` | 증거 | 앵커에는 있는데 세션 디렉토리가 없음 | +| `session_unreadable` | 증거 | 세션을 읽지 못함 | +| `review_chain_broken` | 증거 | 확인 체인 손상 | +| `unanswered_ask` | 운영 | 답을 받지 못한 `ask` | +| `automatic_approval` | 운영 | `--strict-approval` 을 준 경우, 사람 신원 없이 허용된 승인 | + ### 9.3 `airlock audit ack` 책임자가 점검했다는 사실을 append-only 체인에 남깁니다. @@ -511,6 +542,10 @@ report_digest = SHA-256( - 세션 체인을 통째로 재계산해 `head.json`까지 맞춰도 앵커와 대조하면 탐지 - 앵커 파일 없음 -> "탐지 불가"로 명확히 보고 - 같은 세션의 중복 앵커와 `head_seq` 되감기 -> 탐지 +- 같은 세션을 더 긴 `head_seq`로 다시 앵커 -> 경고가 아니라 실패. 조회도 "가장 나중 줄"을 돌려주지 않고 실패 +- 종료된 세션에 엔트리를 덧붙이고 새 head를 잇는 앵커 줄을 더함 -> `verify` 실패, `report` 비영 +- `session_end` 뒤에 엔트리 -> 앵커 유무와 무관하게 실패 +- 앵커 없는 세션에 엔트리 하나와 첫 앵커 줄을 덧붙임(`head.json`은 그대로) -> `AnchoredHeadLag` 실패. `fsync_per_entry: false` 세션은 경고 유지 - 깨진 앵커 체인에 이어 붙이기 거부 - 확인 줄 하나 삭제·수정·재배치·복제 -> 탐지 - 확인 줄의 `reviewer_uid`나 `reviewer_tty`를 고치면 -> 탐지 diff --git a/docs/design.md b/docs/design.md index 1de5cc3..5071b8a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -149,20 +149,23 @@ Rego/OPA 차용은 기각했습니다. 근거는 세 가지입니다. | `observe` | 전 플랫폼 | 없음 | 없음 (기록만) | 구현됨 | | Seatbelt (`sandbox_init_with_parameters`) | macOS | 파일 경로, exec 경로, 아웃바운드 전체 | 커널 강제, 우회 난이도 중 | 구현됨 | | Landlock | Linux | 파일 경로(inode), exec 경로, TCP 포트 | 커널 강제, 우회 난이도 높음 | 구현됨 | -| seccomp user notification | Linux | exec, connect, 선택적 open | 관측과 승인 채널. 강제는 아님 | 구현됨 | +| seccomp user notification | Linux | exec, connect, 선택적 open과 rename·link·symlink | 관측과 승인 채널. 강제는 아님 | 구현됨 | +| seccomp tty ioctl 거부 | Linux | `TIOCSTI`, `TIOCLINUX` | 커널 거부(EPERM), 중계 수준과 무관 | 구현됨 | | Endpoint Security Framework | macOS | - | 커널 강제, 시스템 전역 | 로드맵 | **강제 범위가 플랫폼마다 다르다는 사실 자체를 제품이 노출합니다.** 같은 정책 파일이 두 OS 에서 같은 결론을 내지 않습니다. macOS는 exec 경로를 커널에서 막을 수 있지만 argv 조건은 볼 수 없고 포트 단위 egress도 표현할 수 없습니다. Linux는 포트까지 강제하지만 호스트 단위 egress는 표현할 수 없습니다. 옮기지 못한 규칙은 조용히 버리지 않고 enforcer의 gap 목록에 담아 배너로 출력합니다. 미강제를 침묵하면 감사 로그가 거짓 보증을 하게 됩니다. **exec 는 이제 양쪽 다 커널 경계입니다.** `[defaults].exec` 이 `allow` 가 아닌 정책에서는 두 백엔드 모두 exec 을 블랙리스트가 아니라 화이트리스트로 겁니다. 허용 목록에 없는 프로그램은 커널이 `execve` 자체를 거부하며, 최상위 프로그램은 강제 층이 spawn 직전에 목록에 넣습니다. `[defaults].exec = "allow"` 인 정책은 이전 동작을 그대로 둡니다. 그 설정은 "실행은 기본 허용"이라고 말하고 있으므로 뒤집으면 같은 정책 파일의 뜻이 바뀌기 때문입니다. 커널 경계가 되는 것은 **프로그램 경로까지**이며 argv 조건은 어느 커널 인터페이스로도 표현할 수 없어 여전히 중계 층의 tripwire 입니다([policy-dsl.md 7.1절](policy-dsl.md#71-exec-규칙의-한계를-분명히-한다)). +같은 seccomp 필터에 한 가지 **커널 거부**가 실려 있습니다. `ioctl(TIOCSTI)` 과 `ioctl(TIOCLINUX)` 는 `EPERM` 입니다. 자식은 제어 터미널을 상속받으므로 이 둘로 입력 큐에 `y` 를 밀어 넣으면 `/dev/tty` 에서 답을 읽는 승인 프롬프트가 위조됩니다(9.4). 정상 프로그램은 둘 다 쓰지 않습니다. 인자가 경로가 아니라 정수라 TOCTOU 가 없으므로 이것만은 중계가 아니라 거부이고, `--mediate off` 에서도 Landlock 강제 층이 같은 최소 필터를 겁니다. macOS 는 Seatbelt 가 같은 ioctl 을 거부합니다. + `ask` 는 커널이 표현할 수 없어 백엔드마다 다르게 내려갑니다. 파일 규칙은 deny로 강하하고, macOS의 exec 규칙은 **프로파일에 넣지 않습니다.** macOS에서 답을 받을 수 있는 ask는 브로커가 spawn 전에 묻는 최상위 exec 하나뿐인데, 그것을 deny로 내리면 사람이 승인한 실행이 커널에서 막혀 어떤 방법으로도 진행할 수 없기 때문입니다. 이 비대칭은 gap으로 노출합니다. **중계 수준도 강제력 등급과 같은 취급을 받습니다.** 요청한 `--mediate` 값이 그 플랫폼에서 적용되지 않으면(macOS 에는 중계 기구가 없습니다) 배너가 요청값과 적용값을 함께 보여 주고, 감사 로그 제네시스에는 **실제로 적용된 값**을 기록합니다. 이 값이 없으면 `exec` 엔트리가 없는 체인이 "아무 일도 없었음"인지 "보이지 않았음"인지 구분되지 않습니다. Landlock은 **allow만 표현할 수 있고 규칙이 inode에 걸립니다.** deny 규칙을 그대로 옮길 수 없고 허용한 하위 트리 안에서 일부만 빼는 것도 불가능하므로, 백엔드는 규칙을 번역하는 대신 허용할 트리를 실제로 순회하며 거부 결정이 나는 항목을 빼는 방식으로 계획을 세웁니다. 하위에 거부 대상이 없는 디렉토리는 통째로 한 규칙이 되어 규칙 수가 시크릿 경로의 깊이에 비례합니다. 순회 비용이 시작 시간을 지배하지 않도록, 어떤 규칙에도 닿을 수 없는 항목은 값싼 사전 필터로 걸러 냅니다. -seccomp user notification 층은 강제 층이 아니라 **관측과 승인 채널**입니다. 자식 프로세스가 부르는 `execve`/`connect`와, 선택적으로 `openat`을 브로커로 중계해 감사 엔트리를 남기고 `ask` 승인을 받습니다. 이 층이 없으면 에이전트가 스폰한 자식 프로세스의 행위가 감사 로그에 전혀 남지 않습니다. 다만 인자를 읽은 시점과 커널이 실제로 여는 시점 사이에 링크가 바뀔 수 있어 구조적으로 TOCTOU를 부담하므로([10.1절](#101-landlock은-호스트-단위-네트워크-정책을-표현할-수-없다)), 파일 접근의 실제 경계는 Landlock이고 이 층은 그 위의 기록 층입니다. +seccomp user notification 층은 강제 층이 아니라 **관측과 승인 채널**입니다. 자식 프로세스가 부르는 `execve`/`connect`와, 선택적으로(`--mediate full`) `openat`과 `rename`/`link`/`symlink` 계열을 브로커로 중계해 감사 엔트리를 남기고 `ask` 승인을 받습니다. 이름 변경은 원본을 `delete`, 목적지를 `create` 로 판정하므로 자기보호 규칙이 `openat` 으로 만들 수 없는 정책 파일을 옆 파일의 이름을 바꿔 만드는 것까지 막습니다. 이 층이 없으면 에이전트가 스폰한 자식 프로세스의 행위가 감사 로그에 전혀 남지 않습니다. 다만 인자를 읽은 시점과 커널이 실제로 여는 시점 사이에 링크가 바뀔 수 있어 구조적으로 TOCTOU를 부담하므로([10.1절](#101-landlock은-호스트-단위-네트워크-정책을-표현할-수-없다)), 파일 접근의 실제 경계는 Landlock이고 이 층은 그 위의 기록 층입니다. `observe` 백엔드를 1순위로 둔 것은 철학적 개발 타협이 아니라 설계 요구사항입니다. 강제 없이 기록만 하는 모드는 학습 모드(false positive 튜닝)와 정책 드라이런의 기반이고, 8장에서 식별한 "false positive로 인한 개발자 불편" 리스크의 직접적 대응책입니다. @@ -182,6 +185,41 @@ seccomp user notification 층은 강제 층이 아니라 **관측과 승인 채 로케일을 정책 파일이 아니라 별도 구성 파일(`~/.config/airlock/config.toml`)에 두는 이유는 하나입니다. 정책 파일에 두면 표시 언어를 바꿀 때마다 정책 다이제스트가 달라져 감사 로그에 소음이 생깁니다. 결정 순서와 다이제스트 결합의 한계는 [`docs/i18n.md`](./i18n.md)와 [`docs/limitations.md`](./limitations.md) 7.18에 있습니다. +### 9.6 정책 출처 신뢰: 로드 시점 TOFU 기록 + +**위협.** `airlock run`은 `--policy`가 없으면 `cwd/airlock.toml` -> `cwd/.airlock.toml` -> `~/.config/airlock/policy.toml` 순으로 첫 파일을 읽습니다. 자기보호 규칙(티어 0)은 이 후보 전부를 쓰기 거부로 두지만, Linux Landlock은 inode 에 걸리므로 **아직 없는 경로의 생성을 거부할 수 없고**, 작업 공간 루트는 통째로 쓰기 허용입니다. 곧 샌드박스 안 에이전트가 `./airlock.toml`이 없을 때 그것을 만들거나, 같은 디렉토리 안에서 `rename`으로 기존 파일을 갈아 끼울 수 있습니다. 다음 `airlock run`은 그 파일을 홈 전역 정책보다 먼저 읽습니다. macOS 는 Seatbelt 의 리터럴 거부가 생성까지 막지만, 같은 보장이 플랫폼과 무관하게 필요합니다. `--policy`로 작업 공간 안의 파일을 명시한 경우도 같은 노출을 가집니다. + +**결정.** 정책 파일을 **읽는 시점**에 그 파일이 사람이 승인한 것인지 확인합니다(trust on first use). 커널 강제 층이 놓친 생성과 교체를 로드 시점에서 잡는 두 번째 방어선이며, 첫 번째 방어선(자기보호 규칙, Linux `--mediate full`의 rename 중계)을 대체하지 않습니다. + +**기록 파일.** `<감사 루트>/trusted-policies.jsonl`. 감사 루트는 자기보호(`self:audit-log`) 대상이라 에이전트가 쓸 수 없고, Linux 에서는 기본값이 작업 공간 밖입니다. 한 줄이 승인 하나입니다. + +```json +{"v":1,"path":"/Users/me/work/airlock.toml","digest":"<64 hex>","trusted_at":"2026-09-03T01:02:03.000000000Z","uid":501} +``` + +- `path`는 심볼릭 링크와 `..`을 해소한 절대 경로입니다. `digest`는 [`policy-dsl.md` 11절](./policy-dsl.md#11-정책-다이제스트)의 정책 다이제스트를 hex 로 적은 것이며 감사 제네시스의 `policy_digest`와 같은 값입니다. 주석과 공백과 출력 로케일에 무관하고, 규칙 하나의 `action`이나 순서가 바뀌면 달라집니다. +- append-only 이며 같은 `path`의 **마지막 줄**이 유효합니다. 취소는 새 줄을 쓰는 것이 아니라 파일에서 지우는 것이며, 그 판단은 사람이 파일을 열어 합니다. +- 열 때는 `O_NOFOLLOW | O_CLOEXEC | O_APPEND`, 새로 만들 때는 0600 입니다. 읽을 때 소유 uid 가 실제 uid 와 다르거나 그룹이나 타인의 쓰기 비트가 있으면 **파일 전체를 불신**합니다. 정책 파일에 적용하는 기준(`read_trusted`)과 같습니다. +- 한 줄이라도 해석할 수 없으면 그 줄만 건너뛰지 않고 **파일 전체를 불신**하고 경고를 냅니다. 잘못된 줄 하나가 "뒤쪽 줄은 정당하다"는 인상을 만들면 안 되기 때문입니다. 파일이 없으면 기록이 없는 것입니다. + +**흐름.** `airlock run`은 정책 파일을 실제로 읽는 모든 경우(탐색된 후보든 `--policy` 명시든)에 `Policy::load_file`이 성공한 직후, 브로커를 띄우기 전에 검사합니다. 베이스라인만 쓰는 실행(정책 파일 없음)은 대상이 아닙니다. + +- 기록의 다이제스트와 일치하면 조용히 진행하고 배너에 한 줄 남깁니다. +- 기록이 없거나 다이제스트가 다르면 `/dev/tty`를 새로 열어(9.4 와 같은 채널) 경로, 다이제스트, 이전에 기록된 다이제스트(있으면), 규칙 수를 보여 주고 `[y/N]`을 묻습니다. `y`만 승인입니다. 승인하면 기록을 append 하고 진행합니다. 그 밖의 답은 전부 거부이며 종료 코드 78 로 끝나고 `airlock policy trust `를 안내합니다. +- `/dev/tty`를 열 수 없으면 묻지 않고 같은 거부입니다. 헤드리스 실행에서 처음 보는 정책은 사람이 먼저 `airlock policy trust`로 승인해야 합니다. +- **`--yes`는 이 확인을 건너뛰지 못합니다.** 에이전트가 심은 정책이 바로 다음 `--yes` 실행에서 읽히는 것이 이 위협의 시나리오이므로, `--yes`가 우회가 되면 방어가 없는 것과 같습니다. `--observe`도 같은 검사를 받습니다. 환경 변수나 플래그로 이 확인을 끄는 길은 두지 않습니다. + +`airlock policy trust [PATH]`는 같은 기록을 사람이 직접 남기는 명령입니다. PATH 를 생략하면 탐색 후보 중 첫 파일이며, `Policy::load_file`로 검증한 뒤 다이제스트를 출력하고 기록합니다. 사용자가 직접 치는 명령이므로 tty 여부와 무관하게 동작합니다. `--list`는 기록을 보여 줍니다. `airlock setup`은 정책 파일을 쓴 직후 그 파일을 자동으로 기록합니다. 사용자가 방금 생성에 동의했으므로 첫 실행에서 다시 묻지 않습니다. + +**우회 불가 근거.** + +- 기록 파일은 감사 루트에 있습니다. 감사 루트는 자기보호 대상이고 에이전트의 쓰기가 거부됩니다. 에이전트가 정책을 심을 수 있어도 그 정책의 다이제스트를 기록에 넣을 수 없습니다. +- 확인 채널은 `/dev/tty`이고 강제 층은 자식에게 이 노드를 열어 주지 않습니다(9.4). 에이전트가 자기 stdin 으로 `y`를 밀어 넣을 수 없습니다. +- 다이제스트는 정책의 **의미**에 걸립니다. 기록된 파일과 같은 경로에 다른 규칙 집합을 놓으면 다이제스트가 달라져 다시 묻습니다. 다른 경로에 같은 내용을 놓아도 `path`가 다르므로 다시 묻습니다. +- 검사는 실제로 읽은 `Policy` 객체의 다이제스트로 하고 같은 객체를 브로커에 넘기므로, 검사와 사용 사이에 파일이 바뀌어도 판정 대상은 달라지지 않습니다. + +**남는 한계.** `--audit-dir`가 작업 공간 안을 가리키면 기록 파일도 에이전트 손이 닿는 곳에 놓입니다. 배너가 이것을 경고하며 [`limitations.md` 6.12](./limitations.md#612-정책-로드와-출처-신뢰)에 적혀 있습니다. 기록 자체는 해시체인이 아니며, 감사 루트에 쓸 수 있는 주체는 기록도 쓸 수 있습니다. 앵커와 같은 신뢰 모델입니다. + ## 10. 기술 제약 (2026-07 조사 기준) 개발 전 조사 작업에서 확인된 제약이며, 설계를 바꾸는 사실만 남깁니다. @@ -203,7 +241,7 @@ MVP는 전자를 택합니다. 후자는 IP 기반 정책까지만 표현할 수 현재 Linux 백엔드는 Landlock ABI v4 이상에서 **포트 단위까지** 강제합니다. 정책에 호스트 패턴이나 IP 리터럴이 있으면 그 사실을 경고와 gap 으로 노출합니다. 그와 별개로 seccomp 중계 층이 `connect`의 `sockaddr`을 읽어 IP 와 포트를 감사에 남기므로, 호스트 단위 강제는 아직 없어도 무엇으로 나갔는지는 사후에 증명할 수 있습니다. -전자의 구체 규격은 [`docs/egress-proxy.md`](./egress-proxy.md)입니다. 요지는 프록시 자체가 강제 장치가 아니라는 것입니다. 커널 층이 루프백 외 아웃바운드를 통째로 막아 프록시를 유일한 출구로 만들고, 프록시는 그 위에서 호스트를 판정합니다. macOS는 Seatbelt를 `(allow network-outbound (remote ip "localhost:<포트>"))`로 좁혀 이 경계가 성립하며, 프록시 설정을 무시하는 클라이언트는 우회하지 못하고 연결에 실패합니다. +전자의 구체 규격은 [`docs/egress-proxy.md`](./egress-proxy.md)입니다. 요지는 프록시 자체가 강제 장치가 아니라는 것입니다. 커널 층이 루프백 외 아웃바운드를 통째로 막아 프록시를 IP 연결의 유일한 출구로 만들고, 프록시는 그 위에서 호스트를 판정합니다. macOS는 Seatbelt를 `(allow network-outbound (remote ip "localhost:<포트>"))` 와 시스템 데몬 유닉스 소켓 리터럴 두 개로 좁혀 이 경계가 성립하며, 프록시 설정을 무시하는 클라이언트는 우회하지 못하고 연결에 실패합니다. 정확한 조건은 `docs/egress-proxy.md` 6.1 입니다. **Linux는 아직 그 경계가 없습니다.** Landlock 포트 목록을 프록시 포트 하나로 줄이는 데까지만 구현되어 있어, 자식이 같은 포트로 외부 호스트에 직접 연결하면 프록시를 건너뜁니다. 자식을 새 netns에 넣는 후속 단계가 들어와야 Linux도 같은 결론을 냅니다. 그때까지 이 차이는 enforcer gap 으로 배너에 노출합니다. diff --git a/docs/egress-proxy.md b/docs/egress-proxy.md index 8a82cb8..814fd61 100644 --- a/docs/egress-proxy.md +++ b/docs/egress-proxy.md @@ -64,10 +64,24 @@ GET http://example.com/path HTTP/1.1 ### 3.3 거부하는 것 -- origin-form 요청(`GET /path`). 프록시로 온 요청인데 목적지가 없으므로 판정 대상을 특정할 수 없습니다. `400`입니다. +헤드 파서는 규격에서 조금이라도 벗어나면 받지 않습니다. 애매하게 받아 주는 파서는 프록시가 판정한 목적지와 업스트림이 보는 목적지를 갈라놓기 때문입니다. 거부된 요청은 판정에 닿지 않고, 업스트림 연결을 열지 않으며, 결과 엔트리를 남기지 않습니다. CONNECT 의 헤더는 목적지로 넘기지 않지만 형태 검사는 평문 요청과 똑같이 받습니다. + +`400`이며 연결을 닫는 것입니다. + +- origin-form 요청(`GET /path`). 프록시로 온 요청인데 목적지가 없으므로 판정 대상을 특정할 수 없습니다. +- CRLF 쌍의 일부가 아닌 CR 이나 LF. `X-A: xHost: evil.example` 처럼 값 안에 숨은 LF 는 CRLF 로만 자르는 파서를 지나 업스트림에서 두 번째 Host 가 됩니다. 요청 라인과 헤더 어디에 있든 거부합니다. +- 요청 라인이나 헤더 이름과 값에 든 제어 문자(0x00-0x1F, 0x7F). 헤더 값 안의 HTAB 만 예외이고 request-target 에서는 HTAB 도 거부합니다. 요청 라인에서 HTAB 를 공백으로 읽는 서버가 있어 목적지가 어디서 끝나는지가 갈립니다. +- obs-fold(SP 나 HTAB 로 시작하는 줄), 헤더 이름과 콜론 사이의 공백, RFC 9110 token 이 아닌 헤더 이름. +- Host 헤더가 둘 이상인 헤드. 평문 요청의 Host 는 어차피 판정한 authority 로 다시 쓰지만, 무엇을 받는지의 기준이 요청 종류마다 달라지면 그 차이가 곧 앞뒤 서버의 해석 차이가 됩니다. +- `HTTP/1.0` 과 `HTTP/1.1` 외의 버전. 버전 문자열을 그대로 재방출하므로 `HTTP/1.x` 같은 형태를 통과시키지 않습니다. +- 비ASCII, 공백, 구분자가 든 호스트. 허용 문자는 ASCII 영숫자와 `-` `.` `_` 와 대괄호 안 v6 리터럴의 `:` 뿐입니다. 프록시는 호스트를 정규화하지 않으며, 판정에 쓰는 호스트와 실제 접속과 감사 기록에 쓰는 호스트는 같은 문자열 하나입니다. +- 빈 줄 하나로 끝나지 않는 헤드. 읽기 층이 첫 `CRLF CRLF` 에서 끊으므로 파서도 같은 경계를 요구합니다. 그 사이에 빈 줄이 더 있으면 두 층이 다른 헤드를 보게 됩니다. +- userinfo(`user@host`)가 든 authority, 대괄호 없는 v6 리터럴, 포트 0 과 범위 밖 포트와 숫자가 아닌 포트, CONNECT 의 포트 생략. + +그 외입니다. + - 요청 라인 8KiB 초과, 헤더 총합 64KiB 초과. `431`입니다. -- 프록시 자신의 주소로 향하는 CONNECT. 무한 루프를 막습니다. `403`입니다. -- 헤더에 개행이 섞인 형태(요청 스머글링 시도). `400`이며 연결을 닫습니다. +- 프록시 자신의 주소로 향하는 CONNECT. 무한 루프를 막습니다. 판정 뒤 이름 해석 단계에서 걸러지며 `502`입니다. ## 4. 판정과 감사 @@ -120,29 +134,51 @@ v1은 릴레이 구간에서 내용을 해석하지 않습니다. 그 다음 단 ## 6. 플랫폼 결합 -프록시가 유일한 출구가 되게 만드는 부분입니다. 이것이 없으면 프록시는 그냥 편의 기능입니다. +프록시를 실제 경계로 만드는 부분입니다. 이것이 없으면 프록시는 그냥 편의 기능입니다. "프록시가 유일한 출구"라는 말은 플랫폼과 조건이 붙어야 참이 되며, 그 조건은 아래에 정확히 적습니다. ### 6.1 macOS (Seatbelt) -egress 프록시가 켜지면 프로파일의 아웃바운드 규칙을 루프백 한정으로 좁힙니다. +egress 프록시가 켜지면(`--egress-proxy`가 있고 `--no-network`가 없을 때) 프로파일의 아웃바운드 규칙을 루프백 한 포트와 시스템 데몬 소켓 두 개로 좁힙니다. ``` -(deny network-outbound) +(deny default) (allow network-outbound (remote ip "localhost:<프록시포트>")) -(allow network-outbound (remote unix)) +(allow network-outbound (literal "/private/var/run/mDNSResponder")) +(allow network-outbound (literal "/private/var/run/syslog")) ``` Darwin 25.4(macOS 26.4) arm64에서 동작을 확인했습니다. 허용 포트는 연결되고, 같은 루프백의 다른 포트와 외부 IP는 커널이 거부합니다. `localhost:<포트>`는 IPv4와 IPv6 루프백 양쪽에 매칭합니다. +**유닉스 도메인 소켓은 통째로 열지 않습니다.** 이전 프로파일은 `(allow network-outbound (remote unix))`를 무조건 방출했고, 그것은 자식이 호스트의 어떤 유닉스 소켓에든 붙을 수 있다는 뜻이었습니다. Darwin 25에서 실측한 결과 자식은 바깥에서 띄운 `nc -lU` 리스너로 데이터를 내보냈고, 상속된 `SSH_AUTH_SOCK`으로 ssh-agent에 서명을 요청할 수 있었으며, `/private/var/run/docker.sock`이 살아 있는 호스트라면 컨테이너 데몬에 그대로 닿았습니다. 전부 프록시도 감사 로그도 거치지 않습니다. 곧 "유일한 출구"는 IP 연결에만 참이었습니다. + +지금은 경로 리터럴 허용 목록만 방출합니다 (`profile.rs`의 `PROXY_UNIX_SOCKET_LITERALS`). 항목마다 근거는 다음과 같습니다. + +| 소켓 | 판단 | 근거 | +|------|------|------| +| `/private/var/run/mDNSResponder` | 유지 | `getaddrinfo`가 이 소켓으로 mDNSResponder에 묻습니다. 막으면 외부 이름 해석이 전부 `EAI_NONAME`으로 실패합니다 (`/etc/hosts`의 `localhost`는 여전히 풀립니다). 루트 소유 데몬이며 DNS-SD 프로토콜만 받으므로 이 소켓이 주는 능력은 이름 질의뿐이고, 그것은 이미 7절과 `limitations.md` 4.1.1이 밝힌 DNS 반출 구멍 그대로입니다. 열어 둬도 새로 생기는 능력은 없습니다 | +| `/private/var/run/syslog` | 유지 | syslogd의 `syslog(3)` 입력 소켓입니다. Apple의 기반 프로파일 `system.sb`가 모든 샌드박스 프로세스에 여는 유일한 유닉스 소켓입니다. 루트 소유이고 쓰기 전용 로그 싱크라 파일·exec·네트워크 어느 능력도 주지 않습니다. Darwin 25의 `logger(1)`는 logd XPC로 가므로 이 소켓 없이도 동작하지만, `syslog(3)`를 직접 부르는 도구와 검증하지 않은 OS 버전을 위해 남깁니다 | +| `/private/var/run/asl_input` | 제외 | Darwin 25에 존재하지 않고 현재 `system.sb`에도 없습니다. 죽은 항목을 허용 목록에 두지 않습니다 | + +Seatbelt는 유닉스 소켓을 vnode 경로로 비교하므로 `/var/run/...`(심볼릭 링크)은 `/private/var/run/...`으로 적어야 매칭합니다. `/private/var/run`은 루트 소유 디렉토리라 자식이 같은 이름의 소켓을 미리 만들어 허용 목록을 가로챌 수 없습니다. 방출은 다른 경로 규칙과 같은 `sbpl::quote`를 거칩니다. + +남는 두 소켓은 프록시 모드의 enforcer gap으로 배너에 그대로 나옵니다 (`seatbelt.rs`의 `gaps`). 두 연결 모두 프록시와 감사 로그 밖입니다. + +**정확한 문장.** macOS에서 `--egress-proxy`가 켜지고 `--no-network`가 없을 때, 커널이 자식에게 허용하는 아웃바운드는 프록시 포트 하나와 위 두 소켓뿐입니다. 곧 IP로 나가는 연결은 프록시가 유일한 출구이고, 유닉스 소켓은 DNS와 로그 두 개만 남습니다. `--no-network`면 아웃바운드 규칙을 하나도 방출하지 않으므로 유닉스 소켓까지 전부 닫힙니다. `--egress-proxy` 없이 egress allow 규칙만 있는 정책은 `(allow network-outbound)`로 IP와 유닉스 소켓을 통째로 엽니다. 그 모드는 원래 호스트 규칙이 강제되지 않는 모드이며(`limitations.md` 4.1, 4.5) 유닉스 소켓 축소는 프록시 모드에서만 합니다. + 이 결합이 지금까지의 "egress allow가 하나라도 있으면 아웃바운드를 통째로 연다"는 한계(`limitations.md` 4장)를 macOS에서 해소합니다. ### 6.2 Linux (현재는 Landlock 포트 축소, netns는 후속) **현재 구현.** Landlock의 TCP 포트 허용 목록이 프록시 포트 하나로 줄어듭니다. 정책의 포트 목록은 이제 프록시가 대신 나가므로 자식에게 열지 않습니다. -이것만으로는 프록시가 유일한 출구가 되지 않습니다. Landlock은 포트까지만 보고 목적지 IP를 보지 못하므로, 자식이 자기 환경에 적힌 프록시 포트를 그대로 써서 외부 호스트에 직접 연결하면 프록시를 건너뜁니다. 이 잔여 구멍은 enforcer gap으로 배너에 노출합니다. **곧 Linux에서 `--egress-proxy`는 macOS와 달리 아직 fail-closed가 아닙니다.** +이것만으로는 프록시가 유일한 출구가 되지 않습니다. 두 가지 이유입니다. + +- Landlock은 포트까지만 보고 목적지 IP를 보지 못하므로, 자식이 자기 환경에 적힌 프록시 포트를 그대로 써서 외부 호스트에 직접 연결하면 프록시를 건너뜁니다. +- **Landlock은 유닉스 도메인 소켓을 다루지 않습니다** (추상 소켓은 ABI >= 6의 스코프만 막힙니다, `limitations.md` 3.9). seccomp 중계 층도 `connect(2)`의 피어가 INET이 아니면 평가 없이 통과시킵니다 (`limitations.md` 5.13). 곧 Linux의 프록시 모드에서 자식은 ssh-agent 소켓, `docker.sock`, 임의 유닉스 리스너에 그대로 닿고, 그 연결은 기록되지 않습니다. 브로커가 `SSH_AUTH_SOCK`과 `DOCKER_HOST`를 자식 환경에서 벗기지만(`limitations.md` 9.6) 그것은 가리키는 손가락을 지우는 것이지 소켓 자체를 막는 것이 아닙니다. 경로는 디렉토리 나열로 다시 찾을 수 있습니다. + +이 잔여 구멍은 enforcer gap으로 배너에 노출합니다. **곧 Linux에서 `--egress-proxy`는 macOS와 달리 아직 fail-closed가 아니며, 유닉스 소켓에 대해서는 어느 층도 경계가 아닙니다.** -**후속.** 자식을 `CLONE_NEWUSER | CLONE_NEWNET`로 새 네트워크 네임스페이스에 넣으면 그 안에는 루프백뿐이라 외부로 나갈 경로 자체가 사라집니다. 소켓은 생성 시점의 netns에 묶이므로 브로커가 자식 netns 안에서 리스너를 만들고 accept 루프는 호스트 쪽에서 돕니다. 이 단계가 들어와야 Linux도 macOS와 같은 경계를 갖고, 6.1의 DNS 구멍까지 함께 닫힙니다. +**후속.** 자식을 `CLONE_NEWUSER | CLONE_NEWNET`로 새 네트워크 네임스페이스에 넣으면 그 안에는 루프백뿐이라 외부로 나갈 경로 자체가 사라집니다. 소켓은 생성 시점의 netns에 묶이므로 브로커가 자식 netns 안에서 리스너를 만들고 accept 루프는 호스트 쪽에서 돕니다. 이 단계가 들어와야 Linux도 macOS와 같은 IP 경계를 갖고, 6.1의 DNS 구멍까지 함께 닫힙니다. 경로 기반 유닉스 소켓은 netns로도 닫히지 않으므로(파일시스템 네임스페이스의 문제입니다) 마운트 네임스페이스나 seccomp의 `AF_UNIX` `connect` 판정이 따로 필요합니다. ### 6.3 결합 실패 시 @@ -152,7 +188,9 @@ Darwin 25.4(macOS 26.4) arm64에서 동작을 확인했습니다. 허용 포트 프록시 층이 들어와도 남는 것들입니다. 배너와 `limitations.md`에 그대로 노출합니다. -- **DNS 반출 (macOS).** 자식의 이름 해석은 mDNSResponder를 거치며 이 경로는 `network-outbound`가 아닙니다. `mach-lookup` deny로도 막히지 않는 것을 확인했습니다. 곧 자식은 `<유출데이터>.attacker.com` 질의로 데이터를 내보낼 수 있습니다. netns가 들어오는 Linux 후속 단계에서는 이 경로가 함께 끊깁니다. +- **DNS 반출 (macOS).** 자식의 이름 해석은 `/private/var/run/mDNSResponder` 유닉스 소켓으로 mDNSResponder에 갑니다. 6.1대로 이 소켓은 이름 해석을 살리기 위해 일부러 허용 목록에 남겼고, `mach-lookup` deny로는 막히지 않는 것을 확인했습니다. 곧 자식은 `<유출데이터>.attacker.com` 질의로 데이터를 내보낼 수 있습니다. 이 소켓을 허용 목록에서 빼면 구멍은 닫히지만 `getaddrinfo`가 전부 실패하므로, 그 결정은 별도 옵션으로 다룰 후속입니다. netns가 들어오는 Linux 후속 단계에서는 이 경로가 함께 끊깁니다. +- **시스템 로그 소켓 (macOS).** `/private/var/run/syslog`도 허용 목록에 남습니다. 로컬 로그 싱크라 반출 경로는 아니지만 자식이 시스템 로그에 임의 줄을 써 넣을 수 있고, 그것은 감사 로그 밖입니다. +- **유닉스 소켓 전반 (Linux).** 6.2대로 Landlock도 seccomp 중계도 유닉스 소켓을 보지 않습니다. ssh-agent, `docker.sock`, 임의 리스너가 프록시 모드에서도 닿고 기록되지 않습니다. macOS에서는 6.1의 허용 목록 두 개 외에는 커널이 거부합니다. - **프록시 우회 (Linux).** 6.2대로 현재 Linux 백엔드는 포트까지만 강제하므로 자식이 프록시 포트로 외부에 직접 연결할 수 있습니다. netns가 들어오기 전까지 Linux의 `--egress-proxy`는 강제가 아니라 관측에 가깝습니다. - **DNS 리바인딩.** 4절대로 해석된 IP를 재판정하지 않습니다. - **CONNECT 대상이 IP 리터럴인 경우.** 호스트 패턴 규칙은 IP에 매칭하지 않으므로(`policy-dsl.md` 8절) `[defaults].egress`로 떨어집니다. 기본값이 deny면 막히지만, 호스트 allowlist를 통과하는 것은 아닙니다. diff --git a/docs/limitations.md b/docs/limitations.md index f616331..cb13b82 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -222,6 +222,17 @@ Landlock이 등록하는 LSM 훅에는 `mmap_file`도 `file_mprotect`도 없습 이것은 exec 화이트리스트가 **의도적 실행 경로만** 좁힌다는 뜻입니다. 코드 실행 자체를 막는 경계가 아닙니다. +### 3.15 아직 없는 경로의 생성은 거부할 수 없다 + +Landlock 규칙은 inode 에 걸립니다. 없는 파일에는 inode 가 없으므로 "이 경로는 만들지 말라"를 표현할 방법이 없고, 순회는 존재하는 항목만 봅니다 (`landlock.rs:399-497`). 곧 `deny` 대상이 하나도 없는 디렉토리는 `Grant::Whole` 로 통째로 쓰기 허용되고, 그 안에 어떤 이름이든 새로 만들 수 있습니다 (`landlock.rs:477-478`, `516-537`). + +이것이 정책 파일 자기보호에 그대로 영향을 줍니다. `self:policy-file` 은 아직 없는 후보(`cwd/airlock.toml` 등)까지 쓰기 거부로 두지만 (`airlock-policy/src/baseline.rs:345-368`), 파일이 없으면 Landlock 계획은 작업 공간 루트에 `Whole` 을 주므로 **에이전트가 `./airlock.toml` 을 만들 수 있고, 다음 `airlock run` 이 그것을 홈 정책보다 먼저 읽습니다** (`airlock/src/paths.rs`). 파일이 이미 있으면 루트가 `Partial` 이 되어 루트 안 생성과 `rename` 이 커널에서 막히지만, 그 경우에는 3.3 대로 루트에 새 파일을 만들 수 없게 됩니다. 루트를 늘 `Partial` 로 내리면 실사용이 깨지므로 그 방향은 택하지 않습니다. + +그래서 방어는 두 겹입니다. + +- `--mediate full` 이 `openat` 의 `O_CREAT` 와 `rename`/`link`/`symlink` 계열의 목적지를 `create` 로 판정해 자기보호 규칙으로 거부합니다(5.1). 기본 수준 `exec-net` 에는 없습니다. +- **로드 시점의 정책 신뢰 기록(TOFU)** 이 처음 보는 정책이나 다이제스트가 바뀐 정책을 사람에게 확인시킵니다 (`docs/design.md` 9.6). 커널이 생성을 거부할 수 없다는 이 한계가 TOFU 가 필요한 직접적인 이유입니다. + --- ## 4. 강제 층: macOS (Seatbelt) @@ -240,7 +251,7 @@ Landlock이 등록하는 LSM 훅에는 `mmap_file`도 `file_mprotect`도 없습 `allow` 모드에서 방출하지 않는 이유는 사람이 승인한 실행이 커널에서 막혀 어떤 방법으로도 진행할 수 없게 되기 때문입니다. macOS에는 중계 층도 없으므로(2.6), 그 모드에서 `danger-rm`/`sudo-exec`/`pipe-curl-to-shell` 같은 규칙은 **강제되지도 기록되지도 않습니다.** 화이트리스트 모드에서는 커널이 거부하지만, 사람에게 물어볼 통로가 없어 승인으로 되돌릴 방법도 없습니다. -egress는 `--egress-proxy` 여부로 갈립니다. 플래그가 없으면 egress allow 규칙이 하나라도 있을 때 `(allow network-outbound)`로 아웃바운드가 통째로 열리고, 호스트 규칙은 전부 gap이 됩니다. 플래그가 있으면 프로파일이 `(allow network-outbound (remote ip "localhost:<프록시포트>"))` 하나로 좁아져 프록시가 유일한 출구가 되고, 호스트 판정이 실제 경계를 갖습니다. 규격은 `docs/egress-proxy.md`입니다. +egress는 `--egress-proxy` 여부로 갈립니다. 플래그가 없으면 egress allow 규칙이 하나라도 있을 때 `(allow network-outbound)`로 아웃바운드가 통째로 열리고, 호스트 규칙은 전부 gap이 됩니다. 플래그가 있으면 프로파일이 `(allow network-outbound (remote ip "localhost:<프록시포트>"))` 와 유닉스 소켓 리터럴 두 개(mDNSResponder, syslog. 4.5)로 좁아져 IP 연결은 프록시가 유일한 출구가 되고, 호스트 판정이 실제 경계를 갖습니다. 규격은 `docs/egress-proxy.md` 6.1 입니다. ### 4.1.1 프록시 모드에서도 DNS는 경계 밖이다 @@ -270,7 +281,9 @@ egress는 `--egress-proxy` 여부로 갈립니다. 플래그가 없으면 egress - `(allow process-exec*)` (`profile.rs:237`): **`[defaults].exec = "allow"`인 정책에만 방출됩니다.** 그 설정에서는 명시 deny가 없는 모든 exec이 허용됩니다. `ask`/`deny`/`forbid`이면 이 줄이 없고 허용 목록만 열립니다 (4.12). - `(allow mach-lookup)` (`profile.rs:245`): 클립보드 두 개만 막고 (`profile.rs:247-248`) 나머지 XPC/Mach 서비스는 전부 닿습니다. XPC로 오가는 것은 정책 모델 밖이자 감사 로그 밖입니다 (gap: `seatbelt.rs:255-257`). -- `(allow file-read-metadata)` (`profile.rs:255`): 시스템 전체 경로의 존재/크기/mtime을 읽을 수 있습니다. +- `(allow file-read-metadata)` (`profile.rs:255`): 시스템 전체 경로의 존재/크기/mtime을 읽을 수 있습니다. 유닉스 소켓의 경로도 여기 포함되므로, 환경 변수를 벗겨도(9.6) 소켓 위치는 디렉토리 나열로 다시 찾을 수 있습니다. +- `(allow network-outbound (literal ...))` 두 줄, 프록시 모드 전용 (`profile.rs`의 `PROXY_UNIX_SOCKET_LITERALS`): `/private/var/run/mDNSResponder`와 `/private/var/run/syslog`. 이전에는 `(allow network-outbound (remote unix))`로 **모든** 유닉스 소켓이 열려 있어 ssh-agent 소켓, `docker.sock`, 바깥의 임의 `nc -lU` 리스너가 프록시도 감사도 거치지 않고 닿았습니다 (Darwin 25에서 재현). 지금은 이 두 리터럴만 열리며, 항목별 근거는 `egress-proxy.md` 6.1에 있습니다. 둘 다 루트 소유 시스템 데몬이고 주는 능력은 DNS 질의(4.1.1)와 로컬 로그 쓰기뿐입니다. 프록시 모드 gap으로 배너에 나옵니다. +- `(allow network-outbound)` (egress allow 규칙이 있고 `--egress-proxy`가 없을 때, `profile.rs`의 `network_allowed` 분기): IP뿐 아니라 유닉스 소켓도 통째로 열립니다. 곧 이 모드에서는 `docker.sock`과 ssh-agent 소켓이 닿습니다. 이 모드는 4.1대로 호스트 규칙이 애초에 강제되지 않는 모드이며, 유닉스 소켓 축소는 프록시 모드에서만 합니다. 유닉스 소켓을 정책으로 표현할 어휘가 없으므로(6.8) 이 모드까지 좁히면 `docker` CLI 같은 정당한 사용을 되살릴 길이 없어 그대로 둡니다. `--no-network`는 아웃바운드 규칙을 하나도 방출하지 않아 유닉스 소켓까지 전부 닫힙니다. ### 4.6 `(with no-report)`가 OS 자체 위반 로그를 끈다 @@ -328,35 +341,39 @@ Darwin 25.4에서 확인했으며 회귀 테스트로 고정해 두었습니다 ## 5. 중계 층 (seccomp user notification, Linux 전용) -### 5.1 중재되는 syscall이 기본 3개, 최대 6개다 +### 5.1 중재되는 syscall이 기본 3개, `full` 에서 x86_64 13개 aarch64 9개다 + +`mediated_syscalls`가 `connect`, `execve`, `execveat`를 돌려주고 (`notify.rs:216-248`), `Level::Full`이 `openat`, `openat2`, 레거시 `open` 과 이름 공간을 바꾸는 `linkat`, `symlinkat`, `renameat2`, 그리고 아키텍처에 남아 있는 `rename`, `link`, `symlink`, `renameat` 을 더합니다 (`notify.rs:145-172`). aarch64 의 `renameat`(38) 은 `libc` 크레이트가 gnu 타겟에 상수를 내놓지 않아 리터럴입니다 (`notify.rs:165`). + +이름 공간 syscall 은 원본을 `delete`, 목적지를 `create` 로 판정하고 하나라도 거부면 거부합니다 (`notify.rs:537-604`, `813-835`). 하드링크의 원본도 `delete` 로 봅니다. 그 inode 가 새 이름을 얻어 원래 경로의 규칙을 벗어나기 때문입니다. 심볼릭 링크의 대상 문자열은 접근이 아니라 값이므로 판정하지 않고, 그 링크를 통한 접근은 열리는 시점에 해소 경로로 다시 판정됩니다. `RENAME_EXCHANGE` 는 양쪽에 두 모드를 모두 묻습니다. 원본이 거부되면 목적지는 묻지 않으므로 감사에는 첫 거부까지만 남습니다. -`mediated_syscalls`가 `connect`, `execve`, `execveat`를 돌려주고 (`notify.rs:197-211`), `Level::Full`이 `openat`, `openat2`, 레거시 `open`을 더합니다 (`notify.rs:152-155`, `205`). +이와 별개로 **모든 수준의 필터가 `ioctl(TIOCSTI)` 과 `ioctl(TIOCLINUX)` 를 `EPERM` 으로 거부합니다** (`notify.rs:49`, `bpf.rs:74-94`). 중계가 아니라 커널 거부이며 감사 엔트리를 남기지 않습니다. 근거는 8.5 에 있습니다. **어느 수준에서도 중재되지 않는 것**은 다음과 같습니다. -`unlink`/`unlinkat`, `rename`/`renameat`/`renameat2`, `truncate`, `mkdir`, `link`/`symlink`, `chmod`/`chown`, `socket`, `bind`, `listen`, `sendto`, `sendmsg`, `ptrace`, `clone`/`fork`/`vfork`, `memfd_create`, `mount`, `prctl`, `io_uring_*`. +`unlink`/`unlinkat`, `truncate`, `mkdir`, `chmod`/`chown`, `socket`, `bind`, `listen`, `sendto`, `sendmsg`, `ptrace`, `clone`/`fork`/`vfork`, `memfd_create`, `mount`, `prctl`, `io_uring_*`. -곧 **파일 삭제와 이름 변경은 `--mediate full`에서도 기록되지 않습니다.** 연결 없는 UDP 전송(`sendto`/`sendmsg`)은 egress 관측과 승인을 통째로 비껴갑니다. +곧 **파일 삭제(`unlink`)는 `--mediate full`에서도 기록되지 않습니다.** 이름 변경은 `full` 에서만 기록되며 기본 수준 `exec-net` 은 여전히 보지 못합니다. 연결 없는 UDP 전송(`sendto`/`sendmsg`)은 egress 관측과 승인을 통째로 비껴갑니다. ### 5.2 io_uring이 중계를 통째로 우회한다 -필터는 syscall 번호만 봅니다 (`notify.rs:246-253`). `IORING_OP_OPENAT`, `IORING_OP_CONNECT`, `IORING_OP_WRITE`는 실행 시점에 syscall이 아니므로 알림을 만들지 않습니다. egress `ask`/`deny`와 파일 감사가 전부 우회되고 Landlock만 남습니다. +필터는 syscall 번호만 봅니다 (`bpf.rs:96-99`). `IORING_OP_OPENAT`, `IORING_OP_CONNECT`, `IORING_OP_WRITE`는 실행 시점에 syscall이 아니므로 알림을 만들지 않습니다. egress `ask`/`deny`와 파일 감사가 전부 우회되고 Landlock만 남습니다. -### 5.3 `FileMode::Delete`와 `FileMode::Metadata`는 런타임에 생성될 수 없다 +### 5.3 `FileMode::Delete`는 이름 변경에서만, `FileMode::Metadata`는 어디서도 생성되지 않는다 -`file_mode_for`가 `Read`, `Create`, `Write`만 돌려줍니다 (`notify.rs:534-543`). +`file_mode_for`가 `Read`, `Create`, `Write`만 돌려줍니다 (`notify.rs:706-715`). `Delete` 는 `--mediate full` 의 `rename`/`link` 계열이 원본에 대해 만들고 (`notify.rs:585-604`), `Create` 는 `O_CREAT` 열기와 같은 계열의 목적지에서 나옵니다. 곧 감사 엔트리의 `mode` 에 `delete` 가 나오면 그것은 `unlink` 가 아니라 이름 변경이나 하드링크의 원본입니다. -`mode = ["delete"]`나 `mode = ["metadata"]`로 쓴 규칙은 중계 층이 절대 평가하지 않습니다. 그 규칙의 `ask`/`deny` 의미론이 발화하지 않습니다. +`mode = ["delete"]` 규칙은 `unlink` 에는 발화하지 않습니다. `mode = ["metadata"]` 로 쓴 규칙은 중계 층이 절대 평가하지 않습니다. 그 규칙의 `ask`/`deny` 의미론이 발화하지 않습니다. ### 5.4 `openat2`의 쓰기가 읽기로 분류된다 -`openat2`의 세 번째 인자는 `open_how` 구조체 포인터인데, 이를 읽지 않고 보수적으로 `FileMode::Read`로 잡습니다 (`notify.rs:649-651`, 근거는 같은 자리 주석). +`openat2`의 세 번째 인자는 `open_how` 구조체 포인터인데, 이를 읽지 않고 보수적으로 `FileMode::Read`로 잡습니다 (`notify.rs:838-846`, 근거는 같은 자리 주석). 의도는 보수적 판정이지만 효과는 반대입니다. **읽기 allow + 쓰기 deny인 경로에 `openat2`로 쓰면 허용됩니다.** 감사 엔트리도 mode를 `read`로 남깁니다. ### 5.5 argv와 경로 읽기에 하드 상한이 있고 잘림이 argv 자리를 차지한다 -`MAX_CSTR = 4096`바이트 (`notify.rs:367`), `MAX_ARGV = 256`원소 (`notify.rs:680`), sockaddr 길이는 `2..=128`만 (`notify.rs:454`). +`MAX_CSTR = 4096`바이트 (`notify.rs:447`), `MAX_ARGV = 256`원소 (`notify.rs:872`), sockaddr 길이는 `2..=128`만 (`notify.rs:626`). 이름 변경 계열의 두 경로도 같은 상한으로 읽습니다. - `argv_contains = ["--force"]` deny 규칙은 `--force`를 257번째 이후에 두면 우회됩니다. 평가가 잘린 목록으로 돌기 때문입니다 (`notify.rs:722-724`). - 더 나쁘게, **읽을 수 없는 원소 하나가 루프를 멈춥니다** (`notify.rs:716-719`). 그 뒤 인자는 평가되지 않습니다. @@ -364,15 +381,15 @@ Darwin 25.4에서 확인했으며 회귀 테스트로 고정해 두었습니다 ### 5.6 32비트와 x32 syscall은 프로세스를 죽인다 -아키텍처 불일치는 `SECCOMP_RET_KILL_PROCESS`입니다 (`notify.rs:230-232`, x32는 `237-242`). +아키텍처 불일치는 `SECCOMP_RET_KILL_PROCESS`입니다 (`bpf.rs:67-70`, x32는 `72-76`). -중계 상태에서 i386 바이너리를 돌리면 진단 없이 SIGSYS로 즉사합니다. errno도 감사 엔트리도 없습니다. multiarch 툴체인, 32비트 크로스 컴파일러, Wine은 중계가 켜진 동안 쓸 수 없습니다. 이 강경한 선택 자체는 32비트 바이너리가 `ask` 승인을 건너뛰던 문제의 수정이며(`CHANGELOG.md`), 우회보다 죽이는 쪽이 옳습니다. +이 검사는 이제 **`--mediate off` 에서도** 걸립니다. TIOCSTI 거부 필터가 같은 프롤로그를 쓰기 때문이며 (`notify.rs:334-359`, `landlock.rs:1370-1372`), i386 ABI 의 `ioctl`(54번)은 네이티브 번호(16번)와 달라 검사 없이 통과시키면 거부가 우회됩니다. 곧 Landlock 강제 아래에서 i386 바이너리를 돌리면 중계 수준과 무관하게 진단 없이 SIGSYS로 즉사합니다. errno도 감사 엔트리도 없습니다. multiarch 툴체인, 32비트 크로스 컴파일러, Wine은 `airlock run` 아래에서 쓸 수 없습니다. 이 강경한 선택 자체는 32비트 바이너리가 `ask` 승인을 건너뛰던 문제의 수정이며(`CHANGELOG.md`), 우회보다 죽이는 쪽이 옳습니다. ### 5.7 모르는 아키텍처는 조용히 중계 0으로 강등된다 -`NATIVE_ARCH`는 8개 아키텍처만 압니다 (`notify.rs:120-146`). `None`이면 필터가 만들어지지 않고 (`notify.rs:223`), 세션은 `Mediation::Off`로 진행합니다 (`session.rs:586-592`). +`NATIVE_ARCH`는 8개 아키텍처만 압니다 (`notify.rs:108-134`). `None`이면 필터가 만들어지지 않고 (`notify.rs:256-274`), 세션은 `Mediation::Off`로 진행합니다 (`session.rs`의 `setup_mediation`). -제네시스에는 올바르게 기록되지만 (`session.rs:478-483`), 사용자에게 가는 신호는 stderr 한 줄뿐입니다. +제네시스에는 올바르게 기록되지만, 사용자에게 가는 신호는 stderr 한 줄뿐입니다. 같은 이유로 TIOCSTI 거부 필터도 만들어지지 않으며 이쪽은 Landlock 강제 층이 gap 으로 냅니다 (`landlock.rs:1427-1438`). 그 아키텍처에서는 8.5 의 승인 위조 경로가 열려 있습니다. ### 5.8 첫 `execve` 알림은 무조건 허용된다 @@ -433,11 +450,15 @@ Darwin 25.4에서 확인했으며 회귀 테스트로 고정해 두었습니다 - **하드링크는 원리적으로 보이지 않습니다.** 크레이트 전체에 inode/device 동일성 개념이 없습니다 (`path.rs:126-197`). `ln ~/.ssh/id_rsa ~/work/x` 후 `~/work/x`를 읽으면 `ssh-private-keys` forbid가 통째로 비껴 갑니다. 심볼릭 링크는 양방향 평가로 잡지만 하드링크는 해소 대상이 없습니다. - **바인드 마운트도 같은 구멍입니다.** `mount --bind ~/.ssh ~/work/ssh` 후에는 요청 경로도 해소 경로도 `~/work/ssh/...`입니다. -- **firmlink 별칭 확장이 로드 시점 1회, 선행 고정 접두에만 적용됩니다** (`glob.rs:169-217`, `engine.rs:61-74`). `**/.env`처럼 선행이 와일드카드인 패턴은 변형을 못 얻고, 로드 후 생긴 마운트/링크는 반영되지 않습니다. +- **firmlink 별칭 확장이 로드 시점 1회, 선행 고정 접두에만 적용됩니다** (`glob.rs` `resolved_variant`, `engine.rs` `add_resolved_variants`). `**/.env`처럼 선행이 와일드카드인 패턴은 변형을 못 얻고, 로드 후 생긴 마운트/링크는 반영되지 않습니다. +- **`/System/Volumes/Data` 접기는 firmlink 표에 있는 대상에만, macOS에서만 적용됩니다** (`path.rs` `fold_firmlink`, `docs/policy-dsl.md` 4절 6번). Linux에는 firmlink가 없으므로 해당 없습니다. `/usr/share/firmlinks`를 읽지 못하거나(없음, root 소유가 아님, 다른 사용자가 쓸 수 있음, 심볼릭 링크, 유효한 줄 없음) 그 내용이 실제 시스템과 다르면 내장 기본 목록(10.15 이후 19개)으로 물러서므로, 그 OS 버전에서 새로 생긴 firmlink 대상은 접히지 않고 Data 표기가 그대로 남습니다. 반대로 내장 목록에는 있는데 그 시스템에는 없는 대상(`/pkg` 등)은 실제로는 다른 디렉토리인 두 경로를 하나로 접습니다. 표는 시작 후 한 번만 읽습니다. +- **Data 표기 자리에 와일드카드가 온 규칙은 접히지 않습니다.** `/System/Volumes/Data/*/x`나 `/System/Volumes/Data/**`처럼 firmlink 대상이 와일드카드인 패턴은 그대로 남고, 요청 경로는 접혀 `/Users/...`가 되므로 그런 규칙은 아무것도 잡지 않습니다. Data 볼륨 전체를 겨냥한 규칙은 애초에 루트 표기로 우회되므로 실질 손실은 없지만 로드 경고는 나지 않습니다. +- **접기는 ASCII 대소문자만 무시합니다.** `/system/volumes/data/users/...`는 접히지만 비ASCII 대상이 표에 있다면 그 대소문자·정규화 변형은 접히지 않습니다. 기본 표의 대상은 전부 ASCII입니다. - **런타임 요청 경로에도 `~`를 확장합니다** (`path.rs:199-201`). 커널은 `~`를 확장하지 않으므로, cwd에 실제로 `~`라는 디렉토리가 있으면 정책은 `$HOME/work/x`로 판정하고 커널은 `./~/work/x`를 엽니다. - **cwd가 세션 시작 시점 하나로 고정되고 dirfd 개념이 없습니다** (`path.rs:199`, `session.rs:185`). 자식이 `chdir`한 뒤의 상대 경로는 잘못된 디렉토리에 묶여 판정됩니다. `docs/policy-dsl.md` 4.2절이 dirfd 기준 해석을 요구하지만 정책 API에는 그것을 받을 자리가 없습니다. - **procfs 특수 파일 모델이 없습니다.** 베이스라인에 `/proc//{mem,environ,fd/N}` 관련 규칙이 하나도 없습니다 (`baseline.rs:30-188`). 다른 프로세스의 `environ`(토큰이 흔히 있습니다) 접근이 `[defaults].file`로 떨어집니다. -- 매달린 링크 추적은 **40회**에서 포기하고 그 시점 경로로 판정합니다 (`path.rs:174-197`). +- 링크 추적은 **40홉**에서 포기하고 그 시점까지 해소된 경로에 나머지를 문자열로 붙여 판정합니다 (`path.rs` `walk_symlinks`). macOS 커널의 상한은 32이므로 33홉에서 40홉 사이의 체인은 커널이 `ELOOP`로 거부하는데 정책은 끝까지 따라가 판정합니다. 커널이 열지 않는 경로이므로 우회는 아닙니다. +- **존재하지 않는 구성 요소 뒤의 `..`는 문자열로만 해소됩니다.** `real/없는것/../target`을 커널은 `ENOENT`로 거부하지만 정책은 `real/target`으로 판정합니다. 커널이 여는 것보다 넓게 보는 방향이므로 우회는 아닙니다. ### 6.4 egress 어휘가 좁다 @@ -448,7 +469,7 @@ Darwin 25.4에서 확인했으며 회귀 테스트로 고정해 두었습니다 - **프로토콜 축이 없습니다.** 브로커는 감사에 프로토콜을 남기는데 정책에는 그 축이 없습니다. - **인바운드(listen/bind)와 유닉스 소켓은 어휘 자체가 없습니다.** - 호스트 와일드카드는 선행 `*.` 또는 단독 `*`만 됩니다 (`host.rs:128-134`). `api.*.com`은 로드 거부이고, `*.example.com`은 apex에 매칭하지 않아 규칙 두 개가 필요합니다. -- **런타임 비ASCII 호스트는 매칭에서 빠집니다** (`host.rs:151-156`). 특정 IDN 호스트를 지목한 `deny`가 그 호스트의 유니코드 표기에 걸리지 않고 `[defaults].egress`로 떨어집니다. 기본값이 `ask`인 정책에서는 **deny가 ask로 격하**되고 감사 로그에 어떤 규칙이 걸렸는지도 남지 않습니다. +- **런타임 비ASCII 호스트는 매칭에서 빠집니다** (`host.rs:151-156`). 특정 IDN 호스트를 지목한 `deny`가 그 호스트의 유니코드 표기에 걸리지 않고 `[defaults].egress`로 떨어집니다. 기본값이 `ask`인 정책에서는 **deny가 ask로 격하**되고 감사 로그에 어떤 규칙이 걸렸는지도 남지 않습니다. 프록시 경로는 예외입니다. 헤드 파서가 비ASCII 호스트를 `400`으로 거부해 판정에 닿지 않으므로 (`http.rs` `valid_host`, `docs/egress-proxy.md` 3.3절) 이 격하는 seccomp 중계와 `policy explain`처럼 프록시를 거치지 않는 호출에만 남습니다. - **호스트명과 IP를 잇는 개념이 없습니다** (`engine.rs:502-509`). 파일과 달리 요청/해소 양방향 평가가 없어, DNS 리바인딩과 CNAME과 라운드로빈을 표현할 수 없습니다. - 호스트 규칙은 **백엔드와 무관하게 항상** 경고를 냅니다 (`engine.rs:524-533`). 정책 로드는 그 세션이 `--egress-proxy`로 뜰지 모르므로, 경고 문구는 "프록시로 실행할 때만 강제된다"는 조건부 형태입니다. 프록시 없이 돌리면 그대로 미강제입니다. @@ -543,6 +564,7 @@ env, cwd, uid, 부모 프로세스, 실행 파일 해시 같은 조건을 쓸 - **`home_dir()`의 `/` 폴백이 API에 남아 실제로 쓰입니다** (`path.rs:22-33`). `cmd_policy.rs:77`이 `home_dir()`을, `cmd_run.rs:103`이 `home_dir_checked()`를 씁니다. `HOME`이 비면 `airlock run`은 거부하는데 `airlock policy explain`은 `~/.ssh/**`를 `/.ssh/**`로 축소한 채 답합니다. **설명 명령과 실제 강제의 결론이 갈립니다.** - **규칙 id에 한국어를 쓸 수 없습니다** (`dsl.rs:70-72`). 근거는 커널 프로파일 주석 탈출 방지이며 타당하지만, 사람이 읽는 이름과 기계 식별자를 분리할 `title`/`label` 필드가 없습니다. - **자기보호 대상이 호출자가 열거한 리터럴 경로 목록입니다** (`baseline.rs:319-372`). 감사 루트의 **부모 디렉토리**를 지우거나 이름을 바꾸는 요청은 티어 0에 매칭하지 않고, `rename` 모드가 없어 "이동으로 무력화"를 표현하지 못합니다. 정책 파일 후보 목록이 CLI의 탐색 로직(`airlock/src/paths.rs:81`)과 따로 관리되어 탐색 후보가 늘면 자기보호가 조용히 뒤처집니다. +- **정책 신뢰 기록(`trusted-policies.jsonl`)은 감사 루트의 보호에 기댑니다** (`airlock/src/trust.rs`). Linux Landlock 이 없는 경로의 생성을 거부하지 못하는 틈을 로드 시점 확인으로 메우지만(`docs/design.md` 9.6), 기록 파일이 감사 루트에 있으므로 `--audit-dir`가 작업 공간 안을 가리키면 기록도 에이전트 손이 닿는 곳에 놓입니다. 배너가 이것을 경고할 뿐 막지는 않습니다. 기록은 해시체인이 아니라 소유자 전용 append-only 파일이며, 감사 루트에 쓸 수 있는 주체는 기록도 씁니다. 확인 프롬프트는 다이제스트와 규칙 수만 보여 주고 이전 정책과의 **차이**는 보여 주지 않으므로, 사람이 `y`를 누르기 전에 무엇이 바뀌었는지는 직접 파일을 열어 봐야 합니다. 첫 승인 자체는 TOFU 이며, 그 시점에 이미 심긴 정책을 사람이 자기 것으로 착각하면 기록이 그것을 정당화합니다. ### 6.13 판정 비용이 선형이고 캐시가 없다 @@ -566,11 +588,16 @@ env, cwd, uid, 부모 프로세스, 실행 파일 해시 같은 조건을 쓸 ## 7. 감사 로그 -### 7.1 단일 위조 엔트리가 실패가 아니라 경고로 강등된다 +### 7.1 단일 위조 엔트리가 앵커 없는 세션에서는 경고로 강등된다 -`verify_stream`은 `head.seq == last_seq - 1 && head.hash == second_last_hash`를 크래시 잔여물로 보고 `Warning::HeadLagsByOne`만 냅니다 (`verify.rs:400-409`). CLI의 `verify_one`은 `Ok(report)`면 경고가 있어도 **0을 반환합니다** (`cmd_audit.rs:80-96`). +`verify_stream`은 `head.seq == last_seq - 1 && head.hash == second_last_hash`를 크래시 잔여물로 보고 `Warning::HeadLagsByOne`만 냅니다 (`verify.rs`). CLI의 `verify_one`은 `Ok(report)`면 경고가 있어도 **0을 반환합니다**. -곧 `chain.jsonl`에 쓸 수 있으나 `head.json`을 건드리지 않은 공격자가 잘 만들어진 엔트리 **하나**를 덧붙이면(seq+1, prev = 마지막 해시, 해시 재계산. 비밀값이 필요 없습니다) `airlock audit verify`가 노란 "크래시 잔여물" 줄을 찍고 **0으로 끝납니다.** 일어나지 않은 행위에 대한 allow 판정이나 가짜 `session_end`를 위조하는 비용이 엔트리 하나이며 실패를 내지 않습니다. +앵커가 있는 세션은 다릅니다. 앵커는 엔트리와 `head.json`이 `fsync`된 뒤에 쓰이므로 앵커가 있는데 `head.json`이 뒤처진 상태는 정상 경로로 만들어질 수 없고, 앵커 대조 단계가 `AnchoredHeadLag` 실패로 올립니다 (`docs/audit-format.md` 8.3). 체인이 앵커보다 길면 `EntriesAfterAnchor`, `session_end` 뒤에 엔트리가 있으면 무결성 실패, 같은 세션의 두 번째 앵커 줄은 `SessionReanchored` 실패입니다. 곧 **브로커가 정상 종료한 세션에는 엔트리 하나를 덧붙일 자리가 없습니다.** + +남는 표면은 둘입니다. + +- **SIGKILL로 죽어 앵커가 없는 세션.** `chain.jsonl`에 쓸 수 있으나 `head.json`을 건드리지 않은 공격자가 잘 만들어진 엔트리 **하나**를 덧붙이면(seq+1, prev = 마지막 해시, 해시 재계산. 비밀값이 필요 없습니다) `airlock audit verify`가 노란 "크래시 잔여물" 줄을 찍고 **0으로 끝납니다.** `airlock audit report`는 그 세션을 `anchor_missing` 증거 이상으로 세어 2로 끝나므로 매일 점검에서는 드러나지만, 위조와 크래시를 구분하지는 못합니다. 그 공격자가 `anchors.jsonl`에도 append할 수 있으면 첫 앵커를 대신 써서 `anchor_missing`을 지울 수 있고, 그때는 `AnchoredHeadLag`이 유일한 단서입니다. `head.json`까지 다시 쓸 수 있으면 단서가 없습니다. +- **`--no-fsync` 세션.** 제네시스가 `fsync_per_entry: false`면 엔트리와 `head.json`의 내구화 순서가 보장되지 않아 앵커가 있어도 한 칸 뒤처짐이 크래시로 생길 수 있으므로 경고를 유지합니다. 그 세션에서는 앵커가 있어도 엔트리 하나의 위조가 경고로 남습니다. ### 7.2 시간은 벽시계뿐이고 신뢰할 수 없다 @@ -637,11 +664,11 @@ exec 엔트리의 `cwd`는 세션의 `self.cwd`이지 호출 프로세스의 실 ### 7.11 검증이 연결 관계와 작은 의미 규칙 몇 개만 본다 -`verify_stream`이 보는 것: 파싱, `seq` 무결번, 단일 세션 id, 제네시스 `prev == 0`과 `session_start`, `prev` 연결, 해시 재계산, 승인 대상의 존재와 `ask` 여부, head 앵커 (`verify.rs:275-418`). +`verify_stream`이 보는 것: 파싱, `seq` 무결번, 단일 세션 id, 제네시스 `prev == 0`과 `session_start`, `prev` 연결, 해시 재계산, 승인 대상의 존재와 `ask` 여부, `session_end` 뒤에 엔트리가 없는지, head 앵커 (`verify.rs`). -**보지 않는 것**: `policy_digest`를 실제 정책과 대조, `mediation`이 실제와 맞는지, `enforcement`가 무엇과 맞는지, `ts_rfc3339`와 `ts`의 일치, `session_end`가 체인을 끝내는지, `session`이 0이 아닌지, 결정이 기록된 규칙 id와 일관되는지. +**보지 않는 것**: `policy_digest`를 실제 정책과 대조, `mediation`이 실제와 맞는지, `enforcement`가 무엇과 맞는지, `ts_rfc3339`와 `ts`의 일치, `session_end`가 **있는지**, `session`이 0이 아닌지, 결정이 기록된 규칙 id와 일관되는지. -SIGKILL로 죽어 `session_end`가 없는 체인이 경고 없이 깨끗하게 검증됩니다. `enforcement: landlock` / `mediation: full`을 주장하면서 실제로는 아무것도 강제되지 않은 체인도 깨끗하게 검증됩니다. **검증은 "사후에 편집되었는가"에 답하지 "정확한 기록인가"에 답하지 않습니다.** +SIGKILL로 죽어 `session_end`가 없는 체인이 경고 없이 깨끗하게 검증됩니다. `session_end` 뒤의 엔트리는 실패지만 `session_end`의 부재는 아무 신호도 내지 않으며, 앵커가 있는 세션이 `session_end` 없이 끝나는 것도 검사하지 않습니다. `enforcement: landlock` / `mediation: full`을 주장하면서 실제로는 아무것도 강제되지 않은 체인도 깨끗하게 검증됩니다. **검증은 "사후에 편집되었는가"에 답하지 "정확한 기록인가"에 답하지 않습니다.** ### 7.12 실행 중인 로그를 검증할 수 없다 @@ -739,11 +766,17 @@ CI, 데몬, 분리된 세션에서는 모든 `ask` 규칙이 조용히 `deny`처 사람이 `y`를 누른 시점과 커널이 syscall을 다시 실행하는 시점 사이에 대상은 자기 메모리의 경로나 주소를 바꿔 쓸 수 있습니다. **프롬프트는 관측한 것에 대해 정직하지만 앞으로 일어날 일에 대한 보증은 아닙니다.** -### 8.5 상속된 stdin으로 자식이 사용자의 답을 읽을 수 있다 +### 8.5 상속된 터미널로 자식이 사용자의 답을 읽거나 위조할 수 있다 + +`/dev/tty`는 양쪽 허용 목록에서 일부러 뺐지만 (`landlock.rs:76-87`, `profile.rs`), 같은 자리 주석대로 상속된 stdin/stdout/stderr는 그대로 둡니다. `Command`에 stdio 리다이렉션이 없고 `setsid` 도 하지 않습니다. `setsid` 는 대화형 에이전트를 깨기 때문입니다. + +대화형 실행에서 fd 0/1/2는 **바로 그 터미널입니다.** 자식은 fd 1에 써서 그럴듯한 가짜 승인 프롬프트를 그릴 수 있고, fd 0을 읽어 브로커의 `read_line`보다 먼저 사용자가 친 `y`를 가져갈 수 있습니다. **`/dev/tty`를 막는 것으로 이 경로가 닫히지 않습니다.** -`/dev/tty`는 양쪽 허용 목록에서 일부러 뺐지만 (`landlock.rs:56-60`, `profile.rs:32-37`), 같은 자리 주석대로 상속된 stdin/stdout/stderr는 그대로 둡니다. `Command`에 stdio 리다이렉션이 없습니다 (`session.rs:466-469`). +**답을 위조하는 경로는 닫았습니다.** 제어 터미널에 `ioctl(TIOCSTI, "y\n")` 을 부르면 사용자가 치지 않은 바이트가 입력 큐에 들어가 `/dev/tty` 를 새로 연 승인 프롬프트가 그것을 답으로 읽습니다. macOS 는 Seatbelt 가 TIOCSTI 를 거부함을 실측했고, Linux 는 커널 6.2 이후 `dev.tty.legacy_tiocsti = 0` 이면 막히지만 배포판 기본값과 구버전 커널은 열려 있습니다. 그래서 Linux 는 seccomp 로 `TIOCSTI`(0x5412) 와 가상 콘솔 붙여넣기 경로인 `TIOCLINUX`(0x541C) 를 `EPERM` 으로 거부합니다 (`notify.rs:49`, `bpf.rs:74-94`). 두 값은 x86_64 와 aarch64 에서 같고, 비교는 커널이 그러듯 인자의 하위 32비트로 합니다. 이 필터는 **중계 수준과 무관하게** 걸립니다. 중계 필터에도 들어 있고 (`notify.rs:256-274`), Landlock 강제 층이 `--mediate off` 를 포함한 모든 세션에서 최소 필터로 따로 겁니다 (`notify.rs:334-359`, `landlock.rs:1359-1372`). `--enforce observe` 에 중계까지 끄면 걸리지 않습니다. bubblewrap 과 flatpak 이 같은 두 개를 막습니다. -대화형 실행에서 fd 0/1/2는 **바로 그 터미널입니다.** 자식은 fd 1에 써서 그럴듯한 가짜 승인 프롬프트를 그릴 수 있고, fd 0을 읽어 브로커의 `read_line`(`approve.rs:236`)보다 먼저 사용자가 친 `y`를 가져갈 수 있습니다. **`/dev/tty`를 막는 것으로 이 경로가 닫히지 않습니다.** +이 조치는 대화형 에이전트를 깨지 않습니다. 쉘, 에디터, TUI, 코딩 에이전트는 자기 stdin 을 **읽을** 뿐 자기 입력 큐에 쓰지 않으며, 터미널 크기 조회(`TIOCGWINSZ`)나 모드 설정(`TCSETS`)은 다른 ioctl 이라 영향받지 않습니다. TIOCSTI 를 쓰는 정상 프로그램은 사실상 없고, 그래서 커널 자체가 이를 끄는 방향으로 갔습니다. + +남는 것은 fd 0 을 먼저 읽어 사용자의 진짜 답을 가로채는 경합과 fd 1 에 가짜 프롬프트를 그리는 것입니다. 전자는 브로커에 답이 닿지 않아 거부로 끝나므로 승인 위조가 아니라 방해입니다. 후자는 사회공학입니다. 진짜 프롬프트가 대기 중일 때 가짜 화면으로 `y` 를 유도하면 그 `y` 는 브로커가 읽습니다. 프롬프트 내용의 제어 문자 정리(`docs/policy-dsl.md` 3.4)가 화면 덮어쓰기를 어렵게 하지만 없애지는 못합니다. 둘 다 stdio 재지정이나 별도 세션 없이는 닫히지 않으며 그 방향은 대화형 사용을 깹니다. ### 8.6 프롬프트 내용이 잘린 관측만큼만 완전하다 @@ -785,9 +818,13 @@ PID/마운트/유저/네트워크 네임스페이스 어느 것도 쓰지 않습 ### 9.6 환경 정화가 고정 거부 목록이다 -`INJECTION_PREFIXES = ["LD_", "DYLD_"]`와 14개 정확 일치 목록입니다 (`session.rs:418-436`). +`INJECTION_PREFIXES = ["LD_", "DYLD_"]`, 14개 정확 일치 목록 `INJECTION_EXACT`, 그리고 호스트 데몬 소켓을 가리키는 `SOCKET_HANDLE_EXACT = ["SSH_AUTH_SOCK", "DOCKER_HOST"]`입니다 (`session.rs`의 `sanitize_env`). + +허용 목록이 아니라 거부 목록이라 목록에 없는 로더/인터프리터 훅은 그대로 통과합니다. `JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `GEM_PATH`, `LUA_INIT`, `R_PROFILE`, `PYTHONHOME`, `GIT_CONFIG_GLOBAL`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`가 예입니다. `PATH`는 의도적으로 보존합니다. + +`SSH_AUTH_SOCK`과 `DOCKER_HOST`를 벗기는 근거는 시크릿 경로 기본 deny의 연장입니다. 베이스라인은 `~/.ssh`를 forbid하지만 ssh-agent 소켓은 같은 키로 서명하는 능력을 다른 문으로 내주고, `docker.sock`은 호스트 루트와 다름없는 능력입니다. 키 파일을 막으면서 그 키를 쓰는 소켓의 주소를 자식에게 건네는 것은 모순이므로, 둘 다 기본으로 벗깁니다. 완화는 사용자가 정책으로 정당화해야 하는데 **지금은 그 어휘가 없습니다** (6.8, 유닉스 소켓과 환경 변수 모두). 곧 airlock 아래에서 ssh-agent나 Docker를 써야 하는 사용자는 현재 정당한 경로가 없으며, 이는 조용히 넘기는 쪽이 아니라 닫아 두는 쪽을 택한 결과입니다. 완화 축은 `kind = "env"`나 유닉스 소켓 규칙으로 명시적으로 들어와야 하고 기본값을 되돌리는 방식이어서는 안 됩니다. -허용 목록이 아니라 거부 목록이라 목록에 없는 로더/인터프리터 훅은 그대로 통과합니다. `JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `GEM_PATH`, `LUA_INIT`, `R_PROFILE`, `PYTHONHOME`, `GIT_CONFIG_GLOBAL`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`가 예입니다. `PATH`는 의도적으로 보존합니다 (`session.rs:438-441`). +벗기는 것은 가리키는 값이지 소켓 자체가 아닙니다. 소켓 경로는 `file-read-metadata`(4.5)로 다시 찾을 수 있으므로 실제 차단은 커널 층의 몫입니다. macOS 프록시 모드는 유닉스 소켓을 두 리터럴로 좁혀 막고(4.5), macOS의 egress allow 모드와 Linux는 막지 않습니다 (5.13, `egress-proxy.md` 6.2). ### 9.7 강제 gap이 감사 체인에 절대 기록되지 않는다 @@ -815,7 +852,7 @@ PID/마운트/유저/네트워크 네임스페이스 어느 것도 쓰지 않습 ### 10.1 CLI 표면 -- **`init`이 없습니다.** 서브커맨드는 `run`, `audit {verify,show,list}`, `policy {explain,check,profile}`뿐입니다 (`main.rs:31-41`). 온보딩은 예제를 손으로 복사하는 것입니다. +- **`init`이 없습니다.** 서브커맨드는 `run`, `audit {verify,show,list,report,ack}`, `policy {explain,check,profile,trust}`, `setup` 입니다 (`main.rs`). 온보딩은 `setup` 마법사이거나 예제를 손으로 복사하는 것입니다. - **README의 빠른 시작이 정책 없이는 그대로 실패합니다.** 베이스라인에 egress allow가 하나도 없어 `airlock run -- claude`는 `api.anthropic.com`을 DNS로도 못 찾고 ENOTFOUND로 죽습니다. 이 사실은 `examples/policy/claude-code.toml:5-8`에만 적혀 있습니다. - **학습 모드가 정책을 만들어 주지 않습니다.** `--observe`의 도움말은 "학습 모드"인데 (`cmd_run.rs:16-17`), 관측된 세션의 감사 로그를 정책 규칙으로 바꾸는 `policy generate`/`suggest`가 없습니다. 곧 "observe -> 로그 손으로 읽기 -> TOML 손으로 쓰기" 루프가 전부 수동이고, `docs/design.md` 8장이 스스로 꼽은 "false positive로 인한 개발자 불편" 리스크의 대응이 전문가의 수작업입니다. - **`run`에 `--dry-run`이 없고 정책 시뮬레이션이 단건뿐입니다.** `policy explain`은 한 번에 파일 하나 또는 exec 하나 또는 호스트 하나만 평가합니다 (`cmd_policy.rs:112-139`). 정책 파일을 픽스처 집합에 대해 회귀 테스트할 방법이 없어 정책 리팩터링을 눈으로 검증합니다. @@ -831,6 +868,7 @@ PID/마운트/유저/네트워크 네임스페이스 어느 것도 쓰지 않습 - **상위 디렉토리로 거슬러 올라가지 않습니다** (`paths.rs:79-90`). 하위 디렉토리에서 실행하면 프로젝트 루트의 정책을 통째로 무시하고 조용히 홈 설정이나 베이스라인으로 떨어집니다. 빈 후보 슬롯이 심기 공격 표면이라는 근거는 타당하지만 (`paths.rs:72-75`), **한 단계 위에 정책이 있는데도 경고가 없습니다.** - **정책에는 `XDG_CONFIG_HOME`을 무시하면서 감사에는 `XDG_DATA_HOME`을 존중합니다** (`paths.rs:81` vs `60-62`). 같은 바이너리 안에서 XDG 동작이 일관되지 않고, XDG 설정 경로를 옮긴 사용자는 오류 없이 베이스라인 전용 강제를 받습니다. - **정책을 못 찾았을 때의 폴백이 조용합니다** (`cmd_run.rs:114-130`). "정책 파일을 찾지 못했다"는 줄이 없고 배너에 이름/규칙 수/다이제스트만 나옵니다. `airlock.tml`로 오타 났거나 cwd가 틀린 경우를 다이제스트를 읽어야만 구분합니다. +- **처음 보는 정책 파일은 터미널 없이 실행되지 않습니다** (`cmd_run.rs`, `trust.rs`). 정책 신뢰 기록에 없거나 다이제스트가 다른 파일은 `/dev/tty`로 확인을 받아야 하며, CI 나 헤드리스에서는 종료 코드 78 로 거부됩니다. 사람이 먼저 `airlock policy trust `를 돌려야 하고, 이 확인을 끄는 플래그나 환경 변수는 의도적으로 없습니다. 기록은 감사 루트마다 따로이므로 `--audit-dir`를 바꾸면 같은 파일도 다시 물어봅니다. ### 10.3 배포와 공급망 diff --git a/docs/policy-dsl.md b/docs/policy-dsl.md index a572003..67f93c3 100644 --- a/docs/policy-dsl.md +++ b/docs/policy-dsl.md @@ -245,12 +245,20 @@ env-files probes: ~/proj/.env, ~/proj/api/.env.production 실제 우회가 일어나는 지점입니다. 정책 엔진은 규칙과 대조하기 전에 요청 경로에 아래를 순서대로 적용합니다. -1. `~`를 홈 디렉토리로 펼칩니다. `~user` 형태는 v1에서 지원하지 않으며 로드를 거부합니다. -2. 상대 경로면 그 시점의 cwd를 기준으로 절대 경로로 만듭니다. -3. **요청 경로**를 만듭니다. `.`과 빈 세그먼트를 지우고 `..`를 문자열 수준에서 해소합니다. 루트보다 위로 올라가는 `..`는 루트에서 멈춥니다. -4. **해소 경로**를 만듭니다. 3번의 문자열 정리를 하기 **전**의 절대 경로에서 심볼릭 링크를 먼저 따라간 다음, 그 결과에 문자열 정리를 적용합니다. 커널이 링크를 먼저 따라가고 `..`를 나중에 적용하기 때문이며, 순서를 뒤집어 `..`를 먼저 지우면 `link/..` 같은 경로가 커널이 실제로 여는 곳과 다른 곳을 가리키게 됩니다. 경로가 실제로 존재하면 그대로 해소하고, 존재하지 않으면 존재하는 가장 긴 앞부분만 해소한 뒤 나머지를 문자열로 이어 붙입니다. +1. 첫 NUL 바이트에서 경로를 자릅니다. 커널은 C 문자열을 받으므로 NUL 뒤는 존재하지 않는 것과 같습니다. +2. `~`를 홈 디렉토리로 펼칩니다. `~user` 형태는 v1에서 지원하지 않으며 로드를 거부합니다. +3. 상대 경로면 그 시점의 cwd를 기준으로 절대 경로로 만듭니다. +4. **요청 경로**를 만듭니다. `.`과 빈 세그먼트를 지우고 `..`를 문자열 수준에서 해소합니다. 루트보다 위로 올라가는 `..`는 루트에서 멈춥니다. +5. **해소 경로**를 만듭니다. 4번의 문자열 정리를 하기 **전**의 절대 경로에서 심볼릭 링크를 먼저 따라간 다음, 그 결과에 문자열 정리를 적용합니다. 커널이 링크를 먼저 따라가고 `..`를 나중에 적용하기 때문이며, 순서를 뒤집어 `..`를 먼저 지우면 `link/..` 같은 경로가 커널이 실제로 여는 곳과 다른 곳을 가리키게 됩니다. + - 경로가 통째로 존재하면 커널의 `realpath`가 곧 답입니다. + - 존재하지 않으면(대상이 아직 없는 링크, 아직 만들어지지 않은 꼬리) 루트부터 한 구성 요소씩 걷습니다. 링크를 만나면 그 대상을 남은 구성 요소 앞에 끼워 넣고, **상대 대상은 그 시점까지 해소된 부모에 붙입니다.** 어휘적 부모에 붙이면 `ws/dir -> /out/dir`, `/out/dir/x -> ../id_rsa`에서 커널은 `/out/id_rsa`를 여는데 엔진은 `ws/id_rsa`를 판정해 작업 공간 규칙으로 열어 버립니다. `..`는 그 시점까지 해소된 경로에서 한 단계 올라갑니다. + - 없는 구성 요소를 만나면 거기서 멈추고, 존재하는 접두는 디스크에 적힌 대소문자로 맞춘 뒤 나머지를 문자열로 이어 붙입니다. + - 링크는 40홉까지 따라갑니다. 넘으면 커널이 `ELOOP`를 내는 자리이며, 그 시점까지 해소된 경로에 나머지를 문자열로 붙여 판정합니다. +6. **firmlink 접기 (macOS).** `/Users/x/.ssh`와 `/System/Volumes/Data/Users/x/.ssh`는 firmlink로 같은 디렉토리인데 `realpath`는 firmlink를 넘지 않아 후자를 후자로 돌려줍니다. 그대로 두면 `~/.ssh/**` forbid를 포함한 전 티어가 후자를 놓칩니다. 그래서 요청 경로와 해소 경로 양쪽에서 `/System/Volumes/Data/<대상>/...`을 `/<루트>/...`로 접습니다. 대상은 `/usr/share/firmlinks`(한 줄에 `<루트 경로>\t`)에 있는 것으로 한정하며, 표는 root 소유이고 다른 사용자가 쓸 수 없을 때만 읽고 내용은 데이터로만 다룹니다(절대 경로가 아닌 루트, 상대 경로가 아닌 Data 열, `.`/`..`/NUL이 섞인 줄은 버림). 표를 읽지 못하면 macOS 10.15 이후의 내장 기본 목록으로 물러섭니다. APFS 시스템 볼륨은 대소문자를 구분하지 않으므로 접두와 대상은 ASCII 대소문자를 무시하고 맞춥니다. Linux에는 firmlink가 없으므로 이 단계는 아무것도 하지 않습니다. -곧 하나의 요청에서 경로가 두 개 나옵니다. 에이전트가 적어 낸 **요청 경로**와, 링크를 따라간 끝에 실제로 닿는 **해소 경로**입니다. +같은 접기를 **규칙 경로**에도 적용합니다. `Pattern` 파싱, 자기보호 규칙의 리터럴 경로, 그리고 아래 5절의 해소 변형(`/etc` -> `/private/etc`) 전부에서 앞쪽 고정 구간을 접으므로, 정책이 어느 표기로 적혀 있든 요청이 어느 표기로 오든 하나로 수렴합니다. 다이제스트는 적힌 그대로의 패턴 문자열로 계산하므로 접기의 영향을 받지 않습니다. + +곧 하나의 요청에서 경로가 두 개 나옵니다. 에이전트가 적어 낸 **요청 경로**와, 링크를 따라간 끝에 실제로 닿는 **해소 경로**입니다. `airlock policy explain`은 둘 다 접힌 뒤의 표기로 보여 줍니다. ### 4.1 요청 경로와 해소 경로 양쪽으로 평가한다 @@ -556,6 +564,18 @@ action = "allow" **"쓰이지 않은 `overrides`" 판정.** 그 사용자 규칙 자신이 닿는 범위가 지목한 `forbid` 규칙의 범위와 겹치는지로 판정합니다. `forbid`의 probe 경로를 기준으로 판정하지 않는 이유는, [3.2절의 `read-ssh-config`](#32-forbid와-overrides) 예제처럼 probe를 비껴가면서도 실제로는 예외가 성립하는 정당한 규칙이 "쓰이지 않았다"고 잘못 보고되기 때문입니다. +### 10.1 정책 파일 탐색과 출처 신뢰 + +브로커는 `--policy`가 없으면 `cwd/airlock.toml` -> `cwd/.airlock.toml` -> `~/.config/airlock/policy.toml` 순으로 **처음 존재하는 파일 하나**를 읽습니다. 상위 디렉토리로 거슬러 올라가지 않으며, 하나도 없으면 내장 베이스라인만 적용됩니다. 존재하지 않는 후보도 티어 0 자기보호의 대상입니다([3.1절](#31-자기보호-규칙-티어-0)). 비어 있는 자리에 에이전트가 정책을 만들어 두면 다음 실행이 그것을 읽기 때문입니다. + +파일은 `read_trusted`로 읽습니다. 마지막 구성 요소가 심볼릭 링크면 거부하고, 호출자 소유가 아니거나 그룹이나 타인이 쓸 수 있는 파일도 거부합니다. + +그러나 Linux Landlock 은 inode 에 걸리므로 아직 없는 경로의 생성을 거부하지 못하고, 작업 공간 루트는 통째로 쓰기 허용입니다. 그래서 정책 파일을 읽는 것만으로는 그 파일이 사람이 둔 것인지 에이전트가 심은 것인지 알 수 없습니다. `airlock run`은 로드 성공 직후 **정책 신뢰 기록**(`<감사 루트>/trusted-policies.jsonl`)에서 그 경로의 마지막 다이제스트를 찾고, 기록이 없거나 다르면 `/dev/tty`로 사람에게 묻습니다. 승인은 `y`뿐이고, 터미널이 없으면 묻지 않고 종료 코드 78 로 거부하며, `--yes`로도 건너뛸 수 없습니다. 기록은 `airlock policy trust [PATH]`로 직접 남기고 `--list`로 봅니다. `airlock setup`이 만든 파일은 자동으로 기록됩니다. 기록 파일의 형식과 근거는 [`design.md` 9.6](./design.md#96-정책-출처-신뢰-로드-시점-tofu-기록)에 있습니다. + +기록에 비교하는 값은 [11절](#11-정책-다이제스트)의 다이제스트입니다. 곧 주석이나 공백만 바꾼 정책은 다시 묻지 않고, 규칙 하나의 `action`이나 순서가 바뀐 정책은 반드시 다시 묻습니다. + +`airlock policy check`, `explain`, `profile`은 정책을 실행에 옮기지 않으므로 이 확인을 하지 않습니다. + ## 11. 정책 다이제스트 감사 로그의 제네시스(genesis) 엔트리에 들어가는 값입니다(`docs/audit-format.md` 5.1). @@ -650,6 +670,8 @@ $ airlock policy explain --host mirror.example.com --port 80 --protocol http - `link -> ~/.ssh` 후 `link/../.ssh/id_rsa` (커널은 링크를 먼저 따르고 `..`를 나중에 적용) - 루트 위로 올라가는 `../../../../etc/shadow` - 존재하지 않는 경로를 포함한 `~/.ssh/nonexistent/../id_rsa` +- `~/work/dir -> ~/.ssh/sub`, `~/.ssh/sub/x -> ../id_rsa` 후 `~/work/dir/x` (상대 링크 대상은 해소된 부모에 붙임). 대상이 아직 없는 `~/.ssh/sub/ak -> ../authorized_keys`도 같은 결과여야 함 +- macOS 한정. `/System/Volumes/Data/<홈>/.ssh/id_rsa`가 `~/.ssh/**`에 걸리고, Data 표기로 등록한 자기보호 후보와 Data 표기로 적은 사용자 규칙이 루트 표기 요청과 하나로 수렴함 반대 방향으로, `allow` 규칙이 대소문자나 정규화 변형 때문에 넓어지지 않는지 확인합니다. diff --git a/docs/setup-wizard.md b/docs/setup-wizard.md index 6184f41..6fb9b32 100644 --- a/docs/setup-wizard.md +++ b/docs/setup-wizard.md @@ -18,7 +18,8 @@ 3. 작업 공간 경로 입력 (기본값 cwd, 절대 경로 또는 `~` 경로만 허용, 홈 전체면 경고) 4. 출력 경로 결정 (기본 `./airlock.toml`, 기존 파일은 명시적 확인 없이 덮어쓰지 않음) 5. 생성 후 `Policy::load_str`로 검증. 검증을 통과하기 전에는 파일을 쓰지 않는다 -6. 요약과 함께 바로 실행할 `airlock run` 명령을 안내 +6. 쓴 파일의 다이제스트를 정책 신뢰 기록에 자동으로 남긴다 (`docs/design.md` 9.6). 사용자가 방금 생성에 동의했으므로 첫 `airlock run` 이 다시 묻지 않는다. 기록 실패는 경고와 `airlock policy trust` 안내로만 남는다 +7. 요약과 함께 바로 실행할 `airlock run` 명령을 안내 ### 직접 설정