diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91a7fe2..d9a5a04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,21 @@ jobs: - name: 전체 검증 run: ./scripts/check.sh + # 보안 도구의 의존성에 권고가 뜨면 알아야 합니다. 이 게이트가 없으면 RUSTSEC이 + # 올라와도 아무도 모릅니다 + audit: + name: dependency audit + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + + - name: cargo-deny 설치 + run: cargo install cargo-deny --locked + + - name: 권고와 라이선스 검사 + run: cargo deny check advisories bans sources licenses + cross: name: cross compile runs-on: ubuntu-24.04 diff --git a/Cargo.lock b/Cargo.lock index be46403..f0b1d0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,7 @@ version = "0.1.0" dependencies = [ "airlock-audit", "airlock-broker", + "airlock-canonical", "airlock-policy", "clap", ] @@ -17,6 +18,7 @@ name = "airlock-audit" version = "0.1.0" dependencies = [ "airlock-canonical", + "libc", "serde", "serde_json", "sha2", @@ -27,6 +29,7 @@ name = "airlock-broker" version = "0.1.0" dependencies = [ "airlock-audit", + "airlock-canonical", "airlock-policy", "landlock", "libc", @@ -43,6 +46,7 @@ name = "airlock-policy" version = "0.1.0" dependencies = [ "airlock-canonical", + "libc", "serde", "sha2", "toml", diff --git a/crates/airlock-audit/Cargo.toml b/crates/airlock-audit/Cargo.toml index 7e06c0f..e90caeb 100644 --- a/crates/airlock-audit/Cargo.toml +++ b/crates/airlock-audit/Cargo.toml @@ -9,6 +9,7 @@ description = "Airlock의 해시체인 append-only 감사 로그와 검증기" [dependencies] airlock-canonical.workspace = true +libc.workspace = true sha2.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/airlock-audit/src/log.rs b/crates/airlock-audit/src/log.rs index c0eda04..206186a 100644 --- a/crates/airlock-audit/src/log.rs +++ b/crates/airlock-audit/src/log.rs @@ -17,6 +17,30 @@ pub const BROKER_ACTOR: &str = "airlock"; const DIR_MODE: u32 = 0o700; const FILE_MODE: u32 = 0o600; + +/// 감사 루트와 그 상위를 0700으로 만듭니다. +/// +/// `create_dir_all` 은 umask 를 따르므로 umask 022 에서 `sessions/` 가 0755 가 됩니다. +/// 같은 머신의 다른 사용자가 세션 이름을 훑거나 디렉토리를 미리 놓아둘 수 있으므로, +/// 우리가 만드는 구성 요소는 전부 소유자 전용으로 둡니다. +/// +/// # Errors +/// 중간 구성 요소를 만들지 못하면 실패합니다. +fn create_dir_all_private(dir: &Path) -> Result<()> { + if dir.exists() { + return Ok(()); + } + if let Some(parent) = dir.parent() { + create_dir_all_private(parent)?; + } + match fs::DirBuilder::new().mode(DIR_MODE).create(dir) { + Ok(()) => Ok(()), + // 경쟁으로 누가 먼저 만들었으면 그대로 씁니다 + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(e) => Err(Error::io(dir, e)), + } +} + pub const HEAD_VERSION: u32 = 1; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -62,7 +86,7 @@ impl AuditLog { return Err(Error::SessionDirExists(dir)); } if let Some(parent) = dir.parent() { - fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; + create_dir_all_private(parent)?; } fs::DirBuilder::new() .mode(DIR_MODE) @@ -142,11 +166,14 @@ impl AuditLog { let tmp = self.dir.join("head.json.tmp"); let final_path = self.dir.join(HEAD_FILE); { + // 남아 있는 tmp 는 지우고 새로 만듭니다. create_new 와 O_NOFOLLOW 로 열어 + // 미리 놓인 심볼릭 링크를 따라가지 않게 합니다 + let _ = fs::remove_file(&tmp); let mut f = OpenOptions::new() .write(true) - .create(true) - .truncate(true) + .create_new(true) .mode(FILE_MODE) + .custom_flags(libc::O_NOFOLLOW) .open(&tmp) .map_err(|e| Error::io(&tmp, e))?; f.write_all(&body).map_err(|e| Error::io(&tmp, e))?; @@ -197,7 +224,13 @@ pub fn read_entries_lossy(dir: impl AsRef) -> Result<(Vec, Option(&line) { Ok(entry) => entries.push(entry), diff --git a/crates/airlock-broker/Cargo.toml b/crates/airlock-broker/Cargo.toml index 202f8a6..8f0e4ea 100644 --- a/crates/airlock-broker/Cargo.toml +++ b/crates/airlock-broker/Cargo.toml @@ -9,6 +9,7 @@ description = "Airlock의 OS 강제 층. Linux Landlock과 seccomp, macOS Seatbe [dependencies] airlock-audit.workspace = true +airlock-canonical.workspace = true airlock-policy.workspace = true libc.workspace = true unicode-normalization.workspace = true diff --git a/crates/airlock-broker/src/approve.rs b/crates/airlock-broker/src/approve.rs index 8975b67..a8cb219 100644 --- a/crates/airlock-broker/src/approve.rs +++ b/crates/airlock-broker/src/approve.rs @@ -16,6 +16,7 @@ use std::os::fd::{AsRawFd, RawFd}; use std::time::{Duration, Instant}; use airlock_audit::Granted; +use airlock_canonical::display::sanitize; use airlock_policy::MatchedRule; /// 사람 응답을 기다리는 기본 상한. @@ -166,33 +167,6 @@ fn wait_readable(fd: RawFd, timeout: Duration) -> bool { } } -/// 승인 화면에 그대로 넣으면 화면을 다시 칠하거나 글자 순서를 뒤집을 수 있는 문자인지 봅니다. -/// -/// 제어 문자와 양방향 텍스트 재정렬 문자가 대상입니다. 경로와 argv는 신뢰할 수 없는 -/// 입력이므로 여기를 통과하지 않으면 승인 프롬프트 자체가 위조 가능해집니다 -fn is_display_unsafe(ch: char) -> bool { - ch.is_control() - || matches!(ch, - '\u{200E}' | '\u{200F}' - | '\u{202A}'..='\u{202E}' - | '\u{2066}'..='\u{2069}') -} - -fn sanitize(value: &str) -> String { - if !value.chars().any(is_display_unsafe) { - return value.to_string(); - } - let mut out = String::with_capacity(value.len()); - for ch in value.chars() { - if is_display_unsafe(ch) { - out.push_str(&format!("\\u{{{:04x}}}", ch as u32)); - } else { - out.push(ch); - } - } - out -} - fn render(request: &ApprovalRequest) -> String { let mut out = String::new(); out.push_str("\n\x1b[1;33m┌─ airlock 승인 요청 ─────────────────────────────\x1b[0m\n"); diff --git a/crates/airlock-broker/src/landlock.rs b/crates/airlock-broker/src/landlock.rs index 5577c7b..ed4db63 100644 --- a/crates/airlock-broker/src/landlock.rs +++ b/crates/airlock-broker/src/landlock.rs @@ -24,7 +24,7 @@ use airlock_policy::rule::Matcher; use airlock_policy::{Action, FileMode, Policy}; use landlock::{ ABI, Access, AccessFs, AccessNet, NetPort, PathBeneath, Ruleset, RulesetAttr, - RulesetCreatedAttr, RulesetStatus, + RulesetCreatedAttr, RulesetStatus, Scope, }; use crate::enforcer::Enforcer; @@ -53,13 +53,17 @@ const SYSTEM_READ_PATHS: &[&str] = &[ "/proc/self", ]; +/// 자식에게 열어 주는 장치 노드. +/// +/// `/dev/tty`는 일부러 뺐습니다. 그것은 제어 터미널이며 승인 프롬프트가 나가는 통로입니다. +/// 자식이 열 수 있으면 가짜 승인 화면을 그리거나 사용자가 입력한 답을 먼저 읽어 갈 수 있어 +/// `ask`가 무의미해집니다 const DEV_RW_PATHS: &[&str] = &[ "/dev/null", "/dev/zero", "/dev/full", "/dev/random", "/dev/urandom", - "/dev/tty", ]; fn read_access(abi: ABI) -> landlock::BitFlags { @@ -457,14 +461,19 @@ fn build_plan(policy: &Policy, opts: &ProfileOptions) -> Plan { add_root(&mut walker, ws, true, &mut plan); } - // 정책이 명시적으로 allow 한 파일 경로 중 구체 경로를 추가로 엽니다 + // 정책이 명시적으로 allow 한 파일 경로 중 구체 경로를 추가로 엽니다. + // mode 를 그대로 따릅니다. 읽기만 허용한 규칙에 쓰기까지 열어 주면 정책 파일이 + // 말하는 것보다 커널이 넓어지고, 같은 정책이 macOS 와 Linux 에서 다르게 걸립니다 for rule in policy.user_rules() { if rule.action != Action::Allow { continue; } - let Matcher::File { paths, .. } = &rule.matcher else { + let Matcher::File { paths, modes } = &rule.matcher else { continue; }; + let writable = modes.contains(FileMode::Write) + || modes.contains(FileMode::Create) + || modes.contains(FileMode::Delete); for pattern in paths { let raw = pattern.raw(); if raw.contains('*') || raw.contains('?') { @@ -472,7 +481,7 @@ fn build_plan(policy: &Policy, opts: &ProfileOptions) -> Plan { } let candidate = pattern.witness(); if candidate.exists() { - add_root(&mut walker, &candidate, true, &mut plan); + add_root(&mut walker, &candidate, writable, &mut plan); } } } @@ -497,10 +506,44 @@ fn build_plan(policy: &Policy, opts: &ProfileOptions) -> Plan { )); } + plan_exec_gap(policy, &mut plan); plan_network(policy, opts, &mut plan); plan } +/// exec 제한이 커널에 걸리지 않는다는 사실을 gap 으로 남깁니다. +/// +/// Landlock 은 허용 목록 방식이라 "이 디렉토리는 열되 그 안의 이 바이너리만 실행 금지"를 +/// 표현할 수 없습니다. macOS 는 `(deny process-exec* ...)` 로 같은 규칙을 커널에 내리므로, +/// 선언하지 않으면 같은 정책 파일이 플랫폼마다 다르게 걸리는데 사용자는 그것을 알 수 +/// 없습니다. +fn plan_exec_gap(policy: &Policy, plan: &mut Plan) { + let mut ids: Vec<&str> = Vec::new(); + for rule in policy.user_rules().iter().chain(policy.baseline_rules()) { + if !rule.action.is_restrictive() { + continue; + } + let names_exec = match &rule.matcher { + Matcher::Exec { .. } => true, + Matcher::File { modes, .. } => modes.contains(FileMode::Exec), + Matcher::Egress { .. } => false, + }; + if names_exec { + ids.push(&rule.id); + } + } + if ids.is_empty() { + return; + } + ids.sort_unstable(); + ids.dedup(); + plan.gaps.push(format!( + "exec 제한은 Landlock 이 표현할 수 없어 커널에서 강제되지 않음. \ + 중계 층이 관측할 뿐이며 --mediate off 면 아무것도 남지 않음: {}", + ids.join(", ") + )); +} + fn plan_network(policy: &Policy, opts: &ProfileOptions, plan: &mut Plan) { if !opts.allow_network { return; @@ -533,7 +576,17 @@ fn plan_network(policy: &Policy, opts: &ProfileOptions, plan: &mut Plan) { return; } - if !host_scoped.is_empty() { + if plan.unrestricted_net { + // 포트를 적지 않은 allow 가 하나라도 있으면 TCP 제한 자체를 걸지 않습니다. + // 이때 "포트까지는 강제한다"고 알리면 사실과 정반대가 됩니다 + plan.gaps.push( + "포트를 특정하지 않은 egress allow 규칙이 있어 아웃바운드를 통째로 열었음. \ + 포트 제한도 걸리지 않으므로 규칙마다 port 를 적어야 함" + .to_string(), + ); + } + + if !host_scoped.is_empty() && !plan.unrestricted_net { plan.gaps.push(format!( "호스트 단위 egress 규칙은 Landlock으로 강제되지 않음. 포트까지만 강제하며 \ 호스트 판정은 프록시 층이 필요함: {}", @@ -673,6 +726,15 @@ fn apply(plan: &Plan, abi: ABI) -> std::io::Result { .map_err(std::io::Error::other)?; } + // ABI v6부터 도메인 밖으로 나가는 시그널과 추상 유닉스 소켓 연결을 막을 수 있습니다. + // 이것이 없으면 격리된 프로세스가 브로커와 감독 스레드에 시그널을 보내고, + // dbus 나 ssh-agent 같은 추상 소켓에 그대로 붙습니다 + if abi >= ABI::V6 { + ruleset = ruleset + .scope(Scope::from_all(abi)) + .map_err(std::io::Error::other)?; + } + let mut created = ruleset.create().map_err(std::io::Error::other)?; for (paths, access) in [ diff --git a/crates/airlock-broker/src/profile.rs b/crates/airlock-broker/src/profile.rs index b0781f6..276baa3 100644 --- a/crates/airlock-broker/src/profile.rs +++ b/crates/airlock-broker/src/profile.rs @@ -29,12 +29,17 @@ const SYSTEM_READ_SUBPATHS: &[&str] = &[ "/Applications", ]; +/// 자식에게 열어 주는 장치 노드. +/// +/// `/dev/tty`는 일부러 뺐습니다. 그것은 제어 터미널이며 승인 프롬프트가 나가는 통로입니다. +/// 자식이 열 수 있으면 가짜 승인 화면을 그리거나 사용자가 입력한 답을 먼저 읽어 갈 수 있어 +/// `ask`가 무의미해집니다. 상속된 stdin·stdout·stderr는 그대로 두므로 보통의 대화형 +/// 프로그램은 영향받지 않습니다 const DEV_RW_LITERALS: &[&str] = &[ "/dev/null", "/dev/zero", "/dev/random", "/dev/urandom", - "/dev/tty", "/dev/stdin", "/dev/stdout", "/dev/stderr", @@ -107,6 +112,17 @@ fn mode_writes(modes: ModeSet) -> bool { || modes.contains(FileMode::Delete) } +/// 프로파일 주석에 넣을 문자열에서 줄을 깨는 문자를 지웁니다. +/// +/// 정책 로더가 규칙 id를 이미 영숫자와 몇 개 기호로 제한하지만, 주석 한 줄이 깨지면 +/// 뒤 내용이 살아 있는 지시문이 되어 프로파일 전체가 뒤집힙니다. 강제 층은 로더의 +/// 검증을 신뢰하지 않고 방출 시점에서 한 번 더 막습니다. +fn comment(raw: &str) -> String { + raw.chars() + .map(|c| if c.is_control() { '_' } else { c }) + .collect() +} + pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { let mut out = String::new(); let mut untranslatable = Vec::new(); @@ -122,9 +138,14 @@ pub fn generate(policy: &Policy, opts: &ProfileOptions) -> GeneratedProfile { out.push_str("(allow process-exec*)\n"); out.push_str("(allow signal (target self))\n"); out.push_str("(allow sysctl-read)\n"); - out.push_str("(allow mach-lookup)\n"); out.push_str("(allow ipc-posix-shm)\n"); - out.push_str("(allow file-ioctl)\n\n"); + out.push_str("(allow file-ioctl)\n"); + // mach 서비스는 통째로 열되 정책 밖 유출 통로로 알려진 것부터 되막습니다. 목록을 + // 화이트리스트로 뒤집으면 개발 툴체인이 버전마다 깨지므로 deny 를 뒤에 둡니다 + out.push_str("(allow mach-lookup)\n"); + out.push_str(";; 클립보드는 정책 모델 밖의 읽기 쓰기 통로임\n"); + out.push_str("(deny mach-lookup (global-name \"com.apple.pasteboard.1\"))\n"); + out.push_str("(deny mach-lookup (global-name \"com.apple.pboard\"))\n\n"); out.push_str(";; --- 경로 해석 ---\n"); out.push_str(";; 루트 노드 자체를 읽지 못하면 어떤 절대 경로도 해석되지 않아\n"); @@ -271,11 +292,11 @@ fn emit_exec_rules( out.push_str(&format!( "(deny process-exec* {}) ;; {}\n", target.render(), - rule.id + comment(&rule.id) )); } } - Err(why) => untranslatable.push(format!("{} ({why})", rule.id)), + Err(why) => untranslatable.push(format!("{} ({why})", comment(&rule.id))), } } } @@ -348,14 +369,18 @@ fn emit_file_rules( sbpl::targets_for(pattern) }; if targets.is_empty() { - untranslatable.push(format!("{} {}", rule.id, pattern.raw())); + untranslatable.push(format!( + "{} {}", + comment(&rule.id), + comment(pattern.raw()) + )); } for target in targets { out.push_str(&format!( "({verb} {} {}) ;; {}\n", ops.join(" "), target.render(), - rule.id + comment(&rule.id) )); } } @@ -700,6 +725,24 @@ action = "deny" } } + #[test] + fn comment_strips_control_characters() { + // 규칙 id 는 정책 로더가 이미 제한하지만 강제 층은 그 검증을 신뢰하지 않습니다. + // 주석 한 줄이 깨지면 뒤 내용이 살아 있는 지시문이 되어 프로파일이 뒤집힙니다 + let payload = "ok\n(allow file-read* file-write* (subpath \"/\"))\n;;"; + let out = comment(payload); + assert!(!out.contains('\n'), "개행이 남으면 주석을 탈출함: {out}"); + assert_eq!(out.lines().count(), 1, "한 줄로 남아야 함: {out}"); + for raw in ["a\rb", "a\u{1b}[2Kb", "a\u{0}b"] { + let got = comment(raw); + assert!( + !got.chars().any(char::is_control), + "{raw:?}에 제어 문자가 남음: {got}" + ); + } + assert_eq!(comment("build-cache.v2_x-y"), "build-cache.v2_x-y"); + } + #[test] fn generated_profile_is_balanced() { let p = generate(&baseline(), &ProfileOptions::default()); diff --git a/crates/airlock-broker/src/seatbelt.rs b/crates/airlock-broker/src/seatbelt.rs index 9e70a87..47c5046 100644 --- a/crates/airlock-broker/src/seatbelt.rs +++ b/crates/airlock-broker/src/seatbelt.rs @@ -187,6 +187,9 @@ impl Enforcer for SeatbeltEnforcer { "커널이 거부한 개별 파일·네트워크 접근은 감사 로그에 남지 않음. \ 체인에는 세션 단위 기록만 있음" .to_string(), + "mach 서비스는 클립보드를 뺀 나머지가 열려 있음. XPC 로 오가는 것은 \ + 정책 모델과 감사 로그 밖임" + .to_string(), ]; if !self.untranslatable.is_empty() { gaps.push(format!( diff --git a/crates/airlock-broker/src/session.rs b/crates/airlock-broker/src/session.rs index 8e2e6be..6904c5e 100644 --- a/crates/airlock-broker/src/session.rs +++ b/crates/airlock-broker/src/session.rs @@ -411,6 +411,54 @@ pub fn run( which(program).ok_or_else(|| BrokerError::ProgramNotFound(program.to_string()))?; 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)); @@ -418,6 +466,7 @@ pub fn run( let mut cmd = Command::new(&resolved); cmd.args(args); cmd.current_dir(&config.cwd); + sanitize_env(&mut cmd); // 중계 훅을 강제 층보다 먼저 겁니다. listener fd를 부모에게 넘기는 sendmsg가 // 샌드박스 적용 전에 끝나야 합니다 diff --git a/crates/airlock-canonical/src/display.rs b/crates/airlock-canonical/src/display.rs new file mode 100644 index 0000000..ae2ab39 --- /dev/null +++ b/crates/airlock-canonical/src/display.rs @@ -0,0 +1,81 @@ +//! 이 모듈은 신뢰할 수 없는 문자열을 터미널에 안전하게 내보내도록 정제합니다. +//! +//! # Features +//! 경로, argv, 호스트, 규칙 id는 전부 경계 밖에서 온 값입니다. 이스케이프 시퀀스가 그대로 +//! 나가면 에이전트가 승인 화면이나 감사 뷰어의 출력을 다시 칠할 수 있습니다. 제어 문자와 +//! 양방향 재정렬 문자, 줄 구분 문자를 눈에 보이는 표기로 바꿔 한 값이 한 줄을 넘지 못하게 +//! 합니다. +//! +//! # Examples +//! ```rust +//! use airlock_canonical::display::sanitize; +//! assert_eq!(sanitize("ok"), "ok"); +//! assert_eq!(sanitize("a\u{1b}[2Kb"), "a\\u{001b}[2Kb"); +//! ``` + +/// 화면을 다시 칠하거나 글자 순서를 뒤집을 수 있는 문자인지 봅니다. +/// +/// # Arguments +/// `ch` - 검사할 문자 +pub fn is_display_unsafe(ch: char) -> bool { + ch.is_control() + || matches!(ch, + '\u{200E}' | '\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2066}'..='\u{2069}' + // Zl 과 Zp. is_control 이 잡지 못하지만 줄을 나누는 터미널이 있음 + | '\u{2028}' | '\u{2029}') +} + +/// 신뢰할 수 없는 문자열을 표시용으로 정제합니다. +/// +/// # Arguments +/// `value` - 경계 밖에서 온 문자열 +pub fn sanitize(value: &str) -> String { + if !value.chars().any(is_display_unsafe) { + return value.to_string(); + } + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + if is_display_unsafe(ch) { + out.push_str(&format!("\\u{{{:04x}}}", ch as u32)); + } else { + out.push(ch); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_text_is_untouched() { + assert_eq!(sanitize("/Users/me/work/a.rs"), "/Users/me/work/a.rs"); + assert_eq!(sanitize("한글 경로/파일.txt"), "한글 경로/파일.txt"); + } + + #[test] + fn escape_sequences_are_neutralized() { + let out = sanitize("a\u{1b}[2K\rb"); + assert!(!out.contains('\u{1b}'), "ESC가 남으면 안 됨: {out}"); + assert!(!out.contains('\r'), "CR이 남으면 안 됨: {out}"); + } + + #[test] + fn newlines_cannot_split_a_line() { + for raw in ["a\nb", "a\u{2028}b", "a\u{2029}b"] { + let out = sanitize(raw); + assert_eq!(out.lines().count(), 1, "{raw:?}가 줄을 나눔: {out}"); + } + } + + #[test] + fn bidi_reordering_is_neutralized() { + for ch in ['\u{202E}', '\u{200F}', '\u{2066}'] { + let out = sanitize(&format!("a{ch}b")); + assert!(!out.contains(ch), "{ch:?}가 남으면 안 됨: {out}"); + } + } +} diff --git a/crates/airlock-canonical/src/hex.rs b/crates/airlock-canonical/src/hex.rs index 2b39f82..55e51ae 100644 --- a/crates/airlock-canonical/src/hex.rs +++ b/crates/airlock-canonical/src/hex.rs @@ -53,6 +53,14 @@ pub fn decode(s: &str) -> Result, HexError> { } pub fn decode_fixed(s: &str) -> Result<[u8; N], HexError> { + // 길이부터 봅니다. 먼저 decode 하면 신뢰할 수 없는 입력이 아무리 길어도 통째로 + // 할당하고 해독한 뒤에야 거부하게 됩니다 + if s.len() != N.saturating_mul(2) { + return Err(HexError::WrongLength { + expected: N, + got: s.len() / 2, + }); + } let v = decode(s)?; if v.len() != N { return Err(HexError::WrongLength { diff --git a/crates/airlock-canonical/src/lib.rs b/crates/airlock-canonical/src/lib.rs index dabc026..86f5b39 100644 --- a/crates/airlock-canonical/src/lib.rs +++ b/crates/airlock-canonical/src/lib.rs @@ -1,3 +1,4 @@ +pub mod display; pub mod hex; mod encoder; diff --git a/crates/airlock-policy/Cargo.toml b/crates/airlock-policy/Cargo.toml index 72af18e..7caa09b 100644 --- a/crates/airlock-policy/Cargo.toml +++ b/crates/airlock-policy/Cargo.toml @@ -9,6 +9,7 @@ description = "Airlock의 capability 정책 모델과 TOML DSL, 평가 엔진" [dependencies] airlock-canonical.workspace = true +libc.workspace = true serde.workspace = true sha2.workspace = true toml.workspace = true diff --git a/crates/airlock-policy/src/baseline.rs b/crates/airlock-policy/src/baseline.rs index 1d4c3f9..f6a146f 100644 --- a/crates/airlock-policy/src/baseline.rs +++ b/crates/airlock-policy/src/baseline.rs @@ -311,7 +311,8 @@ pub fn build(home: &Path) -> Result { #[derive(Debug, Clone)] pub struct SelfProtectPaths { pub audit_root: PathBuf, - pub policy_file: Option, + /// 정책 파일 후보 전체. 아직 없는 후보도 포함합니다 + pub policy_files: Vec, pub binary: Option, } @@ -333,7 +334,9 @@ pub fn self_protect(paths: &SelfProtectPaths) -> Vec { }, }); - if let Some(policy) = &paths.policy_file { + if !paths.policy_files.is_empty() { + // 아직 존재하지 않는 후보도 함께 막습니다. 정책 파일이 없을 때 대상이 하나 만들어 + // 두면 다음 실행이 그것을 읽으므로, 없는 경로야말로 막아야 하는 경로입니다 rules.push(Rule { id: "self:policy-file".to_string(), tier: Tier::SelfProtect, @@ -341,7 +344,11 @@ pub fn self_protect(paths: &SelfProtectPaths) -> Vec { reason: Some("정책 파일을 대상이 고칠 수 있으면 강제가 아님".to_string()), overrides: None, matcher: Matcher::File { - paths: vec![Pattern::literal(policy)], + paths: paths + .policy_files + .iter() + .map(|p| Pattern::literal(p.as_path())) + .collect(), modes: ModeSet::from_modes(W), }, }); @@ -451,7 +458,7 @@ mod tests { use crate::rule::Query; let paths = SelfProtectPaths { audit_root: PathBuf::from("/Users/me/.local/share/airlock"), - policy_file: Some(PathBuf::from("/Users/me/work/airlock.toml")), + policy_files: vec![PathBuf::from("/Users/me/work/airlock.toml")], binary: Some(PathBuf::from("/usr/local/bin/airlock")), }; let rules = self_protect(&paths); @@ -481,7 +488,7 @@ mod tests { let root = PathBuf::from("/Users/me/.local/share/airlock"); let rules = self_protect(&SelfProtectPaths { audit_root: root.clone(), - policy_file: None, + policy_files: Vec::new(), binary: None, }); assert!(rules[0].matches(&Query::File { @@ -495,7 +502,7 @@ mod tests { use crate::rule::Query; let rules = self_protect(&SelfProtectPaths { audit_root: PathBuf::from("/tmp/a*b"), - policy_file: None, + policy_files: Vec::new(), binary: None, }); assert!(rules[0].matches(&Query::File { @@ -515,7 +522,7 @@ mod tests { fn optional_self_protect_targets_are_skipped_when_absent() { let rules = self_protect(&SelfProtectPaths { audit_root: PathBuf::from("/tmp/audit"), - policy_file: None, + policy_files: Vec::new(), binary: None, }); assert_eq!(rules.len(), 1); diff --git a/crates/airlock-policy/src/dsl.rs b/crates/airlock-policy/src/dsl.rs index 7417a01..0d673b2 100644 --- a/crates/airlock-policy/src/dsl.rs +++ b/crates/airlock-policy/src/dsl.rs @@ -63,6 +63,34 @@ pub fn parse(src: &str) -> Result { Ok(toml::from_str(src)?) } +/// 규칙 id에 쓸 수 있는 문자인지 봅니다. +/// +/// id는 커널 강제 프로파일에 주석으로 그대로 들어갑니다. 개행이 섞이면 주석이 거기서 +/// 끝나고 뒤 내용이 살아 있는 지시문이 되므로 프로파일 전체를 다시 쓸 수 있습니다. +fn id_char_ok(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':') +} + +/// 규칙 id를 검증합니다. +/// +/// # Errors +/// 빈 문자열이거나 [`id_char_ok`]가 거부하는 문자가 있으면 [`LoadError::InvalidId`]입니다. +pub fn validate_id(id: &str) -> Result<(), LoadError> { + if id.is_empty() { + return Err(LoadError::InvalidId { + id: String::new(), + offender: '\0', + }); + } + match id.chars().find(|c| !id_char_ok(*c)) { + Some(offender) => Err(LoadError::InvalidId { + id: id.to_string(), + offender, + }), + None => Ok(()), + } +} + pub fn parse_action(id: &str, value: &str) -> Result { Action::parse(value).ok_or_else(|| LoadError::UnknownAction { id: id.to_string(), @@ -102,6 +130,7 @@ fn modes_from(raw: &RawRule) -> Result { pub fn to_rule(raw: RawRule, home: &Path) -> Result { let id = raw.id.clone(); + validate_id(&id)?; let action = parse_action(&id, &raw.action)?; let kind = Kind::parse(&raw.kind).ok_or_else(|| LoadError::UnknownKind { id: id.clone(), diff --git a/crates/airlock-policy/src/engine.rs b/crates/airlock-policy/src/engine.rs index 2fd8ef3..ea2d596 100644 --- a/crates/airlock-policy/src/engine.rs +++ b/crates/airlock-policy/src/engine.rs @@ -22,14 +22,26 @@ impl LoadContext { home: home.into(), self_protect: SelfProtectPaths { audit_root: audit_root.into(), - policy_file: None, + policy_files: Vec::new(), binary: None, }, } } pub fn with_policy_file(mut self, path: impl Into) -> Self { - self.self_protect.policy_file = Some(path.into()); + self.self_protect.policy_files = vec![path.into()]; + self + } + + /// 자기보호할 정책 파일 후보를 통째로 지정합니다. + /// + /// 탐색 후보는 아직 존재하지 않아도 막아야 합니다. 비어 있는 자리에 대상이 정책을 + /// 만들어 두면 다음 실행이 그것을 읽기 때문입니다. + /// + /// # Arguments + /// `paths` - 막을 후보 경로 전체 + pub fn with_policy_files(mut self, paths: impl IntoIterator) -> Self { + self.self_protect.policy_files = paths.into_iter().collect(); self } @@ -39,6 +51,28 @@ impl LoadContext { } } +/// 파일 규칙의 각 패턴에 해소된 표기를 하나씩 더합니다. +/// +/// macOS 의 `/etc` -> `/private/etc` 같은 firmlink 때문입니다. 한쪽 표기로만 적힌 규칙은 +/// 다른 표기의 요청을 만나면 매칭되지 않습니다. +/// +/// # Arguments +/// `rule` - 패턴을 넓힐 규칙 +fn add_resolved_variants(rule: &mut Rule) { + let Matcher::File { paths, .. } = &mut rule.matcher else { + return; + }; + let mut extra: Vec = Vec::new(); + for p in paths.iter() { + if let Some(v) = p.resolved_variant() + && !paths.iter().any(|q| q.raw() == v.raw()) + { + extra.push(v); + } + } + paths.extend(extra); +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MatchedRule { pub id: String, @@ -138,16 +172,26 @@ impl Policy { Self::build(&name, defaults, user, ctx) } + /// 정책 파일을 읽어 로드합니다. + /// + /// # Errors + /// 읽기에 실패하거나, 파일이 호출한 사용자의 것이 아니거나, 다른 사용자가 쓸 수 있으면 + /// 실패합니다. 정책 파일은 신뢰 경계 전체를 정의하므로 내용을 보기 전에 출처부터 + /// 확인합니다. pub fn load_file(path: &Path, ctx: &LoadContext) -> Result { - let src = std::fs::read_to_string(path).map_err(|source| LoadError::Io { - path: path.to_path_buf(), - source, - })?; + let src = crate::path::read_trusted(path)?; let ctx = LoadContext { home: ctx.home.clone(), self_protect: SelfProtectPaths { audit_root: ctx.self_protect.audit_root.clone(), - policy_file: Some(path.to_path_buf()), + policy_files: { + // 실제로 읽은 파일이 후보 목록에 없으면 더합니다 + let mut v = ctx.self_protect.policy_files.clone(); + if !v.iter().any(|p| p == path) { + v.push(path.to_path_buf()); + } + v + }, binary: ctx.self_protect.binary.clone(), }, }; @@ -157,14 +201,24 @@ impl Policy { fn build( name: &str, defaults: Defaults, - user: Vec, + mut user: Vec, ctx: &LoadContext, ) -> Result { - let base = baseline::build(&ctx.home).map_err(|source| LoadError::Pattern { + let mut base = baseline::build(&ctx.home).map_err(|source| LoadError::Pattern { id: "baseline".to_string(), source, })?; - let self_protect = baseline::self_protect(&ctx.self_protect); + let mut self_protect = baseline::self_protect(&ctx.self_protect); + + // firmlink 로 다른 이름이 붙는 경로에 양쪽 표기를 모두 넣습니다. 티어를 가리지 + // 않고 적용해야 deny 가 비켜 가지도, allow 가 죽지도 않습니다 + for r in user + .iter_mut() + .chain(base.rules.iter_mut()) + .chain(self_protect.iter_mut()) + { + add_resolved_variants(r); + } // 내장 규칙 id는 예약어입니다. 겹치는 id를 허용하면 감사 로그의 rule 필드가 // 어느 티어의 규칙을 가리키는지 알 수 없어집니다 (10절 3번) diff --git a/crates/airlock-policy/src/error.rs b/crates/airlock-policy/src/error.rs index 6172501..40c4102 100644 --- a/crates/airlock-policy/src/error.rs +++ b/crates/airlock-policy/src/error.rs @@ -13,7 +13,22 @@ pub enum LoadError { source: std::io::Error, }, UnsupportedVersion(u32), + /// 정책 파일의 출처를 믿을 수 없습니다. + /// + /// 심볼릭 링크이거나, 호출자 소유가 아니거나, 다른 사용자가 쓸 수 있는 경우입니다 + UntrustedFile { + path: PathBuf, + why: String, + }, DuplicateId(String), + /// 규칙 id에 허용되지 않는 문자가 있습니다. + /// + /// id는 커널 강제 프로파일에 주석으로 들어가므로 개행이나 제어 문자가 섞이면 + /// 주석을 탈출해 프로파일을 다시 쓸 수 있습니다 (`docs/policy-dsl.md` 10절) + InvalidId { + id: String, + offender: char, + }, /// 사용자 규칙 id가 내장 규칙 id와 겹칩니다. /// /// 감사 로그의 `rule` 필드는 티어를 담지 않으므로, 겹치면 그 결정을 낸 규칙이 어느 @@ -88,7 +103,20 @@ impl fmt::Display for LoadError { Self::UnsupportedVersion(v) => { write!(f, "지원하지 않는 정책 version {v}. v1만 지원함") } + Self::UntrustedFile { path, why } => write!( + f, + "{} 를 신뢰할 수 없음: {why}. 정책 파일은 신뢰 경계 전체를 정의하므로 \ + 호출자 소유이고 남이 쓸 수 없어야 함", + path.display() + ), Self::DuplicateId(id) => write!(f, "규칙 id `{id}`가 중복됨"), + Self::InvalidId { id, offender } => write!( + f, + "규칙 id `{}`에 쓸 수 없는 문자 {:?}가 있음. \ + id는 커널 프로파일에 주석으로 들어가므로 영숫자와 . _ - : 만 허용함", + id.escape_debug(), + offender + ), Self::ReservedId { id, tier } => write!( f, "규칙 id `{id}`가 내장 {tier} 규칙과 겹침. 감사 로그의 rule 필드가 \ diff --git a/crates/airlock-policy/src/glob.rs b/crates/airlock-policy/src/glob.rs index 99afc78..285a8ef 100644 --- a/crates/airlock-policy/src/glob.rs +++ b/crates/airlock-policy/src/glob.rs @@ -159,6 +159,63 @@ impl Pattern { PathBuf::from(std::ffi::OsString::from_vec(out)) } + /// 앞쪽 고정 구간이 다른 실제 경로로 해소되면 그 표기의 패턴을 하나 더 만듭니다. + /// + /// macOS 에서 `/etc`, `/var`, `/tmp` 는 `/private/*` 로 가는 firmlink 입니다. 요청이 + /// `/private/etc/sudoers` 로 오면 요청 경로도 해소 경로도 `/etc/...` 가 아니므로 + /// `/etc/sudoers` 로 적은 forbid 가 통째로 비켜 갑니다. 반대로 `/tmp/**` 로 적은 + /// allow 는 해소 경로가 `/private/tmp/...` 라 아무 것도 열지 못합니다. 양쪽 표기를 + /// 모두 규칙에 넣어야 정책이 적은 대로 걸립니다. + pub fn resolved_variant(&self) -> Option { + // 앞에서부터 고정 세그먼트만 모읍니다. 와일드카드가 나오면 거기서 멈춥니다 + let fixed: Vec<&Vec> = self + .segs + .iter() + .take_while(|s| matches!(s, Seg::Exact(_))) + .map(|s| match s { + Seg::Exact(b) => b, + _ => unreachable!("take_while 가 Exact 만 남김"), + }) + .collect(); + if fixed.is_empty() { + return None; + } + + // 존재하는 가장 긴 접두를 찾습니다. `/etc/shadow` 처럼 대상 파일이 없어도 + // `/etc` 는 해소되므로 앞에서부터 줄여 가며 봅니다 + let mut taken = fixed.len(); + let (prefix_path, canonical) = loop { + if taken == 0 { + return None; + } + let mut prefix: Vec = Vec::new(); + for seg in fixed.iter().take(taken) { + prefix.push(b'/'); + prefix.extend_from_slice(seg); + } + let candidate = PathBuf::from(std::ffi::OsString::from_vec(prefix)); + if let Ok(c) = std::fs::canonicalize(&candidate) { + break (candidate, c); + } + taken -= 1; + }; + if canonical == prefix_path { + return None; + } + + let mut segs = literal_segs(&canonical); + segs.extend(self.segs.iter().skip(taken).cloned()); + + // raw 는 사람이 읽는 설명이므로 앞부분만 바꿔 붙입니다. 원래 raw 가 그 접두로 + // 시작하지 않으면(홈 확장 등) 접두를 통째로 갈아 끼웁니다 + let old_prefix = prefix_path.to_string_lossy().into_owned(); + let tail = self.raw.strip_prefix(&old_prefix).unwrap_or(""); + Some(Pattern { + raw: format!("{}{tail}", canonical.to_string_lossy()), + segs, + }) + } + pub fn matches(&self, path: &Path, case_insensitive: bool) -> bool { let bytes = path.as_os_str().as_bytes(); let segments: Vec<&[u8]> = bytes @@ -291,20 +348,49 @@ impl TextPattern { } } +/// 패턴 세그먼트와 경로 세그먼트를 맞춰 봅니다. +/// +/// `(패턴 위치, 경로 위치)` 쌍으로 메모이제이션합니다. 같은 쌍을 두 번 계산하지 않으므로 +/// 비용이 두 길이의 곱으로 묶입니다. 메모 없이 `**` 마다 되돌아가면 `**` 개수만큼 지수가 +/// 붙어, 경로가 긴 요청 하나로 판정이 초 단위까지 늘어납니다. 경로는 공격자가 정합니다. fn match_segs(pat: &[Seg], path: &[&[u8]], ci: bool) -> bool { - match pat.split_first() { - None => path.is_empty(), - Some((Seg::DoubleStar, rest)) => { - if rest.is_empty() { - return true; + let mut memo = vec![None; pat.len().saturating_add(1) * path.len().saturating_add(1)]; + match_segs_memo(pat, path, ci, 0, 0, &mut memo) +} + +fn match_segs_memo( + pat: &[Seg], + path: &[&[u8]], + ci: bool, + pi: usize, + ti: usize, + memo: &mut [Option], +) -> bool { + let width = path.len().saturating_add(1); + let key = pi * width + ti; + if let Some(hit) = memo[key] { + return hit; + } + + let out = match pat.get(pi) { + None => ti == path.len(), + Some(Seg::DoubleStar) => { + if pi + 1 == pat.len() { + true + } else { + (ti..=path.len()).any(|i| match_segs_memo(pat, path, ci, pi + 1, i, memo)) } - (0..=path.len()).any(|i| match_segs(rest, &path[i..], ci)) } - Some((seg, rest)) => match path.split_first() { + Some(seg) => match path.get(ti) { None => false, - Some((head, tail)) => match_one(seg, head, ci) && match_segs(rest, tail, ci), + Some(head) => { + match_one(seg, head, ci) && match_segs_memo(pat, path, ci, pi + 1, ti + 1, memo) + } }, - } + }; + + memo[key] = Some(out); + out } fn match_one(seg: &Seg, text: &[u8], ci: bool) -> bool { @@ -315,6 +401,11 @@ fn match_one(seg: &Seg, text: &[u8], ci: bool) -> bool { if eq_ascii_ci(p, text) { return true; } + if let (Some(fp), Some(ft)) = (fold_bytes(p), fold_bytes(text)) + && fp == ft + { + return true; + } match (nfc_bytes(p), nfc_bytes(text)) { (None, None) => false, (np, nt) => { @@ -332,6 +423,11 @@ fn match_one(seg: &Seg, text: &[u8], ci: bool) -> bool { if !ci { return false; } + if let (Some(fp), Some(ft)) = (fold_bytes(p), fold_bytes(text)) + && wildcard_match(&fp, &ft, false) + { + return true; + } match (nfc_bytes(p), nfc_bytes(text)) { (None, None) => false, (np, nt) => wildcard_match( @@ -352,6 +448,19 @@ fn nfc_bytes(bytes: &[u8]) -> Option> { Some(s.nfc().collect::().into_bytes()) } +/// 케이스 무시 파일시스템이 같은 이름으로 보는 것들을 한 표기로 모읍니다. +/// +/// ASCII 만 접으면 부족합니다. APFS 는 유니코드 표까지 써서 접기 때문에 `U+017F`(ſ)가 +/// `s` 와 같은 파일이 되고, NFC 는 이 글자를 건드리지 않습니다. 그래서 `~/.awſ/credentials` +/// 로 쓰면 `~/.aws/**` deny 를 비켜 가면서 커널에는 `~/.aws/credentials` 로 들어갑니다. +/// NFC 로 모은 뒤 대문자로 접으면 ſ 는 S 가 되고 é/É 같은 비ASCII 짝도 함께 잡힙니다. +/// +/// 넓어지는 방향이므로 제한적인 규칙(`deny`, `forbid`, `ask`)에만 씁니다. +fn fold_bytes(bytes: &[u8]) -> Option> { + let s = std::str::from_utf8(bytes).ok()?; + Some(s.nfc().collect::().to_uppercase().into_bytes()) +} + fn eq_ascii_ci(a: &[u8], b: &[u8]) -> bool { a.eq_ignore_ascii_case(b) } diff --git a/crates/airlock-policy/src/host.rs b/crates/airlock-policy/src/host.rs index 524d54b..8d15035 100644 --- a/crates/airlock-policy/src/host.rs +++ b/crates/airlock-policy/src/host.rs @@ -43,6 +43,12 @@ pub fn normalize_host(raw: &str) -> Option { if let Ok(ip) = t.parse::() { return Some(ip.to_canonical().to_string()); } + // Rust의 파서는 점 4개 십진 표기만 받지만 getaddrinfo는 8진, 16진, 정수 하나, + // 생략형까지 받습니다. 여기서 잡지 않으면 169.254.169.254 deny를 2852039166으로 + // 그대로 지나갑니다 + if let Some(ip) = parse_inet_aton(t) { + return Some(IpAddr::V4(ip).to_string()); + } let ok = t .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_'); @@ -52,6 +58,52 @@ pub fn normalize_host(raw: &str) -> Option { Some(t.to_ascii_lowercase()) } +/// `inet_aton` 의미론으로 IPv4를 읽습니다. +/// +/// 1개에서 4개의 부분을 받고 각 부분은 10진, `0` 접두 8진, `0x` 접두 16진입니다. 마지막 +/// 부분이 남은 바이트를 통째로 채웁니다. 곧 `127.1`은 127.0.0.1, `2130706433`도 같습니다. +/// +/// # Arguments +/// `t` - 검사할 문자열 +fn parse_inet_aton(t: &str) -> Option { + // 순수 도메인 이름이 여기 걸리면 안 되므로 숫자 표기만 받습니다 + let parts: Vec<&str> = t.split('.').collect(); + if parts.is_empty() || parts.len() > 4 || parts.iter().any(|p| p.is_empty()) { + return None; + } + let mut vals: Vec = Vec::with_capacity(parts.len()); + for p in &parts { + let (digits, radix) = if let Some(h) = p.strip_prefix("0x").or_else(|| p.strip_prefix("0X")) + { + (h, 16) + } else if p.len() > 1 && p.starts_with('0') { + (&p[1..], 8) + } else { + (*p, 10) + }; + if digits.is_empty() || !digits.bytes().all(|b| (b as char).is_digit(radix)) { + return None; + } + vals.push(u32::from_str_radix(digits, radix).ok()?); + } + // 마지막을 뺀 나머지는 한 바이트씩이고, 마지막이 남은 자리를 전부 채웁니다 + let last = *vals.last()?; + let head = &vals[..vals.len() - 1]; + if head.iter().any(|v| *v > 0xFF) { + return None; + } + let tail_bytes = 4 - head.len() as u32; + if tail_bytes < 4 && last >= 1u32 << (8 * tail_bytes) { + return None; + } + let mut addr: u32 = 0; + for (i, v) in head.iter().enumerate() { + addr |= v << (8 * (3 - i as u32)); + } + addr |= last; + Some(std::net::Ipv4Addr::from(addr)) +} + fn is_ip_literal(host: &str) -> bool { host.parse::().is_ok() } @@ -98,7 +150,9 @@ impl HostPattern { pub fn matches(&self, host: &str) -> bool { let Some(norm) = normalize_host(host) else { - return false; + // `*`는 "전부"라는 뜻이며 정규화할 수 없는 값도 전부에 들어갑니다. 여기서 + // false를 돌려주면 deny host="*"가 [defaults].egress보다 느슨해집니다 + return matches!(self, Self::Any); }; match self { Self::Any => true, @@ -189,11 +243,18 @@ mod tests { } #[test] - fn any_matches_everything_resolvable() { + fn any_matches_everything_including_unparseable() { + // `*`는 "전부"입니다. 정규화할 수 없는 값을 비켜 가면 deny host="*"가 + // [defaults].egress 보다 느슨해집니다. allow 에는 `*`를 쓸 수 없으므로 + // (engine 의 WildcardHostAllow) 이 넓힘이 통과를 만들지는 않습니다 let h = p("*"); assert!(h.matches("example.com")); assert!(h.matches("10.0.0.1")); - assert!(!h.matches("")); + assert!(h.matches("")); + assert!(h.matches("한국.com")); + assert!(h.matches("fe80::1%en0")); + assert!(h.matches("exfil.com:443")); + assert!(h.matches(".evil.com")); } #[test] @@ -229,11 +290,55 @@ mod tests { } #[test] - fn non_ascii_runtime_host_never_matches() { - assert!(!p("*").matches("한국.com")); + fn non_ascii_runtime_host_matches_nothing_but_any() { + assert!(!p("example.com").matches("한국.com")); + assert!(!p("*.com").matches("한국.com")); assert!(normalize_host("한국.com").is_none()); } + #[test] + fn alternate_ipv4_notations_normalize_to_the_same_address() { + // getaddrinfo 가 받는 표기를 정책도 같은 주소로 봐야 합니다. 그렇지 않으면 + // 클라우드 메타데이터 deny 를 정수 표기 하나로 지나갑니다 + let meta = p("169.254.169.254"); + for raw in [ + "169.254.169.254", + "2852039166", + "0xA9FEA9FE", + "0xa9fea9fe", + "169.254.43518", + "169.16689662", + "0251.0376.0251.0376", + ] { + assert!( + meta.matches(raw), + "{raw}가 메타데이터 주소로 정규화되어야 함" + ); + } + + let loopback = p("127.0.0.1"); + for raw in ["127.0.0.1", "127.1", "2130706433", "0x7f000001"] { + assert!(loopback.matches(raw), "{raw}가 루프백으로 정규화되어야 함"); + } + } + + #[test] + fn ordinary_domains_are_not_read_as_numeric_ipv4() { + for raw in [ + "example.com", + "a.b.c.d", + "1foo.com", + "0x.com", + "8.8.8.8.com", + ] { + let norm = normalize_host(raw).unwrap_or_default(); + assert!( + norm.parse::().is_err(), + "{raw}는 IP 로 읽히면 안 됨: {norm}" + ); + } + } + #[test] fn malformed_hosts_never_match() { assert!(normalize_host("").is_none()); diff --git a/crates/airlock-policy/src/path.rs b/crates/airlock-policy/src/path.rs index e9619e6..bf5cd73 100644 --- a/crates/airlock-policy/src/path.rs +++ b/crates/airlock-policy/src/path.rs @@ -14,11 +14,77 @@ impl NormalizedPath { } } +/// `HOME`을 읽습니다. +/// +/// 값이 없거나 절대 경로가 아니면 `None`입니다. 예전에는 `/`로 물러섰지만, 그러면 +/// `~/.ssh/**` 같은 베이스라인 forbid가 전부 `/.ssh/**`로 붕괴해 진짜 홈이 무방비가 +/// 됩니다. 시크릿 보호가 조용히 사라지는 유일한 방향이므로 실패로 다룹니다. +pub fn home_dir_checked() -> Option { + let raw = std::env::var_os("HOME").filter(|h| !h.is_empty())?; + let path = PathBuf::from(raw); + if !path.is_absolute() { + return None; + } + Some(std::fs::canonicalize(&path).unwrap_or(path)) +} + pub fn home_dir() -> PathBuf { - std::env::var_os("HOME") - .filter(|h| !h.is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("/")) + home_dir_checked().unwrap_or_else(|| PathBuf::from("/")) +} + +/// 신뢰 경계를 정의하는 파일을 출처 확인과 함께 읽습니다. +/// +/// `O_NOFOLLOW`로 열어 마지막 구성 요소가 심볼릭 링크면 거부하고, 열린 fd를 그대로 +/// `fstat` 해 TOCTOU 없이 소유자와 권한을 봅니다. 검사한 fd에서 그대로 읽으므로 검사 후 +/// 파일이 바뀌어도 읽는 대상은 달라지지 않습니다. +/// +/// # Arguments +/// `path` - 읽을 파일 +/// +/// # Errors +/// 열기·읽기 실패, 심볼릭 링크, 호출자 소유가 아닌 파일, 그룹이나 그 밖의 사용자가 쓸 수 +/// 있는 파일은 전부 거부합니다. +/// +/// # Safety +/// `libc::getuid`는 인자가 없고 실패하지 않으며 스레드 상태를 건드리지 않습니다. 반환값은 +/// 항상 유효한 uid이므로 이 호출에는 지켜야 할 사전 조건이 없습니다. +pub fn read_trusted(path: &Path) -> Result { + use std::io::Read; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let io_err = |source| crate::error::LoadError::Io { + path: path.to_path_buf(), + source, + }; + + let mut file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(io_err)?; + + let meta = file.metadata().map_err(io_err)?; + let uid = unsafe { libc::getuid() }; + if meta.uid() != uid { + return Err(crate::error::LoadError::UntrustedFile { + path: path.to_path_buf(), + why: format!("uid {}의 소유임. 호출자는 uid {uid}", meta.uid()), + }); + } + // 022. 그룹이나 그 밖의 사용자가 쓸 수 있으면 그들이 곧 정책 작성자입니다 + if meta.mode() & 0o022 != 0 { + return Err(crate::error::LoadError::UntrustedFile { + path: path.to_path_buf(), + why: format!( + "권한이 {:04o}로 다른 사용자가 쓸 수 있음", + meta.mode() & 0o7777 + ), + }); + } + + let mut src = String::new(); + file.read_to_string(&mut src).map_err(io_err)?; + Ok(src) } pub fn expand_tilde(raw: &Path, home: &Path) -> PathBuf { @@ -87,7 +153,51 @@ fn resolve_symlinks(absolute: &Path) -> PathBuf { absolute.to_path_buf() } +/// 첫 NUL 바이트에서 경로를 자릅니다. +/// +/// 커널은 C 문자열을 받으므로 NUL 뒤는 존재하지 않는 것과 같습니다. 정책이 뒤까지 읽으면 +/// `~/.ssh/id_ed25519\0/../../work/ok.txt` 가 작업 공간 파일로 판정되는데 커널은 개인키를 +/// 엽니다. 커널이 보는 것과 같은 것을 보게 맞춥니다. +fn truncate_at_nul(path: &Path) -> PathBuf { + let bytes = path.as_os_str().as_bytes(); + match bytes.iter().position(|b| *b == 0) { + None => path.to_path_buf(), + Some(i) => PathBuf::from(OsString::from_vec(bytes[..i].to_vec())), + } +} + +/// 마지막 구성 요소가 심볼릭 링크면 그 대상까지 따라갑니다. +/// +/// `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); let absolute = if expanded.is_absolute() { expanded @@ -97,7 +207,8 @@ pub fn normalize(raw: &Path, cwd: &Path, home: &Path) -> NormalizedPath { p }; let requested = lexical_clean(&absolute); - let resolved = lexical_clean(&resolve_symlinks(&absolute)); + // 대상이 아직 없는 링크는 canonicalize 가 놓치므로 한 번 더 따라갑니다 + let resolved = lexical_clean(&resolve_symlinks(&follow_dangling_link(&absolute))); NormalizedPath { requested, resolved, diff --git a/crates/airlock-policy/tests/bypass.rs b/crates/airlock-policy/tests/bypass.rs index 38c3aa0..4552e40 100644 --- a/crates/airlock-policy/tests/bypass.rs +++ b/crates/airlock-policy/tests/bypass.rs @@ -1543,3 +1543,236 @@ action = "deny" p.warnings() ); } + +#[test] +fn rule_id_with_a_newline_is_rejected_at_load() { + let h = Home::new("id-newline"); + let src = " +version = 1 +[[rules]] +id = \"no-nc\\n(allow file-read* file-write* (subpath \\\"/\\\"))\\n;;\" +kind = \"exec\" +program = \"nc\" +action = \"deny\" +"; + let err = Policy::load_str(src, &h.ctx()).unwrap_err(); + assert!( + matches!(err, LoadError::InvalidId { offender, .. } if offender == '\n'), + "id의 개행은 커널 프로파일 주석을 탈출시키므로 로드를 거부해야 함: {err:?}" + ); +} + +#[test] +fn rule_id_control_characters_are_rejected_at_load() { + // TOML 이스케이프 표기 그대로 넣습니다. \u{..} 는 TOML 문법이 아닙니다 + for (label, toml_id) in [ + ("캐리지 리턴", r"a\rb"), + ("ESC", r"a\u001B[2Kb"), + ("NUL", r"a\u0000b"), + ("탭", r"a\tb"), + ("공백", "a b"), + ("괄호", "a)b"), + ("따옴표", r#"a\"b"#), + ("역슬래시", r"a\\b"), + ] { + let h = Home::new("id-ctl"); + let src = format!( + " +version = 1 +[[rules]] +id = \"{toml_id}\" +kind = \"file\" +path = \"~/work/**\" +action = \"allow\" +" + ); + let err = Policy::load_str(&src, &h.ctx()).unwrap_err(); + assert!( + matches!(err, LoadError::InvalidId { .. }), + "{label}이 든 id는 거부해야 함: {err:?}" + ); + } +} + +#[test] +fn ordinary_rule_ids_still_load() { + let h = Home::new("id-ok"); + let src = r#" +version = 1 +[[rules]] +id = "build-cache.v2_x-y" +kind = "file" +path = "~/work/**" +action = "allow" +"#; + let p = Policy::load_str(src, &h.ctx()).unwrap(); + assert_eq!(read(&p, &h, "~/work/a.rs"), Action::Allow); +} + +fn mode(policy: &Policy, home: &Home, raw: &str, m: FileMode) -> Action { + policy.evaluate_file(Path::new(raw), m, home.path()).action +} + +#[test] +fn unicode_case_fold_aliases_cannot_evade_secret_denies() { + // APFS 는 U+017F(ſ)를 s 와 같은 파일로 봅니다. NFC 는 이 글자를 건드리지 않으므로 + // ASCII 만 접으면 ~/.awſ/credentials 로 써 놓고 ~/.aws/credentials 로 읽힙니다 + let h = Home::new("fold"); + let p = Policy::baseline_only(&h.ctx()).unwrap(); + for raw in [ + "~/.aw\u{17f}/credentials", + "~/.\u{17f}\u{17f}h/id_rsa", + "~/.\u{17f}\u{17f}h/authorized_keys", + "~/.AWS/credentials", + ] { + assert!( + mode(&p, &h, raw, FileMode::Create).is_restrictive(), + "{raw}는 시크릿 규칙에 걸려야 함" + ); + assert!(read(&p, &h, raw).is_restrictive(), "{raw} 읽기도 걸려야 함"); + } + // 무관한 경로가 함께 막히면 안 됩니다 + assert_eq!(read(&p, &h, "~/work/awesome/notes.txt"), Action::Ask); +} + +#[test] +fn dangling_symlink_to_a_secret_is_resolved_before_deciding() { + // 대상이 아직 없는 링크는 canonicalize 가 놓칩니다. create 는 대상이 없는 것이 + // 정상이므로 이 경로가 곧 시크릿 자리에 파일을 만드는 통로가 됩니다 + let h = Home::new("dangling"); + let ws = h.join("work"); + fs::create_dir_all(&ws).unwrap(); + let link = ws.join("ak"); + std::os::unix::fs::symlink(h.join(".ssh/authorized_keys"), &link).unwrap(); + + let p = Policy::baseline_only(&h.ctx()).unwrap(); + let raw = link.to_string_lossy().into_owned(); + for m in [FileMode::Create, FileMode::Write, FileMode::Read] { + assert!( + mode(&p, &h, &raw, m).is_restrictive(), + "{m:?}가 링크를 따라 ~/.ssh 로 가야 함" + ); + } +} + +#[test] +fn a_nul_byte_cannot_hide_the_real_target() { + // 커널은 NUL 에서 문자열을 끊습니다. 정책이 뒤까지 읽으면 작업 공간 파일로 보이지만 + // 커널은 개인키를 엽니다 + let h = Home::new("nul"); + h.make_secret(); + let p = Policy::baseline_only(&h.ctx()).unwrap(); + let raw = format!( + "{}\u{0}/../../work/ok.txt", + h.join(".ssh/id_ed25519").display() + ); + assert!( + read(&p, &h, &raw).is_restrictive(), + "NUL 뒤는 없는 것으로 보고 개인키로 판정해야 함" + ); +} + +#[test] +fn every_policy_candidate_is_self_protected_even_when_absent() { + // 정책 파일이 없을 때 대상이 하나 만들어 두면 다음 실행이 그것을 읽습니다. + // 비어 있는 자리야말로 막아야 하는 자리입니다 + let h = Home::new("candidates"); + let ws = h.join("work"); + fs::create_dir_all(&ws).unwrap(); + let planted = ws.join("airlock.toml"); + + let ctx = LoadContext::new(h.path(), h.join(".local/share/airlock")) + .with_policy_files(vec![planted.clone(), ws.join(".airlock.toml")]); + let p = Policy::baseline_only(&ctx).unwrap(); + + let raw = planted.to_string_lossy().into_owned(); + for m in [FileMode::Write, FileMode::Create, FileMode::Delete] { + let ev = p.evaluate_file(Path::new(&raw), m, h.path()); + assert!( + ev.action.is_restrictive(), + "{m:?}로 정책을 심을 수 있으면 안 됨" + ); + assert_eq!( + ev.rule.as_ref().map(|r| r.tier), + Some(Tier::SelfProtect), + "자기보호 티어가 잡아야 함" + ); + } +} + +#[test] +fn deep_double_star_patterns_do_not_blow_up() { + // 메모 없이 되돌아가면 세그먼트 수의 네제곱으로 늘어납니다. 경로는 공격자가 정합니다 + let h = Home::new("glob-perf"); + let src = r#" +version = 1 +[[rules]] +id = "deep" +kind = "file" +path = "/**/a/**/a/**/a/**/z" +action = "allow" +"#; + let p = Policy::load_str(src, &h.ctx()).unwrap(); + let long = format!("/{}/q", vec!["a"; 600].join("/")); + + let start = std::time::Instant::now(); + let _ = p.evaluate_file(Path::new(&long), FileMode::Read, h.path()); + let took = start.elapsed(); + assert!( + took < std::time::Duration::from_millis(500), + "600 세그먼트 판정이 {took:?} 걸림. 메모이제이션이 빠졌는지 확인할 것" + ); +} + +#[test] +fn firmlinked_prefixes_are_covered_in_both_spellings() { + // macOS 에서 /tmp 는 /private/tmp 로 가는 firmlink 입니다. 한쪽 표기로만 적힌 + // deny 는 다른 표기의 요청을 놓치고, 한쪽으로만 적힌 allow 는 죽습니다 + let Ok(canonical) = fs::canonicalize("/tmp") else { + return; + }; + if canonical == Path::new("/tmp") { + // firmlink 가 없는 플랫폼입니다 + return; + } + + let h = Home::new("firmlink"); + let src = r#" +version = 1 +[defaults] +file = "deny" +[[rules]] +id = "no-secrets-dir" +kind = "file" +path = "/tmp/secrets/**" +action = "deny" +[[rules]] +id = "tmp-ok" +kind = "file" +path = "/tmp/**" +action = "allow" +"#; + let p = Policy::load_str(src, &h.ctx()).unwrap(); + + let other = canonical.join("build.o"); + assert_eq!( + mode(&p, &h, "/tmp/build.o", FileMode::Write), + Action::Allow, + "allow 가 적은 표기로 동작해야 함" + ); + assert_eq!( + mode(&p, &h, &other.to_string_lossy(), FileMode::Write), + Action::Allow, + "allow 가 해소된 표기로도 동작해야 함" + ); + + let secret = canonical.join("secrets/x"); + assert!( + mode(&p, &h, "/tmp/secrets/x", FileMode::Read).is_restrictive(), + "deny 가 적은 표기로 걸려야 함" + ); + assert!( + mode(&p, &h, &secret.to_string_lossy(), FileMode::Read).is_restrictive(), + "deny 가 해소된 표기로도 걸려야 함" + ); +} diff --git a/crates/airlock/Cargo.toml b/crates/airlock/Cargo.toml index af9d73f..f6aa5f3 100644 --- a/crates/airlock/Cargo.toml +++ b/crates/airlock/Cargo.toml @@ -9,6 +9,7 @@ description = "AI 코딩 에이전트를 위한 로컬 제로트러스트 게이 [dependencies] airlock-audit.workspace = true +airlock-canonical.workspace = true airlock-broker.workspace = true airlock-policy.workspace = true clap.workspace = true diff --git a/crates/airlock/src/cmd_audit.rs b/crates/airlock/src/cmd_audit.rs index 83aaa49..2a65c7f 100644 --- a/crates/airlock/src/cmd_audit.rs +++ b/crates/airlock/src/cmd_audit.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use airlock_audit::{Entry, Event, Warning, verify_dir}; +use airlock_canonical::display::sanitize; use crate::paths; @@ -121,7 +122,10 @@ fn describe_event(event: &Event) -> String { .. } => { let short: String = policy_digest.to_hex().chars().take(12).collect(); - let source = policy_source.as_deref().unwrap_or("내장 베이스라인"); + let source = policy_source + .as_deref() + .map(sanitize) + .unwrap_or_else(|| "내장 베이스라인".to_string()); let sync = if *fsync_per_entry { "" } else { @@ -140,18 +144,20 @@ fn describe_event(event: &Event) -> String { path_resolved, mode, } => { - if path_requested == path_resolved { - format!("파일 {mode} {path_requested}") + let requested = sanitize(path_requested); + let resolved = sanitize(path_resolved); + if requested == resolved { + format!("파일 {mode} {requested}") } else { - format!("파일 {mode} {path_requested} \x1b[35m-> {path_resolved}\x1b[0m") + format!("파일 {mode} {requested} \x1b[35m-> {resolved}\x1b[0m") } } - Event::Exec { program, argv, .. } => format!("실행 {program} {argv:?}"), + Event::Exec { program, argv, .. } => format!("실행 {} {argv:?}", sanitize(program)), Event::Egress { host, port, protocol, - } => format!("아웃바운드 {protocol} {host}:{port}"), + } => format!("아웃바운드 {protocol} {}:{port}", sanitize(host)), Event::Approval { for_seq, granted, @@ -159,7 +165,7 @@ fn describe_event(event: &Event) -> String { } => { let mut s = format!("승인응답 seq={for_seq} {granted}"); if let Some(n) = note { - s.push_str(&format!(" ({n})")); + s.push_str(&format!(" ({})", sanitize(n))); } s } @@ -171,6 +177,10 @@ fn describe_event(event: &Event) -> String { } fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { + // 렌더링 전에 체인을 먼저 검증합니다. 위조된 엔트리를 아무 표시 없이 사람에게 + // 보여 주면 뷰어가 공격자의 출력 장치가 됩니다 + let integrity = airlock_audit::verify_dir(dir); + let (entries, problem) = match airlock_audit::read_entries_lossy(dir) { Ok(v) => v, Err(e) => { @@ -179,8 +189,18 @@ fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { } }; if let Some(p) = &problem { - eprintln!("airlock: 경고 {p}"); - eprintln!("airlock: airlock audit verify로 무결성을 확인할 것"); + // 경고와 엔트리를 같은 스트림에 둡니다. 리다이렉트나 페이저로 경고만 사라지면 + // 조용히 뚫린 것과 같습니다 + println!("\x1b[1;31mairlock 경고\x1b[0m {p}"); + } + + if let Err(failure) = &integrity { + println!( + "\x1b[1;31m╔══ 무결성 실패 ═══════════════════════════════════\x1b[0m\n\ + \x1b[1;31m║\x1b[0m {failure}\n\ + \x1b[1;31m║\x1b[0m 아래 내용은 검증되지 않았으므로 증거로 쓸 수 없음\n\ + \x1b[1;31m╚═════════════════════════════════════════════════\x1b[0m" + ); } if entries.is_empty() { @@ -214,8 +234,15 @@ fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { for e in window { let color = decision_color(e); - let time = e.ts_rfc3339.get(0..19).unwrap_or(&e.ts_rfc3339); - let rule = e.rule.as_deref().unwrap_or("기본값"); + // 저장된 ts_rfc3339은 해시 대상이 아니므로 위조할 수 있습니다. 해시가 보증하는 + // ts에서 다시 만들어 표시합니다 + let derived = airlock_audit::format_rfc3339_nanos(e.ts); + let time = derived.get(0..19).unwrap_or(&derived).to_string(); + let rule = e + .rule + .as_deref() + .map(sanitize) + .unwrap_or_else(|| "기본값".to_string()); println!( "{:>5} {time}Z {color}{:<6}\x1b[0m {:<9} {} \x1b[2m[{rule}]\x1b[0m", e.seq, @@ -234,6 +261,11 @@ fn show(dir: &Path, limit: usize, decisions_only: bool) -> i32 { "\n\x1b[33m주의\x1b[0m {observed}개 엔트리가 observe 모드임. 기록되었지만 강제되지 않음" ); } + + // 검증 실패는 종료 코드로도 드러냅니다. 파이프라인이 show 만 부르고도 알아챌 수 있어야 합니다 + if integrity.is_err() || problem.is_some() { + return 2; + } 0 } diff --git a/crates/airlock/src/cmd_policy.rs b/crates/airlock/src/cmd_policy.rs index c0f95d0..22fd97d 100644 --- a/crates/airlock/src/cmd_policy.rs +++ b/crates/airlock/src/cmd_policy.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use airlock_canonical::display::sanitize; use airlock_policy::{Action, FileMode, LoadContext, Policy}; use crate::paths; @@ -58,13 +59,26 @@ fn load( audit_root: Option, ) -> Result<(Policy, Option), i32> { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let audit_root = audit_root.unwrap_or_else(paths::audit_root); - let mut ctx = LoadContext::new(airlock_policy::path::home_dir(), &audit_root); + let audit_root = paths::absolutize(&audit_root.unwrap_or_else(paths::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 path = paths::discover_policy(explicit, &cwd); let policy = match &path { Some(p) => Policy::load_file(p, &ctx).map_err(|e| { eprintln!("airlock: {e}"); @@ -168,14 +182,21 @@ pub fn exec(cmd: PolicyCommand, audit_root: Option) -> i32 { Ok(v) => v, Err(code) => return code, }; + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let opts = airlock_broker::ProfileOptions { - workspace: workspace.or_else(|| std::env::current_dir().ok()), + workspace: Some(paths::absolutize( + &workspace.unwrap_or_else(|| cwd.clone()), + &cwd, + )), ..Default::default() }; let generated = airlock_broker::profile::generate(&policy, &opts); print!("{}", generated.text); for item in &generated.untranslatable { - eprintln!("airlock: 경고 프로파일로 옮기지 못한 규칙 {item}"); + eprintln!( + "airlock: 경고 프로파일로 옮기지 못한 규칙 {}", + sanitize(item) + ); } 0 } @@ -202,10 +223,10 @@ fn colored(action: Action) -> String { fn print_rule(ev: &airlock_policy::Evaluation) { match &ev.rule { Some(rule) => { - println!("규칙 {} ({} tier)", rule.id, rule.tier); - println!("매칭 {}", rule.pattern); + println!("규칙 {} ({} tier)", sanitize(&rule.id), rule.tier); + println!("매칭 {}", sanitize(&rule.pattern)); if let Some(reason) = &rule.reason { - println!("근거 {reason}"); + println!("근거 {}", sanitize(reason)); } } None => println!("규칙 없음 (기본값 적용)"), @@ -214,8 +235,8 @@ fn print_rule(ev: &airlock_policy::Evaluation) { fn print_file(ev: &airlock_policy::Evaluation, mode: FileMode) { if let Some(np) = &ev.path { - println!("요청 {}", np.requested.display()); - println!("해소 {}", np.resolved.display()); + println!("요청 {}", sanitize(&np.requested.display().to_string())); + println!("해소 {}", sanitize(&np.resolved.display().to_string())); if np.diverges() { println!(" \x1b[35m경로가 다름. 더 제한적인 쪽이 채택됨\x1b[0m"); } @@ -227,9 +248,9 @@ fn print_file(ev: &airlock_policy::Evaluation, mode: FileMode) { fn print_exec(ev: &airlock_policy::Evaluation, argv: &[String]) { if let Some(np) = &ev.path { - println!("프로그램 {}", np.requested.display()); + println!("프로그램 {}", sanitize(&np.requested.display().to_string())); if np.diverges() { - println!("해소 {}", np.resolved.display()); + println!("해소 {}", sanitize(&np.resolved.display().to_string())); } } println!("argv {argv:?}"); @@ -243,7 +264,7 @@ fn print_exec(ev: &airlock_policy::Evaluation, argv: &[String]) { } fn print_egress(ev: &airlock_policy::Evaluation, host: &str, port: u16) { - println!("호스트 {host}:{port}"); + println!("호스트 {}:{port}", sanitize(host)); println!("결정 {}", colored(ev.action)); print_rule(ev); } diff --git a/crates/airlock/src/cmd_run.rs b/crates/airlock/src/cmd_run.rs index f5f1acc..6f9982c 100644 --- a/crates/airlock/src/cmd_run.rs +++ b/crates/airlock/src/cmd_run.rs @@ -72,15 +72,41 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { return 64; }; - let audit_root = args - .audit_dir - .clone() - .or(global_audit_root) - .unwrap_or_else(paths::audit_root); + let audit_root = paths::absolutize( + &args + .audit_dir + .clone() + .or(global_audit_root) + .unwrap_or_else(paths::audit_root), + &cwd, + ); let session_dir = paths::session_dir(&audit_root); - let policy_path = paths::discover_policy(args.policy.as_deref(), &cwd); + // 여는 경로는 해소하지 않습니다. 미리 해소하면 O_NOFOLLOW 검사가 무의미해집니다 + 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); + } + } + } - let mut ctx = LoadContext::new(airlock_policy::path::home_dir(), &audit_root); + // HOME이 없거나 상대 경로면 ~/ 앵커 forbid가 전부 엉뚱한 곳을 가리킵니다. 시크릿 + // 보호가 사라진 채로 도는 것보다 중단이 낫습니다 + let Some(home) = airlock_policy::path::home_dir_checked() else { + eprintln!("airlock: HOME이 비어 있거나 절대 경로가 아님"); + eprintln!("airlock: ~/ 로 시작하는 시크릿 보호 규칙이 전부 무효가 되므로 실행을 중단함"); + 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); } @@ -107,7 +133,7 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { eprintln!("airlock: 경고 {w}"); } - let workspace = args.workspace.clone().unwrap_or_else(|| cwd.clone()); + let workspace = paths::absolutize(&args.workspace.clone().unwrap_or_else(|| cwd.clone()), &cwd); if let Err(why) = check_workspace(&workspace, args.workspace.is_some()) { eprintln!("airlock: {why}"); return 64; @@ -116,7 +142,17 @@ pub fn exec(args: RunArgs, global_audit_root: Option) -> i32 { let mut enforcer: Box = if args.observe { Box::new(ObserveEnforcer) } else { - build_enforcer(&workspace, !args.no_network) + match build_enforcer(&workspace, !args.no_network) { + Ok(e) => e, + Err(why) => { + eprintln!("airlock: {why}"); + eprintln!( + "airlock: 커널 강제 없이는 에이전트를 격리할 수 없으므로 실행을 중단함. \ + 기록만 원하면 --observe를 명시할 것" + ); + return 70; + } + } }; let approver: Box = if args.yes { @@ -261,7 +297,16 @@ fn check_workspace(workspace: &std::path::Path, explicit: bool) -> Result<(), St Ok(()) } -fn build_enforcer(workspace: &std::path::Path, allow_network: bool) -> Box { +/// 커널 강제 백엔드를 만듭니다. +/// +/// # Errors +/// 이 플랫폼에 백엔드가 없거나 커널이 지원하지 않으면 그 사유를 담아 실패합니다. 호출부는 +/// 실행을 중단해야 합니다. 강제를 관측으로 조용히 바꾸는 것은 격리를 포기하는 것이므로 +/// 사용자가 `--observe`로 명시할 때만 허용합니다. +fn build_enforcer( + workspace: &std::path::Path, + allow_network: bool, +) -> Result, String> { #[cfg(target_os = "macos")] { let mut opts = ProfileOptions::default() @@ -272,16 +317,15 @@ fn build_enforcer(workspace: &std::path::Path, allow_network: bool) -> Box Box PathBuf { + // 존재하면 심볼릭 링크와 . .. 까지 해소합니다. 존재하지 않아도 절대 경로는 보장합니다 + let joined = absolutize_lexical(path, cwd); + std::fs::canonicalize(&joined).unwrap_or(joined) +} + +/// 심볼릭 링크를 해소하지 않고 절대 경로로만 만듭니다. +/// +/// 정책 파일을 열 때 씁니다. 미리 해소해 버리면 `O_NOFOLLOW`가 볼 링크가 남지 않아 +/// 링크 검사가 무의미해집니다. +/// +/// # Arguments +/// `path` - 절대화할 경로 +/// `cwd` - 기준이 되는 현재 디렉토리 +pub fn absolutize_lexical(path: &Path, cwd: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +/// 자기보호로 막아야 할 경로의 모든 표기를 모읍니다. +/// +/// 어휘적 절대 경로와 해소된 경로를 모두 넣습니다. macOS는 `/tmp`가 `/private/tmp`로 가는 +/// firmlink이므로 한쪽만 막으면 다른 쪽 표기로 그대로 지나갑니다. +/// +/// # Arguments +/// `path` - 막을 경로 +/// `cwd` - 기준이 되는 현재 디렉토리 +pub fn protect_forms(path: &Path, cwd: &Path) -> Vec { + let lexical = absolutize_lexical(path, cwd); + let canonical = std::fs::canonicalize(&lexical).unwrap_or_else(|_| lexical.clone()); + if canonical == lexical { + vec![lexical] + } else { + vec![lexical, canonical] + } +} + pub fn audit_root() -> PathBuf { if let Some(dir) = std::env::var_os("AIRLOCK_AUDIT_DIR").filter(|v| !v.is_empty()) { return PathBuf::from(dir); @@ -20,21 +69,24 @@ pub fn session_dir(root: &Path) -> PathBuf { .join(format!("{nanos}-{}", std::process::id())) } +/// 정책 파일 탐색 후보를 순서대로 돌려줍니다. +/// +/// 존재 여부를 보지 않습니다. 자기보호는 아직 비어 있는 후보까지 막아야 하기 때문입니다. +/// 비어 있는 자리에 대상이 정책을 만들어 두면 다음 실행이 그것을 읽습니다. +/// +/// # Arguments +/// `cwd` - 현재 디렉토리 +pub fn policy_candidates(cwd: &Path) -> Vec { + let mut out: Vec = POLICY_FILE_NAMES.iter().map(|n| cwd.join(n)).collect(); + out.push(home_dir().join(".config/airlock/policy.toml")); + out +} + pub fn discover_policy(explicit: Option<&Path>, cwd: &Path) -> Option { if let Some(p) = explicit { return Some(p.to_path_buf()); } - for name in POLICY_FILE_NAMES { - let candidate = cwd.join(name); - if candidate.is_file() { - return Some(candidate); - } - } - let user = home_dir().join(".config/airlock/policy.toml"); - if user.is_file() { - return Some(user); - } - None + policy_candidates(cwd).into_iter().find(|c| c.is_file()) } pub fn latest_session(root: &Path) -> Option { diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..384586f --- /dev/null +++ b/deny.toml @@ -0,0 +1,31 @@ +# cargo-deny 설정입니다. CI의 dependency audit 잡이 이 파일을 씁니다. +# 보안 도구의 의존성에 권고가 뜨면 빌드를 세웁니다. + +[advisories] +# RUSTSEC 권고가 하나라도 있으면 실패합니다 +yanked = "deny" +ignore = [] + +[licenses] +# AGPL-3.0-only 프로젝트가 링크해도 되는 것만 둡니다 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", + "AGPL-3.0-only", +] +confidence-threshold = 0.9 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docs/audit-format.md b/docs/audit-format.md index 01699ff..5943619 100644 --- a/docs/audit-format.md +++ b/docs/audit-format.md @@ -24,9 +24,12 @@ |--------------------------------|---------------------------------| | 로그 디렉토리에 쓰기 권한 없음 (샌드박스된 에이전트) | 변조 불가, 강제 층이 실제 보장을 제공 | | 엔트리 일부만 수정/삭제/삽입 | 검증에서 반드시 탐지 | -| 로그 파일 뒤쪽을 잘라냄 (truncation) | 앵커 파일과 대조해 탐지 | +| 로그 파일 뒤쪽만 잘라냄 (앵커는 그대로) | 앵커 파일과 대조해 탐지 | +| 뒤쪽을 자르고 앵커도 함께 고쳐 씀 | v1에서 탐지 불가, 아래 참고 | | 체인 전체를 처음부터 재계산해 교체 | v1에서 탐지 불가, 하드웨어 서명이 로드맵에 있는 이유 | +`head.json`은 `chain.jsonl`과 같은 디렉토리에 같은 권한으로 있습니다. 곧 체인을 자를 수 있는 공격자는 앵커도 고칠 수 있으므로, 앵커는 "쓰기 권한이 체인에만 있고 앵커에는 없는" 공격자만 막습니다. 실제 파일시스템 권한 모델에서 그런 분리는 생기지 않습니다. 앵커의 값은 부분 쓰기나 크래시로 끊긴 파일을 탐지하는 데 있지, 권한을 가진 공격자를 막는 데 있지 않습니다. + 곧 실제 강제력은 두 가지 조합에서 나옵니다. 첫째로 감사 디렉토리를 강제 층이 에이전트에게 쓰기 금지하고, 둘째로 해시체인이 그 방어가 뚫렸을 때 흔적을 남깁니다. 전자 없이 후자만으로는 부족하다는 사실을 제품이 숨기지 않습니다. ## 3. 저장 레이아웃 diff --git a/docs/design.md b/docs/design.md index b062b51..905dce1 100644 --- a/docs/design.md +++ b/docs/design.md @@ -168,7 +168,9 @@ seccomp user notification 층은 강제 층이 아니라 **관측과 승인 채 ### 9.4 ask 승인 UX: `/dev/tty` 직결 인라인 프롬프트 -브로커가 제어 터미널을 직접 열어 승인을 묻습니다. 에이전트의 stdin/stdout/stderr와 물리적으로 분리된 채널이므로, 에이전트가 승인 프롬프트를 위조하거나 자기 출력으로 사용자 입력을 가로챌 수 없습니다. 이는 8장의 "사람 승인 피싱" 리스크 대응의 핵심입니다. TUI는 이 채널 위에 얹는 표현 계층이며 별도 결정 사항이 아닙니다. +브로커가 제어 터미널을 직접 열어 승인을 묻습니다. 에이전트의 stdin/stdout/stderr와 분리된 채널이므로, 에이전트가 승인 프롬프트를 위조하거나 자기 출력으로 사용자 입력을 가로챌 수 없습니다. 이는 8장의 "사람 승인 피싱" 리스크 대응의 핵심입니다. TUI는 이 채널 위에 얹는 표현 계층이며 별도 결정 사항이 아닙니다. + +분리는 채널을 따로 쓰는 것만으로는 성립하지 않습니다. `/dev/tty`는 프로세스가 열기만 하면 닿는 제어 터미널이므로, 강제 프로파일이 자식에게 이 노드를 열어 주면 자식이 같은 화면에 가짜 프롬프트를 그리고 사용자가 친 답을 먼저 읽어 갈 수 있습니다. 그래서 강제 층은 `/dev/tty`를 자식의 허용 목록에서 뺍니다(Seatbelt의 `DEV_RW_LITERALS`, Landlock의 `DEV_RW_PATHS`). 상속된 stdin·stdout·stderr는 그대로 두므로 보통의 대화형 프로그램은 영향받지 않습니다. 승인 프롬프트는 에이전트가 제공한 설명이 아니라 브로커가 관측한 원본 사실(정규화된 절대 경로, argv 배열 원본, 매칭된 규칙과 그 근거)만 표시합니다. 에이전트가 만든 문자열은 승인 화면에 넣지 않습니다. diff --git a/docs/policy-dsl.md b/docs/policy-dsl.md index fbce607..87e8e55 100644 --- a/docs/policy-dsl.md +++ b/docs/policy-dsl.md @@ -53,7 +53,7 @@ action = "allow" | 필드 | 필수 | 설명 | |-------------|-----|------------------------------------------------------------------------------------------------------| -| `id` | 예 | 파일 내에서 유일하고 내장 규칙 id와 겹치지 않을 것. 감사 로그의 `rule` 필드에 기록되므로 안정적인 이름을 쓸 것 | +| `id` | 예 | 영숫자와 `.` `_` `-` `:` 만 쓸 수 있음. 파일 내에서 유일하고 내장 규칙 id와 겹치지 않을 것. 감사 로그의 `rule` 필드에 기록되므로 안정적인 이름을 쓸 것 | | `kind` | 예 | `file` \| `exec` \| `egress` | | `action` | 예 | `allow` \| `deny` \| `ask`. `forbid`는 내장 베이스라인 전용이며 사용자 규칙에 쓰면 로드 거부 ([3.2절](#32-forbid와-overrides)) | | `reason` | 조건부 | 사람이 읽는 근거. `overrides`를 쓸 때 필수 | @@ -365,6 +365,7 @@ action = "allow" 1. `version`이 1입니다. 2. TOML 문법이 유효하고 알 수 없는 키가 없습니다. 3. `id`가 유일합니다. 사용자 규칙끼리 겹치지 않는 것은 물론이고 내장 베이스라인과 자기보호 규칙의 id와도 겹치지 않아야 합니다. 감사 로그의 `rule` 필드는 티어를 담지 않으므로, id가 겹치면 그 결정을 낸 규칙이 어느 티어의 것인지 로그만으로 알 수 없게 됩니다. `overrides`로 내장 id를 **지목**하는 것은 이와 별개이며 정상입니다. +3-1. `id`가 영숫자와 `.` `_` `-` `:` 로만 되어 있습니다. id는 macOS 커널 프로파일에 주석으로 그대로 들어가므로, 개행이 섞이면 주석이 거기서 끝나고 뒤 내용이 살아 있는 SBPL 지시문이 됩니다. 마지막 규칙이 이기는 언어라 그 자리에 넓은 `allow` 하나를 두면 베이스라인 forbid 까지 덮입니다. 강제 층도 방출 시점에 제어 문자를 한 번 더 지우지만, 로드 시점에서 먼저 막습니다. 4. 모든 `action`과 `kind`, `mode` 값이 알려진 값입니다. 5. 모든 `path` 패턴이 5절 문법에 맞고 절대 경로입니다. 6. `[defaults].egress`가 `allow`가 아닙니다.