Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/airlock-audit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 37 additions & 4 deletions crates/airlock-audit/src/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))?;
Expand Down Expand Up @@ -197,7 +224,13 @@ pub fn read_entries_lossy(dir: impl AsRef<Path>) -> Result<(Vec<Entry>, Option<S
for (idx, line) in reader.lines().enumerate() {
let line = line.map_err(|e| Error::io(&path, e))?;
if line.trim().is_empty() {
continue;
// 검증기는 빈 줄을 치명적 오류로 봅니다(docs/audit-format.md 8절). 뷰어가
// 조용히 건너뛰면 같은 파일을 두 리더가 다르게 읽어 절단 탐지에 구멍이 납니다
problem = Some(format!(
"{}번째 줄이 비어 있음. 엔트리가 아닌 줄이 끼어들었음",
idx.saturating_add(1)
));
break;
}
match serde_json::from_str::<Entry>(&line) {
Ok(entry) => entries.push(entry),
Expand Down
1 change: 1 addition & 0 deletions crates/airlock-broker/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 1 addition & 27 deletions crates/airlock-broker/src/approve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// 사람 응답을 기다리는 기본 상한.
Expand Down Expand Up @@ -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");
Expand Down
74 changes: 68 additions & 6 deletions crates/airlock-broker/src/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AccessFs> {
Expand Down Expand Up @@ -457,22 +461,27 @@ 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('?') {
continue;
}
let candidate = pattern.witness();
if candidate.exists() {
add_root(&mut walker, &candidate, true, &mut plan);
add_root(&mut walker, &candidate, writable, &mut plan);
}
}
}
Expand All @@ -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;
Expand Down Expand Up @@ -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으로 강제되지 않음. 포트까지만 강제하며 \
호스트 판정은 프록시 층이 필요함: {}",
Expand Down Expand Up @@ -673,6 +726,15 @@ fn apply(plan: &Plan, abi: ABI) -> std::io::Result<RulesetStatus> {
.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 [
Expand Down
Loading
Loading