본문 바로가기
Security Operations

[Security Operations] OCSF로 보안 로그 정규화하기: 형식이 다른 인증 로그를 규칙 하나로 탐지하기

by Tru5tC0der 2026. 9. 25.

보안 관제에서 다루는 로그는 제품마다 형식이 다릅니다. SSH 서버는 Failed password for root from 172.30.66.66 port 35072 같은 문자열 한 줄을 남기고, AWS CloudTrail은 "sourceIPAddress"와 "ConsoleLogin": "Failure"가 들어 있는 JSON을 남깁니다. 둘 다 "누군가 로그인에 실패했다"는 같은 사건인데, 출발지 IP를 가리키는 필드 이름도, 실패를 나타내는 방식도 다릅니다.

그래서 같은 공격자가 서버와 클라우드 콘솔을 번갈아 두드려도, 소스마다 따로 파싱하고 따로 쿼리해야 둘을 연결할 수 있습니다. 로그 소스가 늘어날수록 파서와 탐지 규칙도 소스 수만큼 늘어납니다.

OCSF(Open Cybersecurity Schema Framework)는 이 문제를 풀기 위한 공개 스키마입니다. 로그를 OCSF 형식으로 바꿔 두면 어느 제품에서 왔든 출발지 IP는 src_endpoint.ip, 실패는 status_id: 2로 같아집니다.

이번 글에서는 OCSF 스키마가 어떻게 생겼는지 살펴보고, SSH 로그와 CloudTrail 로그를 OCSF로 바꾸는 변환기를 Go로 직접 만들어 보겠습니다. 변환 결과는 OCSF 공식 검증 API로 확인하고, 두 가지 실제 로그로 동작을 검증합니다. 하나는 Docker로 띄운 SSH 서버가 남긴 로그이고, 다른 하나는 공개된 AWS 계정의 CloudTrail 로그 190만 건입니다. 마지막에는 두 소스를 넘나드는 무차별 대입 공격을 탐지 규칙 하나로 잡아내 보겠습니다.

코드와 실행 결과는 모두 GitHubgunh0/ocsf-auth-normalizer에 공개해 두었습니다.


목차

  • OCSF란?
  • OCSF 스키마는 어떻게 생겼을까?
  • Authentication 클래스 살펴보기
  • 변환기는 어떻게 구성했을까?
  • 로그 한 줄을 OCSF로 바꿔 보기
  • 변환 결과는 어떻게 검증할까?
  • Docker로 실제 SSH 로그를 만들어 확인하기
  • 실제 CloudTrail 로그로도 확인해 보기
  • 소스가 달라도 탐지 규칙은 하나
  • 도입할 때 고민할 점
  • 마치며
  • 참고 자료

OCSF란?

OCSF는 보안 이벤트를 표현하는 공통 스키마입니다. 2022년 8월 AWS와 Splunk가 주도하고 IBM Security, CrowdStrike, Palo Alto Networks, Zscaler 등 여러 보안 기업이 함께 참여해 공개했습니다. 2024년 11월에는 Linux Foundation 프로젝트가 되었고, 발표 당시 기준으로 200개 조직과 900명이 넘는 기여자가 참여하고 있습니다.

스키마는 계속 개정되고 있습니다. 2023년 9월에 1.0.0이 나온 뒤 2025년부터는 몇 달 간격으로 새 버전이 나왔고, 이 글을 쓰는 시점(2026년 9월)의 최신 버전은 2026년 8월 3일에 나온 1.9.0입니다.

실제 제품에서도 쓰이고 있습니다. 가장 큰 사례는 2025년 12월 정식 출시된 새 AWS Security Hub입니다. Security Hub는 GuardDuty, Inspector, Macie 같은 AWS 보안 서비스의 탐지 결과를 OCSF 형식으로 받아서 모으고, 자신이 만드는 탐지 결과도 OCSF로 내보냅니다. 로그 저장소인 Amazon Security Lake도 데이터를 OCSF로 정규화해서 저장합니다.

OCSF는 스키마를 정의할 뿐, 로그를 모으거나 분석하는 제품은 아닙니다. 원본 로그를 OCSF로 바꾸는 일은 로그를 수집하는 쪽에서 해야 합니다. 이번 글에서 만들어 볼 것도 바로 그 변환기입니다.


OCSF 스키마는 어떻게 생겼을까?

OCSF는 이벤트를 세 단계로 분류합니다.

단계 속성 예시
카테고리(Category) category_uid 3 Identity & Access Management
클래스(Class) class_uid 3002 Authentication
활동(Activity) activity_id 1 Logon

1.9.0 기준으로 카테고리는 8개이고, 그 아래에 클래스가 86개 있습니다.

category_uid 카테고리 클래스 수
1 System Activity 16
2 Findings 8
3 Identity & Access Management 8
4 Network Activity 14
5 Discovery 26
6 Application Activity 8
7 Remediation 4
8 Unmanned Systems 2

로그인 성공과 실패는 IAM 카테고리의 Authentication(3002) 클래스에 해당합니다. 한 클래스 안에서도 무슨 일이 일어났는지는 activity_id로 구분합니다. Authentication이라면 1은 로그온, 2는 로그오프입니다.

클래스와 활동을 합친 값이 type_uid입니다. 계산 방법은 정해져 있습니다.

type_uid = class_uid × 100 + activity_id
         = 3002 × 100 + 1
         = 300201   (Authentication: Logon)

type_uid 하나만 보면 이 이벤트가 어떤 클래스의 어떤 활동인지 바로 알 수 있습니다. 대신 class_uid, activity_id와 값이 어긋나면 안 됩니다. 뒤에서 검증 API로 확인해 보면 실제로 이 부분에서 오류가 납니다.

클래스 위에 덧붙이는 구성 요소도 세 가지가 있습니다.

  • 객체(Object): user, network_endpoint처럼 여러 클래스가 함께 쓰는 속성 묶음입니다. 로그인한 사용자는 어느 클래스에서든 user.name으로 표현합니다.
  • 프로파일(Profile): cloud, host, container 등 15개가 있습니다. 클래스에 속성 묶음을 얹는 방식이라, 클라우드 환경의 이벤트라면 cloud 프로파일로 계정과 리전 정보를 붙일 수 있습니다.
  • 확장(Extension): linux, win, macos가 있습니다. 특정 플랫폼에만 있는 속성이나 클래스를 스키마에 추가합니다.

각 속성에는 요구 수준도 정해져 있습니다. 반드시 있어야 하는 required, 가능하면 채우라는 recommended, 있어도 되고 없어도 되는 optional 세 가지입니다.


Authentication 클래스 살펴보기

Authentication 클래스를 프로파일 없이 조회하면 속성이 50개 나옵니다. 이 가운데 required는 8개입니다.

속성 설명
class_uid, category_uid 3002, 3
activity_id, type_uid 활동과 그 조합 값
severity_id 심각도 (1 Informational ~ 6 Fatal)
time 사건이 일어난 시각. 에포크 밀리초 정수
metadata 스키마 버전(version)과 이벤트를 만든 제품(product)
user 인증을 시도한 사용자

그렇다면 이 8개만 채우면 올바른 OCSF 이벤트가 될까요? 필수 속성만 넣은 이벤트를 OCSF 공식 검증 API에 보내 보았습니다.

{
  "class_uid": 3002, "category_uid": 3, "activity_id": 1, "type_uid": 300201,
  "severity_id": 1, "time": 1790000000000,
  "metadata": { "version": "1.9.0", "product": { "name": "sshd", "vendor_name": "OpenBSD" } },
  "user": { "name": "alice" }
}
constraint_failed: Constraint failed: "at_least_one" from class "authentication" uid 3002;
expected at least one constraint attribute, but got none.

검증에 실패합니다. 스키마를 다시 보면 Authentication 클래스에는 속성별 요구 수준과 별개로 제약 조건(constraint)이 하나 더 있습니다.

"constraints": { "at_least_one": ["service", "dst_endpoint"] }

인증 대상이 된 서비스(service)나 목적지(dst_endpoint) 중 하나는 반드시 있어야 한다는 뜻입니다. 어디에 로그인했는지 모르는 인증 이벤트는 분석에 쓸 수 없으니 당연한 요구입니다. 이렇게 속성 목록만 봐서는 드러나지 않는 규칙이 있기 때문에, 변환기를 만들 때는 검증 API로 결과를 꼭 확인하는 것이 좋습니다.

그 밖에 recommended 속성 중 인증 로그에서 자주 쓰는 것은 다음과 같습니다.

속성 설명
status_id 1 Success, 2 Failure
status_detail 실패 사유 등 원본의 상세 설명
src_endpoint 출발지 IP와 포트
dst_endpoint, service 인증 대상
is_mfa, is_remote MFA 사용 여부, 원격 로그인 여부
logon_type_id 10 Remote Interactive 등 로그온 유형
observables IP, 사용자 이름처럼 검색과 연관 분석에 쓸 핵심 값 목록

변환기는 어떻게 구성했을까?

변환기는 ocsf-auth라는 CLI 하나로, 명령이 세 개 있습니다.

go install github.com/gunh0/ocsf-auth-normalizer/cmd/ocsf-auth@latest

ocsf-auth convert -source sshd /var/log/auth.log > ssh.ocsf.jsonl
ocsf-auth convert -source cloudtrail AWSLogs/.../CloudTrail/us-east-1/2026/07/*.json.gz > aws.ocsf.jsonl
ocsf-auth validate ssh.ocsf.jsonl aws.ocsf.jsonl
ocsf-auth detect -threshold 4 -window 10m ssh.ocsf.jsonl aws.ocsf.jsonl > findings.ocsf.jsonl

GitHubgunh0/ocsf-auth-normalizer/README.md#install--run

convert는 원본 로그를 OCSF 이벤트로 바꿔 한 줄에 하나씩 JSON으로 출력합니다. CloudTrail이 S3에 저장하는 *.json.gz 파일도 압축을 풀지 않고 바로 읽습니다. validate는 변환 결과를 OCSF 공식 검증 API로 확인하고, detect는 변환된 이벤트에서 무차별 대입 공격을 찾습니다.

저장소 구성은 다음과 같습니다.

경로 역할
internal/ocsf/ OCSF 1.9.0 Authentication, Detection Finding 타입과 상수
internal/source/ OpenSSH, CloudTrail 변환기
internal/validate/ 스키마 서버 검증 API 클라이언트
internal/detect/ 소스를 넘나드는 무차별 대입 탐지 규칙
demo/ 실제 SSH 서버를 띄우는 Docker 환경과 실행 결과
scripts/verify-cloudtrail.sh 공개 CloudTrail 데이터셋으로 검증하는 스크립트

외부 의존성 없이 Go 표준 라이브러리만 썼고, 단위 테스트는 네트워크 없이 돌아갑니다. GitHub Actions에서 push마다 gofmt, go vet, go test -race를 실행합니다.


로그 한 줄을 OCSF로 바꿔 보기

OpenSSH 서버가 남기는 로그 한 줄을 Authentication 이벤트로 바꿔 보겠습니다. 아래는 뒤에서 Docker로 띄운 SSH 서버가 실제로 남긴 로그입니다.

2026-09-25T11:33:21.921789+00:00 web-01 sshd[69]: Failed password for root from 172.30.66.66 port 35072 ssh2

GitHubgunh0/ocsf-auth-normalizer/demo/sample/auth.log

메시지 형식은 OpenSSH가 정해 두었으니 정규식으로 읽으면 됩니다. 변환기가 인식하는 메시지는 다음과 같습니다.

sshd 메시지 activity_id status_id status_detail
Accepted <방식> for <사용자> from <IP> port <포트> 1 Logon 1 Success  
Failed <방식> for <사용자> from <IP> port <포트> 1 Logon 2 Failure Failed password
Failed <방식> for invalid user <사용자> from ... 1 Logon 2 Failure Invalid user
Disconnected from user <사용자> <IP> port <포트> 2 Logoff 1 Success  

같은 시도에 대해 Invalid user admin from ... 줄과 Failed password for invalid user admin ... 줄이 함께 남기 때문에, 앞의 줄은 세지 않고 뒤의 줄만 이벤트로 만듭니다. pam_unix 줄도 같은 시도를 한 번 더 기록한 것이라 건너뜁니다.

정규식은 internal/source/sshd.go에 있습니다. OpenSSH 9.8부터 인증 처리가 sshd-session 프로세스로 분리되었기 때문에, 프로그램 이름은 sshd와 sshd-session을 모두 받습니다. 타임스탬프도 RFC 3339 형식과, 연도와 시간대가 없는 옛 syslog 형식(Sep 25 10:12:01)을 모두 읽습니다.

sshdAccepted     = regexp.MustCompile(`^Accepted (\S+) for (\S+) from (\S+) port (\d+)`)
sshdFailed       = regexp.MustCompile(`^Failed (\S+) for (invalid user )?(\S+) from (\S+) port (\d+)`)
sshdDisconnected = regexp.MustCompile(`^Disconnected from user (\S+) (\S+) port (\d+)`)

GitHubgunh0/ocsf-auth-normalizer/internal/source/sshd.go#L33-L35

어느 소스에서 왔든 분류 속성은 NewAuthentication 한 곳에서 채웁니다. ClassAuthentication은 3002, CategoryIAM은 3이고, TypeUID는 앞에서 본 class_uid × 100 + activity_id를 계산하는 함수입니다. type_uid를 계산하는 곳이 하나뿐이라 값이 어긋날 일이 없습니다.

func NewAuthentication(activityID, statusID int, timeMillis int64) Authentication {
    severity := SeverityInformational
    if statusID == StatusFailure {
        severity = SeverityLow
    }
    return Authentication{
        ClassUID:    ClassAuthentication,
        CategoryUID: CategoryIAM,
        ActivityID:  activityID,
        TypeUID:     TypeUID(ClassAuthentication, activityID),
        SeverityID:  severity,
        StatusID:    statusID,
        Time:        timeMillis,
    }
}

GitHubgunh0/ocsf-auth-normalizer/internal/ocsf/ocsf.go#L148-L162

앞의 로그 한 줄은 다음 이벤트가 됩니다.

{
  "class_uid": 3002,
  "category_uid": 3,
  "activity_id": 1,
  "type_uid": 300201,
  "severity_id": 2,
  "status_id": 2,
  "status_detail": "Failed password",
  "time": 1790336001921,
  "message": "Failed password for root from 172.30.66.66 port 35072 ssh2",
  "metadata": {
    "version": "1.9.0",
    "product": { "name": "OpenSSH", "vendor_name": "OpenBSD" },
    "uid": "71b47c72fda702da308778a650de2a12",
    "log_name": "auth.log"
  },
  "user": { "name": "root" },
  "src_endpoint": { "ip": "172.30.66.66", "port": 35072 },
  "dst_endpoint": { "port": 22, "hostname": "web-01" },
  "service": { "name": "sshd" },
  "is_remote": true,
  "logon_type_id": 10,
  "observables": [
    { "name": "src_endpoint.ip", "type_id": 2, "value": "172.30.66.66" },
    { "name": "user.name", "type_id": 4, "value": "root" }
  ],
  "raw_data": "2026-09-25T11:33:21.921789+00:00 web-01 sshd[69]: Failed password for root from 172.30.66.66 port 35072 ssh2"
}

GitHubgunh0/ocsf-auth-normalizer/demo/sample/ssh.ocsf.jsonl

몇 가지를 짚어 보겠습니다.

  • time은 문자열이 아니라 에포크 밀리초 정수입니다. 원본 타임스탬프의 시간대를 반영해 변환합니다.
  • metadata.product에는 이 이벤트를 처음 만든 제품을 적습니다. 변환기가 아니라 OpenSSH입니다.
  • metadata.uid는 원본 줄의 SHA-256 해시 앞부분입니다. 같은 파일을 두 번 변환해도 같은 ID가 나오고, 뒤에서 탐지 결과가 원본 이벤트를 참조할 때 이 값을 씁니다.
  • raw_data에는 원본 줄을 그대로 남깁니다. 매핑하지 못한 정보가 있더라도 원본으로 돌아가 확인할 수 있습니다.

CloudTrail의 ConsoleLogin 이벤트도 같은 방식으로 바꿉니다(internal/source/cloudtrail.go). 원본이 JSON이라 파싱은 더 쉽고, 대응 관계만 정하면 됩니다.

OCSF 속성 OpenSSH CloudTrail ConsoleLogin
status_id Accepted / Failed responseElements.ConsoleLogin
status_detail Failed password, Invalid user errorMessage
user.name 메시지 안의 사용자 userIdentity.userName (루트 계정은 root)
user.account   userIdentity.accountId (AWS 계정)
src_endpoint.ip from <IP> sourceIPAddress
service.name sshd eventSource (signin.amazonaws.com)
is_mfa 알 수 없음 additionalEventData.MFAUsed
metadata.uid 원본 줄의 해시 eventID

CloudTrail에는 SSH처럼 목적지 호스트가 없습니다. 대신 service.name에 signin.amazonaws.com을 넣어서 앞에서 본 at_least_one 제약을 채웁니다.


변환 결과는 어떻게 검증할까?

OCSF 스키마 브라우저(schema.ocsf.io)는 검증 API를 함께 제공합니다. 이벤트 하나를 JSON으로 보내면 오류와 경고를 돌려줍니다.

curl -s -X POST -H 'Content-Type: application/json' \
  -d @event.json https://schema.ocsf.io/1.9.0/api/v2/validate
{"warnings":[],"errors":[],"error_count":0,"warning_count":0}

주소에 스키마 버전(1.9.0)을 넣은 점에 주목해 주세요. 버전을 빼고 /api/v2/validate로 보내면 서버의 최신 버전 기준으로 검증합니다. 지금은 둘이 같지만, 1.10.0이 나오면 1.9.0 기준으로 만든 이벤트가 새 규칙으로 검증됩니다. 그래서 변환기의 validate 명령은 기본값으로 이벤트가 선언한 버전의 API(EndpointFor)를 호출합니다.

변환기를 처음 만들 때 흔히 하는 실수 다섯 가지를 일부러 넣어서 보내 보았습니다. 앞에서 본 SSH 이벤트에서 속성 하나씩만 바꿨습니다.

바꾼 내용 검증 결과
type_uid를 300202로 type_uid_incorrect: Event's "type_uid" value of 300202 does not match expected value of 300201 (class_uid 3002 * 100 + activity_id 1 = 300201)
severity_id를 "Low" 문자열로 attribute_wrong_type: expected integer_t, got string_t. 이어서 attribute_enum_value_unknown
time을 ISO 8601 문자열로 attribute_wrong_type: expected timestamp_t (long_t), got string_t
스키마에 없는 src_ip 추가 attribute_unknown: attribute "src_ip" is not defined in class "authentication"
status_id를 5로 attribute_enum_value_unknown: value 5 is not defined for enum "status_id"

오류 메시지가 원인을 정확히 알려 줍니다. 특히 src_ip처럼 원본 필드 이름을 그대로 들고 오는 실수는 JSON 파싱만으로는 드러나지 않는데, 검증 API는 스키마에 없는 속성이라고 바로 알려 줍니다. 원본에만 있는 값을 꼭 남겨야 한다면 unmapped 객체에 넣으면 됩니다.

사내망처럼 외부 API를 쓸 수 없는 환경이라면 OCSF 스키마 서버(ocsf-server)를 직접 띄우고 -endpoint로 주소를 바꿀 수 있습니다.

ocsf-auth validate -endpoint http://localhost:8080/api/v2/validate events.jsonl

Docker로 실제 SSH 로그를 만들어 확인하기

지금까지는 로그 한 줄씩 확인했습니다. 이번에는 실제 SSH 서버에 정상 사용자와 공격자가 접속하게 해서, 그 서버가 남긴 로그를 그대로 변환해 보겠습니다. 저장소의 demo/ 디렉터리에 Docker Compose 환경을 만들어 두었습니다.

컨테이너 IP 역할
target 172.30.66.10 Ubuntu 24.04, OpenSSH, rsyslog. /var/log/auth.log를 남긴다
client 172.30.66.20 정상 사용자 alice가 키로 로그인한다
attacker 172.30.66.66 root, 존재하지 않는 admin, ops 순서로 비밀번호를 추측한다. ops의 마지막 추측이 맞는다

공격자의 시나리오(demo/attacker/scenario.sh)는 셸 스크립트 몇 줄입니다.

try root   123456
try root   password
try admin  admin
try admin  admin123
try ops    ops
try ops    'Summer2026!'

GitHubgunh0/ocsf-auth-normalizer/demo/attacker/scenario.sh

뒤에서 두 소스를 함께 탐지하는 모습을 보여 주기 위해, 같은 공격자가 SSH보다 먼저 AWS 콘솔 로그인을 두 번 시도했다고 가정한 CloudTrail 레코드 두 개도 실행 시각에 맞춰 만듭니다. CloudTrail은 로컬에서 재현할 수 없기 때문입니다. 데모에서 이 두 건만 시뮬레이션이고, SSH 로그는 모두 실제로 발생한 것입니다. CloudTrail 변환기는 다음 절에서 실제 로그로 따로 검증합니다.

make demo를 실행하면 컨테이너 구동부터 변환, 검증, 탐지까지 한 번에 진행됩니다.

$ make demo
==> scenario
[client] logged in as alice on web-01
[attacker] root / 123456 failed
[attacker] root / password failed
[attacker] admin / admin failed
[attacker] admin / admin123 failed
[attacker] ops / ops failed
[attacker] got a shell as ops
          31 lines in out/auth.log
==> convert
           9 sshd events,        2 CloudTrail events
==> detect
           1 findings
==> validate (schema.ocsf.io)
ok    3002 f93d660a9c719d6668a92db2c225480c
...
ok    2004 cross-source-brute-force:172.30.66.66:1790335998000

12 events, 12 valid, 0 invalid

GitHubgunh0/ocsf-auth-normalizer/demo/run.sh, 결과는 GitHubgunh0/ocsf-auth-normalizer/demo/sample/validate.txt

SSH 서버가 남긴 로그(demo/sample/auth.log)는 31줄입니다. 이 중 인증 이벤트에 해당하는 줄은 다음 9줄입니다(날짜와 호스트 이름은 생략했습니다).

11:33:17.921110 sshd[39]:  Accepted publickey for alice from 172.30.66.20 port 35642 ssh2: ED25519 SHA256:Ha0+...
11:33:17.977445 sshd[50]:  Disconnected from user alice 172.30.66.20 port 35642
11:33:21.921789 sshd[69]:  Failed password for root from 172.30.66.66 port 35072 ssh2
11:33:25.770869 sshd[93]:  Failed password for root from 172.30.66.66 port 35088 ssh2
11:33:28.903223 sshd[123]: Failed password for invalid user admin from 172.30.66.66 port 42926 ssh2
11:33:34.315912 sshd[156]: Failed password for invalid user admin from 172.30.66.66 port 42934 ssh2
11:33:38.377221 sshd[194]: Failed password for ops from 172.30.66.66 port 42936 ssh2
11:33:39.945210 sshd[219]: Accepted password for ops from 172.30.66.66 port 56694 ssh2
11:33:39.996320 sshd[230]: Disconnected from user ops 172.30.66.66 port 56694

GitHubgunh0/ocsf-auth-normalizer/demo/sample/auth.log

나머지 22줄은 서버 시작 메시지, pam_unix 기록, Invalid user, Connection closed 같은 줄이라 이벤트로 만들지 않았습니다. 9줄이 모두 이벤트 9건으로 바뀌었고, CloudTrail 2건과 탐지 결과 1건까지 12건 모두 공식 검증을 통과했습니다. 변환 결과와 검증 결과는 저장소의 demo/sample/에 그대로 남겨 두었습니다.


실제 CloudTrail 로그로도 확인해 보기

CloudTrail 변환기는 데모의 시뮬레이션 레코드만으로는 제대로 검증했다고 말하기 어렵습니다. 직접 만든 예제는 결국 예상한 형태만 담고 있기 때문입니다. 그래서 공개된 실제 CloudTrail 로그로 다시 확인했습니다.

flaws.cloud는 2017년 2월에 공개된 AWS 보안 실습 사이트로, 잘못 설정된 AWS 계정을 누구나 공격해 볼 수 있게 만든 곳입니다. 운영자인 Summit Route가 2020년 10월 이 계정의 CloudTrail 로그를 공개했습니다. 2017년 2월 12일부터 2020년 10월 7일까지 1,939,207건이고, IP 주소와 계정 ID, 액세스 키는 형식만 유지한 채 임의의 값으로 바꾸어 익명화했습니다. 그래서 아래에 나오는 IP는 실제 공격자의 주소가 아닙니다.

scripts/verify-cloudtrail.sh가 데이터셋(240MB)을 받아서 변환, 탐지, 검증을 차례로 실행합니다.

$ make verify-cloudtrail
==> convert
          81 ConsoleLogin events: 69 success, 12 failure
==> detect
           1 findings
    4 failed logons from 12.80.110.252 within 10m0s against CloudTrail:(unknown user).
==> validate (schema.ocsf.io)

82 events, 82 valid, 0 invalid

GitHubgunh0/ocsf-auth-normalizer/scripts/verify-cloudtrail.sh

190만 건 가운데 콘솔 로그인은 81건이었고, 변환 결과 81건과 탐지 결과 1건이 모두 검증을 통과했습니다. 탐지 규칙은 2018년 2월 한 IP에서 2분 사이에 네 번 로그인에 실패한 기록을 찾아냈습니다.

실제 로그를 돌려 보니 직접 만든 예제에 없던 형태도 나왔습니다. 계정에 없는 사용자 이름으로 로그인에 실패하면, AWS는 입력한 이름 대신 HIDDEN_DUE_TO_SECURITY_REASONS를 기록합니다. 이 값을 그대로 user.name에 넣으면 그런 이름의 사용자가 실패를 몰아서 받은 것처럼 보이기 때문에, 변환기는 이 경우 user.name을 비우고 계정 정보만 남깁니다.


소스가 달라도 탐지 규칙은 하나

이제 OCSF로 바꾼 효과를 확인해 볼 차례입니다. 데모 결과에서 출발지 IP별 실패 횟수를 세는 쿼리는 소스가 두 개인데도 하나면 됩니다.

cat demo/sample/*.ocsf.jsonl | jq -s -c '
  map(select(.class_uid == 3002 and .status_id == 2))
  | group_by(.src_endpoint.ip)
  | map({ip: .[0].src_endpoint.ip, failures: length})'
[{"ip":"172.30.66.66","failures":7}]

GitHubgunh0/ocsf-auth-normalizer/demo/sample

CloudTrail 실패 2건과 SSH 실패 5건이 한 IP로 묶였습니다. 원본 형식 그대로였다면 SSH 로그에서는 정규식으로 from 뒤의 IP를 꺼내고, CloudTrail에서는 sourceIPAddress와 responseElements.ConsoleLogin을 따로 읽어서 합쳐야 했을 것입니다.

변환기의 detect 명령(internal/detect/bruteforce.go)은 이 쿼리를 조금 더 다듬은 규칙입니다.

  • 같은 출발지 IP의 로그인 실패를 시간순으로 묶습니다. 실패 사이의 간격이 -window(기본 10분)를 넘으면 다른 묶음으로 봅니다.
  • 한 묶음의 실패가 -threshold(기본 4회) 이상이면 탐지 결과를 만듭니다.
  • 실패 뒤에 같은 IP에서 로그인에 성공했다면, 비밀번호가 뚫렸을 가능성이 크므로 심각도를 High로 올립니다.

규칙 안에는 OpenSSH나 CloudTrail이라는 이름이 한 번도 나오지 않습니다. src_endpoint.ip, status_id, time만 봅니다. 앞에서 flaws.cloud 로그에 돌린 것도 같은 규칙입니다. 나중에 VPN이나 웹 애플리케이션 로그인 로그를 OCSF로 바꿔 넣어도, 규칙을 고치지 않고 그 소스까지 함께 탐지할 수 있습니다.

탐지 결과도 OCSF 형식으로 내보냅니다. Findings 카테고리의 Detection Finding(2004) 클래스입니다. 데모에서 나온 결과(demo/sample/findings.ocsf.jsonl)는 다음과 같습니다.

{
  "class_uid": 2004,
  "category_uid": 2,
  "activity_id": 1,
  "type_uid": 200401,
  "severity_id": 4,
  "confidence_id": 3,
  "status_id": 1,
  "is_alert": true,
  "message": "Successful logon after brute-force attempts from 172.30.66.66",
  "finding_info": {
    "uid": "cross-source-brute-force:172.30.66.66:1790335998000",
    "desc": "7 failed logons from 172.30.66.66 within 10m0s against CloudTrail:ops, CloudTrail:root, OpenSSH:root, OpenSSH:admin, OpenSSH:ops. Followed by a successful logon as OpenSSH:ops.",
    "analytic": { "type_id": 1, "name": "cross-source-brute-force" },
    "first_seen_time": 1790335998000,
    "last_seen_time": 1790336019945,
    "related_events": [
      { "uid": "00000000-0000-4000-8000-00000000c7a1", "type_uid": 300201, "severity_id": 2 },
      { "uid": "00000000-0000-4000-8000-00000000c7a2", "type_uid": 300201, "severity_id": 2 }
    ],
    "related_events_count": 8
  },
  "evidences": [
    { "src_endpoint": { "ip": "172.30.66.66" } },
    { "user": { "name": "ops" } }, { "user": { "name": "root" } }, { "user": { "name": "admin" } }
  ]
}

GitHubgunh0/ocsf-auth-normalizer/demo/sample/findings.ocsf.jsonl

(related_events는 8건 중 앞의 2건만 옮겼습니다.)

두 소스의 실패 7건 뒤에 SSH 로그인 성공이 이어졌기 때문에 심각도가 High(4)입니다. related_events에는 근거가 된 인증 이벤트의 metadata.uid가 들어 있어서, 탐지 결과에서 원본 이벤트까지 거슬러 올라갈 수 있습니다. 탐지 결과가 OCSF 형식이니 Security Hub처럼 OCSF 탐지 결과를 받는 시스템에 그대로 넘길 수도 있습니다.


도입할 때 고민할 점

매핑은 결국 사람이 정해야 한다

OCSF가 속성 이름은 정해 주지만, 원본의 어떤 값을 어느 속성에 넣을지는 변환기를 만드는 사람이 정해야 합니다. 예를 들어 SSH의 Invalid user는 비밀번호가 틀린 것과 계정이 없는 것을 구분해 주는 중요한 정보인데, status_id에는 둘 다 2 Failure로 들어갑니다. 그래서 이번 변환기는 이 차이를 status_detail에 따로 남겼습니다. 이런 결정을 소스마다 문서로 남겨 두지 않으면, 같은 OCSF 속성이라도 소스마다 의미가 조금씩 달라집니다. 저장소 README에도 소스별 매핑 표를 따로 정리해 두었습니다.

실제 로그에는 예상하지 못한 형태가 있다

앞에서 본 HIDDEN_DUE_TO_SECURITY_REASONS처럼, 문서의 예제만 보고 만든 변환기는 실제 로그에서 빈틈을 드러냅니다. flaws.cloud 로그에서도 IAM 역할(AssumedRole)로 콘솔에 로그인한 5건은 userName 필드가 없어서, 지금 변환기로는 user.name이 비어 나옵니다. 검증은 통과하지만 사용자 이름으로 검색하면 이 로그인이 나오지 않습니다. 변환 결과가 스키마 검증을 통과했다고 해서 매핑이 완전하다는 뜻은 아닙니다.

원본은 버리지 않는다

raw_data에 원본을 남기고, 매핑하지 못한 값은 unmapped에 넣어 두는 편이 안전합니다. 지금 쓰지 않는 값이 나중에 사고를 조사할 때 필요해질 수 있고, 매핑 규칙에 빈틈이 있었을 때 원본으로 다시 변환할 수도 있습니다.

스키마 버전을 기록하고, 그 버전으로 검증한다

OCSF는 몇 달 간격으로 새 버전이 나옵니다. metadata.version에 어떤 버전 기준으로 변환했는지 남겨 두어야, 나중에 스키마가 바뀌었을 때 어떤 이벤트를 다시 변환해야 하는지 알 수 있습니다. 검증할 때도 앞에서 본 것처럼 그 버전의 API를 호출해야 합니다.

검증은 표본으로 한다

모든 이벤트를 외부 API로 검증하면 느리고, API 가용성에도 영향을 받습니다. OCSF 공식 Go 라이브러리인 ocsf-toolkit도 운영 환경에서는 모든 이벤트를 검증하지 말고 표본을 골라 검증하라고 권장합니다. 변환 규칙을 바꿀 때 CI에서 대표 로그를 검증하고, 운영 중에는 일부만 검증하는 방식이 현실적입니다.


마치며

OCSF는 보안 이벤트를 카테고리, 클래스, 활동의 세 단계로 분류하고, 속성 이름과 값의 의미를 정해 둔 공개 스키마입니다. 로그인 이벤트라면 어느 제품에서 왔든 Authentication(3002) 클래스로 표현하고, 출발지 IP는 src_endpoint.ip, 실패는 status_id: 2로 같아집니다.

직접 변환기를 만들어 보면서 확인한 내용을 정리하면 다음과 같습니다.

  1. 필수 속성만 채워도 검증에 실패할 수 있습니다. Authentication 클래스는 service나 dst_endpoint 중 하나를 요구하는 제약 조건이 따로 있습니다.
  2. type_uid는 class_uid × 100 + activity_id로 계산하며, 어긋나면 검증 API가 바로 잡아냅니다. 검증은 이벤트가 선언한 스키마 버전의 API로 해야 합니다.
  3. 소스가 달라도 OCSF로 바꾼 뒤에는 탐지 규칙 하나로 충분합니다. 실제 SSH 로그 5건과 CloudTrail 2건이 한 공격자의 시도로 묶였고, 뒤이은 로그인 성공까지 잡아냈습니다.
  4. 실제 로그는 예제보다 다양합니다. flaws.cloud의 CloudTrail 로그를 돌려 보고 나서야 예제에 없던 형태를 발견했습니다.

전체 코드와 실행 결과는 GitHubgunh0/ocsf-auth-normalizer에서 확인할 수 있습니다. make demo와 make verify-cloudtrail로 이 글의 실험을 그대로 재현할 수 있습니다.


참고 자료

이 글의 코드

  • GitHubgunh0/ocsf-auth-normalizer: 변환기, Docker 데모, CloudTrail 검증 스크립트
  • demo/sample/: 글에 인용한 데모 실행 결과 (원본 auth.log, 변환 결과, 탐지 결과, 검증 결과)

OCSF

AWS