플러그인 하나가 시스템 전체를 뚫는 상황을 막고 싶다면, 권한 설계를 아키텍처 단계에서 결정해야 한다. 단순히 read/write로 이분화하는 것은 시작점도 안 된다. 리소스 유형, 작업 범위, 접근 제약을 3차원으로 정의하고, 선언-검증-감사의 자동화 루프를 구축하는 과정을 실제 코드와 함께 풀어낸다.
왜 단순 권한 모델이 무너지는가
플러그인 시스템에서 권한 스코프는 곧 공격 표면이다. 플러그인 하나가 전체 파일시스템이나 환경변수에 접근할 수 있다면, 그 플러그인 하나의 취약점이 시스템 전체 침해로 이어진다. 실제 사고의 상당수는 "허용된 플러그인이 예상보다 넓은 범위에 접근했다"는 패턴이다.
흔히 저지르는 실수는 두 가지다. 첫째, 배포 편의를 위해 권한을 넉넉하게 부여한 뒤 축소를 미룬다. 둘째, 선언만 하고 런타임 검증을 생략한다. 이 두 가지가 겹치면 선언은 문서에 불과해지고 실제 접근은 무제한이 된다.
스코프 모델 설계: 3차원 선언
권한 구조를 타입으로 먼저 정의한다. 리소스 유형(file, network, env, db), 허용 액션 목록, 그리고 접근 가능한 범위를 추가로 제한하는 제약 조건이 한 세트를 이룬다.
interface PluginScope {
resource: ResourceType; // "file" | "network" | "env" | "db"
actions: Action[]; // ["read"] | ["read", "write"] | ["execute"]
constraints: ScopeConstraint;
}
interface ScopeConstraint {
pathPattern?: string[]; // ["/tmp/**", "/data/readonly/**"]
networkHosts?: string[]; // ["api.example.com"]
envKeyPattern?: string[]; // ["APP_*", "!APP_SECRET_*"]
maxPayloadBytes?: number;
}
플러그인은 배포 시점에 plugin.manifest.yaml로 필요한 스코프를 명시한다. 예시로 로그 파서 플러그인은 특정 경로의 로그 파일 읽기와 내부 메트릭 서버 접근만 필요하다.
# plugin.manifest.yaml
name: log-parser
version: 1.2.0
scopes:
- resource: file
actions: [read]
constraints:
pathPattern:
- "/var/log/app/*.log"
- resource: network
actions: [read]
constraints:
networkHosts:
- "metrics.internal"
매니페스트를 파싱할 때 허용되지 않은 리소스 타입이나 pathPattern 없이 file 스코프를 선언하는 경우 배포 자체를 거부한다. 권한 축소 압력을 배포 파이프라인에 내재화하는 첫 번째 지점이다.
런타임 권한 게이트: 선언을 실제 검증으로
선언만으로는 아무것도 막지 못한다. 플러그인 코드가 실행될 때 모든 리소스 접근 요청을 인터셉트하는 게이트가 반드시 필요하다.
class PermissionGate {
private grantedScopes: Map<string, PluginScope[]>;
check(pluginId: string, request: ResourceRequest): boolean {
const scopes = this.grantedScopes.get(pluginId) ?? [];
return scopes.some(scope =>
scope.resource === request.resource &&
scope.actions.includes(request.action) &&
this.matchesConstraints(scope.constraints, request)
);
}
private matchesConstraints(
constraints: ScopeConstraint,
request: ResourceRequest
): boolean {
if (constraints.pathPattern && request.path) {
return constraints.pathPattern.some(p =>
micromatch.isMatch(request.path!, p)
);
}
if (constraints.networkHosts && request.host) {
return constraints.networkHosts.includes(request.host);
}
return true;
}
}
플러그인 실행 컨텍스트는 Worker thread, V8 isolate, Wasm sandbox 중 하나로 격리하고, 해당 컨텍스트에서 발생하는 모든 시스템 콜을 게이트를 통해 라우팅한다. 게이트를 우회하는 직접 I/O 호출은 샌드박스 자체가 차단한다.
권한 에스컬레이션 방지: 세 가지 벡터
게이트를 우회하려는 시도는 세 가지 패턴으로 나타난다.
첫째, 심볼릭 링크를 통한 간접 접근. 허용된 경로 안에 심볼릭 링크를 만들어 외부 경로에 접근하는 방식이다. 경로를 항상 정규화한 뒤 검증하면 막을 수 있다.
function resolveSafePath(base: string, input: string): string | null {
const resolved = path.resolve(base, input);
if (!resolved.startsWith(base)) return null; // path traversal 차단
return resolved;
}
둘째, 플러그인 간 위임 체인. 플러그인 A가 플러그인 B를 호출해 B의 더 넓은 권한으로 리소스에 접근하는 패턴이다. 호출 스택에 원래 플러그인 ID를 전파하고, 교집합 권한만 허용한다.
function mergeScopes(
callerScopes: PluginScope[],
calleeScopes: PluginScope[]
): PluginScope[] {
// 합집합이 아닌 교집합만 허용
return callerScopes.filter(cs =>
calleeScopes.some(ces =>
ces.resource === cs.resource &&
cs.actions.every(a => ces.actions.includes(a))
)
);
}
셋째, 환경변수 오염. process.env 전체를 플러그인에 노출하면 DB 비밀번호나 API 키가 그대로 보인다. 허용된 패턴만 포함한 스냅샷을 전달한다.
function filterEnv(
env: NodeJS.ProcessEnv,
patterns: string[]
): Record<string, string> {
return Object.fromEntries(
Object.entries(env).filter(([key]) =>
patterns.some(p => micromatch.isMatch(key, p))
)
);
}
envKeyPattern에 !APP_SECRET_* 같은 부정 패턴을 지원하면 와일드카드를 허용하면서도 민감 키를 제외할 수 있다.
감사 로그와 스코프 드리프트 감지
권한 검증 결과를 구조화된 로그로 남기는 것이 세 번째 단계다.
interface AuditEntry {
timestamp: number;
pluginId: string;
resource: string;
action: string;
path?: string;
allowed: boolean;
reason?: string; // "scope_not_found" | "constraint_mismatch"
}
30일치 로그에서 실제 접근 패턴과 선언된 스코프를 비교하면 두 가지 신호를 포착할 수 있다.
| 신호 | 의미 | 대응 |
|---|---|---|
| 선언했지만 미사용 스코프 | 과잉 권한 | 다음 배포 시 제거 권고 |
allowed: false 반복 |
버그 또는 악의적 시도 | 즉시 알림 + 플러그인 격리 |
# 30일간 미사용 스코프 분석
jq '
group_by(.pluginId) |
map({
plugin: .[0].pluginId,
used_scopes: (
[.[] | select(.allowed) | "\(.resource):\(.action)"] |
unique
)
})
' audit.jsonl
PR 단계에서 스코프 크리프 차단
plugin.manifest.yaml이 변경될 때 자동으로 스코프 diff를 생성하고 리뷰어에게 알린다. 새로운 리소스 타입이나 확장된 pathPattern이 추가된 경우 보안 팀 승인을 필수로 설정하면 스코프 증가를 구조적으로 차단할 수 있다.
# .github/workflows/scope-review.yml
on:
pull_request:
paths:
- "**/plugin.manifest.yaml"
jobs:
scope-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 2 }
- run: |
git diff HEAD~1 -- '**/plugin.manifest.yaml' \
| python scripts/scope_diff_report.py \
| gh pr comment --body-file -
이 워크플로가 없으면 스코프는 거의 항상 증가하는 방향으로만 움직인다. 배포 편의를 위해 넓게 열어둔 권한은 좁혀지는 일이 없다. 자동화된 diff 리포트가 "이번 PR에서 스코프가 어떻게 바뀌었는가"를 가시화해야 리뷰어가 검토할 수 있다.
스코프 설계의 실질적인 효과는 선언-검증-감사 세 단계가 자동화된 루프를 형성할 때 나온다. 권한을 한 번 부여하면 줄어드는 일이 없다는 관성을 감사 로그 기반 피드백 루프로 깨는 것이 핵심이다. 다음 글에서는 플러그인 간 격리 수준을 결정하는 샌드박스 런타임 선택 기준(Worker thread vs V8 isolate vs Wasm)을 다룬다.
🐦 X에서 더 빠르게: @baegseungh7061
📚 이 시리즈 더 보기: 전체 글 목록
💌 새 글 알림: X 팔로우 또는 블로그 RSS 구독