Hamutaro

안녕하세요

저희 학원 윗층에서 철거 공사 진행중인데 정말 시끄럽고 쿵쿵거리네요..

아직 상태가 멀쩡한게 아니라서 살짝 어지럽습니다..

그럼 레츠고

 


 

오늘의 수업

교시내용

2교시 서버가 거절한 요청 중 반복된 것 찾고 이유로 순서 정하기
3교시 SaaS 로그에서 이상 사용 세 가지 찾기
4교시 웹 로그에서 경로를 스캔한 IP 찾기
5교시 서로 다른 로그를 한 사용자로 연결해 분석하기
6교시 위험 점수를 매겨 등급과 이유 저장하기

서버가 거절한 요청 중 반복된 것 찾고 이유로 순서 정하기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「11/2 에 판정 서버가 요청을 거절한 기록이 9건 있습니다. 판정 서버는 사용자가 누른 기능을 허용할지 정하는 서버입니다. 로그에는 실패 라고만 적혀 있어서 왜 거절했는지 알 수 없고, 9건을 하나씩 열어 볼 시간도 없습니다.

같은 사람이 여러 번 거절당한 기록부터 확인하고 싶습니다. 3과목(접근통제)에서 만든 권한 규칙으로 거절 이유도 붙여 주세요. 퇴사한 사람의 계정이면 가장 먼저 알려 주세요. 거절이 전부 문제는 아니니 공격이라고 단정하지는 말아 주세요.」

왜 중요한가

거절 이유를 알아야 퇴사한 계정을 정리할지, 서버 설정을 확인할지 정할 수 있습니다.


왜 필요한가

판정 서버는 요청을 거절할 때마다 실패로 기록합니다.

어제 오전에는 웹 서버의 로그인 실패를 확인했습니다.

오늘은 판정 서버 로그의 거절 기록을 확인합니다.

오늘 먼저 확인할 거절은 한 사용자가 3번 이상 거절당한 기록입니다.

질문은 이것 하나입니다.

3과목(접근통제)의 권한 규칙(check_permission)으로 같은 요청을 다시 판정하면 거절 이유가 나옵니다.

이유에 따라 할 일이 다릅니다.

거절 이유뜻할 일순서

퇴사한 계정 퇴사했는데 계정이 남아 있음 계정 정리 1
권한 없음 그 역할에 허용되지 않은 행동을 요청함 요청한 이유 확인 2
허용 규칙으로는 허용인데 서버가 거절함 서버 설정 확인(공격이라고 확정할 수 없음) 3

2.1 먼저 확인할 거절

판정 서버 로그의 실패 9개입니다.

먼저 확인할 질문은 하나입니다.

질문「예」가 되는 때

그 사용자의 실패가 3번 이상인가? 그날 그 사용자의 판정 서버 실패가 3번 이상입니다(REPEAT_LIMIT = 3). 행동의 종류와 상관없이 실패 횟수만 셉니다.

「예」이면 먼저 확인합니다.

「아니오」이면 이번에는 뺍니다.

시각사용자행동그 사용자의 실패 횟수먼저 확인

10:05 gaeun ACCOUNT 3번 예
10:09 gaeun ACCOUNT 3번 예
10:14 gaeun ACCOUNT 3번 예
11:02 dami SALES 4번 예
11:03 dami SALES 4번 예
11:05 dami SALES 4번 예
11:08 dami SALES 4번 예
11:40 seoyeon SETTLE 2번 아니오
18:20 seoyeon SALES 2번 아니오

먼저 확인할 기록은 7개(gaeun 3 + dami 4)이고, seoyeon 의 2건만 빠집니다.

7개를 3과목(접근통제) 규칙으로 판정하면 dami 는 「퇴사한 계정」으로 1순위, gaeun 은 「허용」으로 3순위입니다.

이 판정은 거절이 규칙대로였는지 확인하는 것이고, 공격이라고 확정하는 것이 아닙니다.


🐼 판다스 문법 상자 — 목록에 있는 사용자인지 isin

열.isin(리스트)는 열의 값이 리스트 안에 있으면 True, 없으면 False를 줍니다.

아래는 로그인 기록입니다.

퇴사한 사용자를 차단 목록(blocked_users)에 적어 두었습니다.

이 목록에 있는 사용자의 기록만 찾아봅니다.

import pandas as pd

data = {
    "user": ["gaeun", "dami", "seoyeon", "junho", "dami"],
    "result": ["성공", "실패", "성공", "실패", "실패"],
}

df = pd.DataFrame(data)

print(df)

실행 결과:

      user result
0    gaeun     성공
1     dami     실패
2  seoyeon     성공
3    junho     실패
4     dami     실패

위 코드에 이어서 실행합니다.

blocked_users = ["dami", "junho"]

is_blocked = df["user"].isin(blocked_users)

print(is_blocked)

실행 결과:

0    False
1     True
2    False
3     True
4     True
Name: user, dtype: bool

다음 코드입니다.

blocked_df = df[is_blocked]

print(blocked_df)

실행 결과:

    user result
1   dami     실패
3  junho     실패
4   dami     실패

읽는 법:

첫 번째 출력은 입력 표입니다.

두 번째 출력에서 1번 행(dami) · 3번 행(junho) · 4번 행(dami)이 True입니다.

세 번째 출력은 그 세 기록만 남은 표입니다.

Name: user, dtype: bool 줄은 몰라도 됩니다.


🐼 판다스 문법 상자 — 센 결과에서 이름만 꺼내기 .index

센 결과 .index는 value_counts()로 센 결과에서 왼쪽에 나오는 이름들(인덱스)을 꺼냅니다.

사용자별로 센 결과라면 사용자 이름이 나옵니다.

import pandas as pd

data = {
    "user": ["dami", "gaeun", "dami", "seoyeon", "dami", "gaeun"],
    "result": ["실패", "실패", "실패", "실패", "실패", "실패"],
}

df = pd.DataFrame(data)

print(df)

실행 결과:

      user result
0     dami     실패
1    gaeun     실패
2     dami     실패
3  seoyeon     실패
4     dami     실패
5    gaeun     실패

사용자별 실패 횟수를 셉니다.

counts = df["user"].value_counts()

print(counts)

실행 결과:

user
dami       3
gaeun      2
seoyeon    1
Name: count, dtype: int64

2번 이상 실패한 사용자 이름만 꺼냅니다.

many = counts[counts >= 2]

names = list(many.index)

print(names)

실행 결과:

['dami', 'gaeun']

읽는 법:

첫 번째 출력은 입력 표입니다.

두 번째 출력은 사용자별 실패 횟수입니다.

dami 3번
gaeun 2번
seoyeon 1번

세 번째 출력은 2번 이상 실패한 사용자 이름만 담은 리스트입니다.


한 장 정리

함수받는 것리턴하는 것

load_events 파일 경로 이벤트 표
pick_server_failures 이벤트 표 판정 서버 실패 이벤트 표
pick_suspicious 서버 실패 표 실패가 반복된 사용자의 이벤트 표
judge_failure 사용자 · 행동 판정 이유와 우선순위

pick_suspicious는 반복된 사용자의 이름을 꺼내고, isin으로 그 사용자의 행을 골라 이벤트 표를 만듭니다.

judge_failure는 행동을 자원과 행위로 나누고, 3과목(접근통제) check_permission으로 판정하고, 이유에서 우선순위를 꺼냅니다.


2교시 한눈에

하려는 일코드 · 말

센 결과의 왼쪽 이름 repeated_names = repeated_users.index
리스트 안에 있는지 is_repeated_user = users.isin(repeated_names)
3과목 규칙으로 판정 is_allowed, reason = check_permission(user, resource, permission_action)
순서 priority = PRIORITY.get(reason, 4)

SaaS 로그에서 이상 사용 세 가지 찾기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「우리 회사는 파일 보관 서비스(SaaS)를 씁니다. 이 서비스는 회사 밖 어디서나 로그인할 수 있습니다. 11/2 에는 02:14 에 새벽 로그인 시도가 있었고, 17:38 에는 seoyeon 이 평소와 다른 IP 로 로그인한 뒤 파일을 5번 연속으로 내려받았습니다.

다음 세 가지를 각각 찾아서 어느 사용자가 해당하는지 알려 주세요.

① 업무 시간 밖 로그인
② 평소와 다른 IP 로 로그인
③ 파일 5번 이상 내려받기

평소 기록이 없는 사용자는 IP 를 비교할 수 없으니 IP 확인에서는 빼 주세요.」

왜 중요한가

SaaS 는 회사 밖에서도 로그인할 수 있어서 로그인에 성공했다는 것만으로는 본인인지 알 수 없고, 세 규칙을 따로 확인하면 담당자가 확인할 대상을 구분할 수 있습니다.


왜 필요한가

어제 2교시에서 평소 기준을 정했습니다.

오늘은 사용자마다 평소가 다르므로 사용자별 평소 IP를 딕셔너리로 둡니다.

SaaS 이상 사용을 보는 규칙은 세 가지이고, 세 규칙은 각각 따로 찾습니다.

업무 시간 밖 로그인

로그인한 시각의 시(hour)가 8보다 작거나 20보다 큰 로그인 시도입니다.

오전 8시 전, 오후 9시 이후입니다.

20시대(20:00 ~ 20:59)는 업무 시간 안입니다.

평소와 다른 IP

사용자의 평소 IP와 다른 IP로 로그인했습니다.

대량 다운로드

파일을 5번 이상 내려받았습니다.

평소 IP 기록이 없는 사용자(gaeun)는 비교할 기준이 없으므로 「평소와 다른 IP」 규칙에서만 뺍니다.

다른 두 규칙에는 그대로 포함합니다.


3.1 SaaS 로그와 세 규칙

샘플에서 source가 saas인 이벤트 14개입니다.

시각사용자IP행동결과해당하는 규칙

02:14 jiwoo 203.0.113.80 login 실패 업무 시간 밖 · 평소와 다른 IP
09:05 seoyeon 198.51.100.9 login 성공 해당 없음
09:30 junho 192.0.2.41 login 성공 해당 없음
10:12 gaeun 198.51.100.77 login 성공 평소 기록이 없어 IP 규칙에서 제외
11:25 seoyeon 198.51.100.9 file.download 성공 다운로드 1번
13:50 junho 192.0.2.41 file.upload 성공 해당 없음
15:20 junho 192.0.2.41 login 성공 해당 없음
16:02 jiwoo 198.51.100.52 login 성공 해당 없음
17:38 seoyeon 203.0.113.80 login 성공 평소와 다른 IP
17:40 ~ 17:42 seoyeon 203.0.113.80 file.download 5번 성공 다운로드 합계 6번(대량)

파일 내려받기는 11:25 에 1번, 17:40 ~ 17:42 에 5번이라 합계 6번입니다.

팀장 메시지의 「5번 연속」은 17:40 ~ 17:42 에 기록된 다운로드 5건을 말합니다.

USUAL_IPS에 평소 IP를 둔 사용자는 3명입니다.

seoyeon · junho · jiwoo

규칙에 해당한다고 계정이 도용됐다는 뜻은 아니고, 먼저 확인할 대상이라는 뜻입니다.


pd.to_datetime과 .dt.hour

pd.to_datetime(열)로 문자열 시각을 시각 값으로 바꾼 뒤 .dt.hour를 붙이면 시(0 ~ 23)만 숫자로 꺼냅니다.

import pandas as pd

data = {
    "user": ["jiwoo", "seoyeon", "junho"],
    "time": [
        "2026-11-02 02:14:08",
        "2026-11-02 09:05:30",
        "2026-11-02 21:40:10",
    ],
}

df = pd.DataFrame(data)

print(df)

실행 결과:

      user                 time
0    jiwoo  2026-11-02 02:14:08
1  seoyeon  2026-11-02 09:05:30
2    junho  2026-11-02 21:40:10

시각 값으로 바꿉니다.

times = pd.to_datetime(df["time"])

print(times)

실행 결과:

0   2026-11-02 02:14:08
1   2026-11-02 09:05:30
2   2026-11-02 21:40:10
Name: time, dtype: datetime64[ns]

시만 꺼냅니다.

hours = times.dt.hour

print(hours)

실행 결과:

0     2
1     9
2    21
Name: time, dtype: int32

읽는 법:

02:14 → 2
09:05 → 9
21:40 → 21

🐼 판다스 문법 상자 — 범위 안인지 between

열.between(작은 값, 큰 값)은 열의 값이 작은 값 이상이고 큰 값 이하이면 True, 아니면 False를 줍니다.

작은 값과 큰 값 자체도 포함합니다.

import pandas as pd

data = {
    "user": ["gaeun", "dami", "seoyeon", "junho"],
    "hour": [9, 2, 20, 21],
}

df = pd.DataFrame(data)

print(df)

실행 결과:

      user  hour
0    gaeun     9
1     dami     2
2  seoyeon    20
3    junho    21

8시부터 20시 사이인지 확인합니다.

in_hours = df["hour"].between(8, 20)

print(in_hours)

실행 결과:

0     True
1    False
2     True
3    False
Name: hour, dtype: bool

읽는 법:

9시와 20시는 True, 2시와 21시는 False입니다.

20시도 True인 것은 8과 20 자체도 사이에 포함하기 때문입니다.


🐼 판다스 문법 상자 — 조건 뒤집기 ~

~조건은 True는 False로, False는 True로 뒤집습니다.

in_hours = df["hour"].between(8, 20)

print(in_hours)
0     True
1    False
2     True
3    False
Name: hour, dtype: bool

업무 시간 밖인지 확인합니다.

off_hours = ~in_hours

print(off_hours)

실행 결과:

0    False
1     True
2    False
3     True
Name: hour, dtype: bool

두 출력이 서로 반대입니다.

업무 시간 안이던 0 · 2번 행은 False가 되고, 밖이던 1 · 3번 행은 True가 됩니다.


🐍 문법 상자 — 다른지 !=와 값이 있는지 is not None

a != b는 두 값이 다르면 True, 같으면 False입니다.

값 is not None은 값이 None이 아니면 True입니다.

usual_ip = "198.51.100.9"

ip = "203.0.113.80"

is_new_ip = ip != usual_ip

print(is_new_ip)

실행 결과:

True

평소 기록이 없는 경우입니다.

no_record = None

has_record = no_record is not None

print(has_record)

실행 결과:

False

평소 IP와 이번 IP가 달라서 True이고, 값이 None이라 「값이 있는가」를 묻는 결과는 False입니다.

None은 어떤 IP와도 같지 않아서

ip != None

은 항상 True입니다.

그래서 평소 기록이 없는 사용자는 is not None으로 먼저 제외합니다.


한 장 정리

함수받는 것리턴하는 것

load_events · pick_saas · pick_logins 파일 경로 · 이벤트 표 이벤트 표 · SaaS 표 · 로그인 표
find_off_hours_logins 로그인 표 업무 시간 밖 로그인 표
find_new_ip_users 로그인 표 평소와 다른 IP로 로그인한 사용자 리스트
find_bulk_download_users SaaS 표 대량 다운로드 사용자 리스트

3교시 한눈에

하려는 일코드 · 말

범위 안인지 is_business_hour = hours.between(BUSINESS_START, BUSINESS_END)
조건 뒤집기 is_off_hours = ~is_business_hour
평소 기록이 있는지 has_usual_ip = usual_ip is not None
평소 IP와 다른지 is_new_ip = ip != usual_ip

웹 로그에서 경로를 스캔한 IP 찾기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「웹 서버(우리 서비스 화면을 보여 주는 서버)의 11/2 실패 기록이 13건 있습니다. 이 중에 /.env · /phpmyadmin 처럼 일반 사용자는 열 일이 없는 경로를 요청한 IP가 있습니다. 해킹 도구가 접근할 수 있는 곳이 있는지 경로를 차례로 요청해 본 것 같습니다.

그런데 사용자가 경로를 잘못 입력하거나 없는 게시글을 요청해도 똑같이 실패로 기록됩니다. 해킹 도구가 자주 요청하는 경로를 요청한 IP만 가려 주세요.」

왜 중요한가

실패를 모두 알림으로 보내면 알림이 너무 많아져서, 담당자가 진짜 경로 스캔을 놓칠 수 있습니다.


왜 필요한가

웹 서버는 요청이 처리되지 않으면 실패로 기록합니다.

없는 경로를 요청하면 404라는 HTTP 상태 코드가 남습니다.

해킹 도구는 접근할 수 있는 곳을 찾으려고 정해진 경로를 차례로 요청합니다.

자주 요청하는 경로는 이렇습니다.

/.env
/wp-login.php
/phpmyadmin

/.env

비밀번호와 키가 든 설정 파일.

/wp-login.php

워드프레스 로그인 화면.

/phpmyadmin

데이터베이스 관리 화면.

오늘 질문은 하나입니다.

실패한 요청 중에 이 경로를 요청한 IP가 있는가?

이 경로 중 하나를 요청했으면 오늘 기준에서는 스캔으로 봅니다.

공격으로 확정하는 것은 아닙니다.


4.1 웹 실패 로그에서 스캔 찾기

샘플에서 source가 web이고 result가 실패인 이벤트는 13개입니다.

IP실패한 요청스캐너 경로를 요청했나오늘 규칙 결과

198.51.100.21 GET /board/99 아니오 제외
198.51.100.34 GET /report 아니오 제외
198.51.100.77 GET /admin 아니오 제외
198.51.100.90 GET /.env · GET /wp-login.php · GET /phpmyadmin 예 스캔
203.0.113.50 POST /login 5번 · GET /admin · GET /admin/users 아니오 제외

SCANNER_PATTERN은

/.env|/wp-login|/phpmyadmin

입니다.

따옴표 안의 |는 「또는」이라는 뜻이라, 셋 중 하나가 들어 있으면 해당합니다.

조건끼리 합치는 기호가 아니라, 문자열 안에 쓴 글자일 뿐입니다.

203.0.113.50은 실패가 7번이지만 로그인과 관리자 화면 실패라서 스캐너 경로가 없습니다.

이 IP는 로그인 실패 규칙에서 따로 다룹니다.


🐼 판다스 문법 상자 — 문자열이 들어 있는지 str.contains

열.str.contains("문자열")은 열의 값 안에 그 문자열이 들어 있으면 True, 없으면 False를 줍니다.

.str은 열의 각 값을 문자열로 다루겠다는 표시입니다.

문자열 안의 |는 「또는」이라는 뜻입니다.

import pandas as pd

data = {
    "ip": [
        "198.51.100.90",
        "192.0.2.15",
        "198.51.100.90",
        "203.0.113.50",
    ],
    "path": [
        "/.env",
        "/board",
        "/phpmyadmin",
        "/board/12",
    ],
}

df = pd.DataFrame(data)

print(df)

실행 결과:

              ip         path
0  198.51.100.90        /.env
1     192.0.2.15       /board
2  198.51.100.90  /phpmyadmin
3   203.0.113.50    /board/12

스캐너 경로인지 확인합니다.

is_scanner = df["path"].str.contains(
    "/.env|/phpmyadmin"
)

print(is_scanner)

실행 결과:

0     True
1    False
2     True
3    False
Name: path, dtype: bool

조건에 맞는 행만 남깁니다.

scanner_df = df[is_scanner]

print(scanner_df)

실행 결과:

              ip         path
0  198.51.100.90        /.env
2  198.51.100.90  /phpmyadmin

읽는 법:

/.env가 든 0번 행과 /phpmyadmin이 든 2번 행이 True입니다.

세 번째 출력은 그 두 행만 남은 표입니다.

열의 문자열 기능은 .str.을 먼저 쓰고 부릅니다.


한 장 정리

함수받는 것리턴하는 것

load_events · pick_web_failures 파일 경로 · 이벤트 표 이벤트 표 · 웹 실패 표
find_scan_ips 웹 실패 표 스캔 IP 리스트와 IP별 요청 횟수
explain_scan IP · 요청 횟수 설명 딕셔너리

4교시 한눈에

하려는 일코드 · 말

문자열이 들어 있는지 is_scanner = actions.str.contains(SCANNER_PATTERN)
조건에 맞는 행만 scanner_rows = web_failures[is_scanner]
IP별 개수 scan_counts = scanner_rows["ip"].value_counts()
문자열 연결 reason = "스캐너 경로를 " + str(request_count) + "번 요청"

서로 다른 로그를 한 사용자로 연결해 분석하기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「오전에 서버 · SaaS · 웹 로그를 각각 따로 탐지했더니 결과 목록이 세 개가 됐습니다. 목록을 번갈아 보다가 같은 사람 이름이 서버 결과에도, SaaS 결과에도 나오는 것을 봤습니다. 같은 사용자가 짧은 시간 안에 여러 로그에서 이상 징후를 보였다면 하나의 사건일 수 있습니다.

같은 사용자에게 서로 다른 로그의 이벤트가 짧은 시간 안에 모여 있으면 하나의 사건으로 연결해서 알려 주세요. 웹 로그에는 사용자 이름이 없고 IP만 있으니, SaaS에 로그인한 기록에서 IP로 사용자를 찾아 연결해 주세요.」

왜 중요한가

로그 하나에서는 작은 이상 징후도 같은 사용자에게 짧은 시간 안에 여러 로그에서 겹치면 먼저 확인할 이유가 분명하지만, 아침과 저녁처럼 시간이 멀리 떨어진 기록까지 한 사건으로 보면 안 됩니다.


왜 필요한가

서버 로그와 SaaS 로그에는 사용자(user)가 있고, 웹 로그에는 IP만 있습니다.

SaaS의 성공한 로그인은 IP와 사용자가 함께 있으므로

IP → 사용자

딕셔너리를 만들어 웹 로그의 IP로 사용자를 찾습니다.

세 로그를 표 하나로 합쳐 사용자 순, 같은 사용자는 시각 순으로 놓으면 사용자별 이벤트를 시각 순서로 확인할 수 있습니다.

연결됐다고 보는 조건은 둘입니다.

  1. 서로 다른 로그(source)가 2종류 이상이다.
  2. 그 사용자의 첫 이벤트부터 마지막 이벤트까지가 60분 이내이다.

하나만 맞으면 연결된 것으로 보지 않습니다.


5.1 IP로 사용자 채우기 · 표 잇기 · 두 열로 정렬하기

SaaS의 성공한 로그인에서 만든 「IP → 사용자」 딕셔너리는 이렇게 생깁니다.

IP사용자만들어진 곳

198.51.100.9 seoyeon 09:05 성공한 로그인
192.0.2.41 junho 09:30 성공한 로그인
198.51.100.77 gaeun 10:12 성공한 로그인
198.51.100.52 jiwoo 16:02 성공한 로그인
203.0.113.80 seoyeon 17:38 성공한 로그인

딕셔너리에는 성공한 로그인만 넣습니다.

02:14 에 jiwoo가 203.0.113.80으로 시도한 로그인은 실패였으므로 넣지 않고, 같은 IP로 성공한 17:38 의 seoyeon이 이 IP의 사용자로 기록됩니다.

서버 로그는 실패 이벤트 전체 9개를 씁니다.

먼저 확인할 이벤트만 고르면 어떤 사용자가 서버 로그에 나오는지가 오전 결과에 따라 달라지기 때문입니다.

웹 실패 이벤트의 IP가 위 표에 있으면 그 사용자의 이벤트로 연결해 분석할 수 있고, 딕셔너리에 IP가 없으면 사용자를 확인할 수 없어 이번 연결에서 제외합니다.


🐼 판다스 문법 상자 — 딕셔너리로 값을 바꾸기 map

열.map(딕셔너리)은 열의 값을 딕셔너리에서 찾아, 짝지어진 값으로 바꿉니다.

딕셔너리에 없는 값은 NaN이 됩니다.

import pandas as pd

data = {
    "ip": [
        "203.0.113.80",
        "198.51.100.77",
        "192.0.2.15",
    ],
    "action": [
        "GET /admin",
        "GET /admin",
        "GET /board",
    ],
}

df = pd.DataFrame(data)

print(df)

실행 결과:

              ip      action
0   203.0.113.80  GET /admin
1  198.51.100.77  GET /admin
2     192.0.2.15  GET /board

IP → 사용자 딕셔너리입니다.

ip_user = {
    "203.0.113.80": "seoyeon",
    "198.51.100.77": "gaeun",
}

users = df["ip"].map(ip_user)

print(users)

실행 결과:

0    seoyeon
1      gaeun
2        NaN
Name: ip, dtype: object

앞의 두 IP는 딕셔너리에 있어서 seoyeon과 gaeun이 되고, 세 번째 IP는 딕셔너리에 없어서 NaN입니다.


🐼 판다스 문법 상자 — 표를 위아래로 이어 붙이기 pd.concat

pd.concat([표1, 표2, …])는 열 이름이 같은 표 여러 개를 위아래로 이어 붙여 표 하나로 만듭니다.

import pandas as pd

server_data = {
    "user": ["gaeun", "gaeun"],
    "action": [
        "ACCOUNT:CHANGE",
        "ACCOUNT:CHANGE",
    ],
}

server_logs = pd.DataFrame(server_data)

print(server_logs)

실행 결과:

    user          action
0  gaeun  ACCOUNT:CHANGE
1  gaeun  ACCOUNT:CHANGE

SaaS 표입니다.

saas_data = {
    "user": ["gaeun"],
    "action": ["login"],
}

saas_logs = pd.DataFrame(saas_data)

print(saas_logs)

실행 결과:

    user action
0  gaeun  login

두 표를 잇습니다.

both = pd.concat(
    [server_logs, saas_logs]
)

print(both)

실행 결과:

    user          action
0  gaeun  ACCOUNT:CHANGE
1  gaeun  ACCOUNT:CHANGE
0  gaeun           login

판정 서버 2행 아래에 SaaS 1행이 붙어 3행이 되었습니다.

왼쪽 번호는 원래 표의 번호를 그대로 가져와서 0이 두 번 나옵니다.

표를 대괄호 안에 넣지 않고 쉼표로 따로 전달하면 오류가 납니다.


🐼 판다스 문법 상자 — 열 두 개로 정렬하기

표.sort_values(["열1", "열2"])는 열1 순으로 정렬하고, 열1의 값이 같은 행은 열2 순으로 정렬합니다.

import pandas as pd

data = {
    "user": [
        "seoyeon",
        "gaeun",
        "seoyeon",
        "gaeun",
    ],
    "time": [
        "17:38",
        "10:14",
        "09:05",
        "10:05",
    ],
}

df = pd.DataFrame(data)

print(df)

실행 결과:

      user   time
0  seoyeon  17:38
1    gaeun  10:14
2  seoyeon  09:05
3    gaeun  10:05

정렬합니다.

sorted_df = df.sort_values(
    ["user", "time"]
)

print(sorted_df)

실행 결과:

      user   time
3    gaeun  10:05
1    gaeun  10:14
2  seoyeon  09:05
0  seoyeon  17:38

먼저 사용자 순

gaeun → seoyeon

으로 놓이고,

같은 사용자 안에서는 시각 순

10:05 → 10:14
09:05 → 17:38

으로 놓였습니다.


연결 조건

시각은

hours * 60 + minutes

로 「자정부터 몇 분이 지났는지」로 바꿉니다.

예:

09:33 → 573

사용자별로 가장 늦은 시각에서 가장 이른 시각을 빼면 첫 이벤트와 마지막 이벤트의 시간 차가 나옵니다.

이 값을 60분 기준과 비교합니다.

times.dt.minute는 시각 값에서 분(0 ~ 59)만 꺼냅니다.

grouped["source"].nunique()는 사용자별로 source 값이 몇 종류인지 셉니다.

연결 조건:

서로 다른 source 2종류 이상
+
첫 이벤트부터 마지막 이벤트까지 60분 이내

한 장 정리

함수받는 것리턴하는 것

make_ip_user_map SaaS 로그인 표 IP → 사용자 딕셔너리
attach_user_to_web 웹 실패 표 · 딕셔너리 user 열이 붙은 웹 이벤트 표
build_timeline 서버 · SaaS · 웹 표 사용자 · 시각 순으로 정렬된 표
find_chains 정렬된 표 연결된 사건 리스트와 연결되지 않은 사용자
find_chains_in_events 이벤트 표 연결된 사건 리스트

5교시 한눈에

하려는 일코드 · 말

IP로 사용자 채우기 users = web_part["ip"].map(ip_user)
표 위아래로 잇기 timeline = pd.concat([server_part, saas_part, web_part])
열 두 개로 정렬 timeline = timeline.sort_values(["user", "time"])
이유 문장 만들기 "서로 다른 로그 " + str(source_count) + "종류 · " + str(span) + "분 안"

위험 점수를 매겨 등급과 이유 저장하기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「오전과 5교시에서 탐지된 규칙이 여러 개인데, 결과가 모두 탐지됨 이라고만 나와서 어느 대상부터 확인할지 정할 수 없습니다. 퇴사한 계정이 계속 시도한 것과 파일을 한 번 더 내려받은 것이 같은 무게일 수는 없습니다.

탐지된 규칙마다 점수를 정하고 대상(사용자 또는 IP)마다 더해서 high · mid · low 로 나눠 주세요. 담당자가 왜 그 등급인지 알 수 있게, 탐지된 규칙 이름도 같이 파일에 적어 주세요.」

왜 중요한가

점수만 있고 이유가 없으면 담당자가 근거를 확인하기 어려워서, 탐지된 규칙 이름을 이유로 함께 저장합니다.


왜 필요한가

규칙마다 점수를 주고, 대상(사용자 또는 IP)마다 탐지된 규칙의 점수를 더합니다.

같은 규칙이 한 대상의 여러 이벤트에서 탐지되어도 그 규칙의 점수는 대상마다 한 번만 더합니다.

합계를

high · mid · low

로 나누고, 탐지된 규칙 이름을 함께 저장하면 담당자가 점수가 나온 근거를 확인할 수 있습니다.


6.1 규칙마다 점수와 등급 기준

탐지된 규칙점수어느 단계에서 탐지되나

퇴사한 계정 60 오전 2교시
권한 없음 20 오전 2교시
규칙은 허용인데 서버가 거절 10 오전 2교시
업무 시간 밖 로그인 15 오전 3교시
평소와 다른 IP 20 오전 3교시
대량 다운로드 25 오전 3교시
경로 스캔 30 오전 4교시
여러 로그 연결 30 5교시

등급 기준은 다음과 같습니다.

60 이상         → high
35 이상 60 미만 → mid
35 미만         → low

「이상」은 >=입니다.

높은 기준(high)부터 먼저 검사합니다.

등급은 위험이 없다는 판정이 아니라 확인할 순서를 정하는 기준입니다.

서버 거절은 판정 이유 단어

퇴사한 계정
권한 없음
허용

를 그대로 이름으로 쓰고,

허용은

규칙은 허용인데 서버가 거절

이라는 이름으로 바꿔 씁니다.

대상은 사용자(seoyeon · dami …)와 IP(경로 스캔)입니다.

코드의 entity는 점수를 매길 대상입니다.

이 숫자들은 오늘 정해 고정한 값이고, 현업에서는 조직이 정합니다.


🐼 판다스 문법 상자 — 그룹마다 합계 sum

그룹["열"].sum()은 groupby로 만든 그룹마다 그 열의 숫자를 모두 더합니다.

import pandas as pd

data = {
    "entity": [
        "seoyeon",
        "seoyeon",
        "dami",
    ],
    "points": [
        20,
        25,
        60,
    ],
}

df = pd.DataFrame(data)

print(df)

실행 결과:

    entity  points
0  seoyeon      20
1  seoyeon      25
2     dami      60

대상별로 점수를 더합니다.

grouped = df.groupby("entity")

totals = grouped["points"].sum()

print(totals)

실행 결과:

entity
dami       60
seoyeon    45
Name: points, dtype: int64

읽는 법:

seoyeon은

20 + 25 = 45

dami는

60

입니다.

groupby 결과는 이름순(dami → seoyeon)으로 나옵니다.

문제에서는 점수가 높은 순으로 다시 정렬합니다.


위험 점수 흐름

탐지된 규칙
↓
규칙마다 점수 붙이기
↓
같은 대상끼리 묶기
↓
점수 합계
↓
high · mid · low
↓
탐지된 규칙 이름을 이유로 저장

같은 규칙이 같은 대상에서 여러 번 탐지되어도 점수는 한 번만 더합니다.


등급 정하기

if score >= HIGH_LIMIT:
    return "high"

if score >= MID_LIMIT:
    return "mid"

return "low"

높은 기준부터 검사합니다.


기록 모양

기록에는

  • 대상
  • 점수
  • 등급
  • 이유

가 들어갑니다.

record = {
    "entity": entity,
    "score": int(score),
    "grade": grade,
    "reasons": reasons
}

점수만 저장하지 않고 탐지된 규칙 이름을 이유로 함께 남깁니다.


한 장 정리

함수받는 것리턴하는 것

collect_findings 이벤트 표 · 연결된 사건 탐지된 규칙 리스트
score_findings 탐지된 규칙 리스트 대상별 점수
make_grade 점수 "high" · "mid" · "low"
collect_reasons 탐지된 규칙 대상별 규칙 이름
save_records 기록 리스트 저장한 건수
make_records 대상별 점수 · 이유 점수 · 등급 · 이유 기록 리스트

6교시 한눈에

하려는 일코드 · 말

규칙마다 점수 채우기 points = findings_table["rule"].map(WEIGHTS)
대상마다 점수 더하기 totals = grouped["points"].sum()
등급 정하기 if score >= HIGH_LIMIT: return "high"
기록 만들기 {"entity": entity, "score": int(score), …}

 

 


 

요즘 아파서 따뜻한 물만 먹고 있는데요,, 그래서 그런가 너무 더워요

가뜩이나 니트입어서 정말 따뜻한데,, 이 강의실이 창문이 없어서 더 그런 것 같습ㄴㅣ다..

그럼 아됴..

'공부 > SKT ALEPH' 카테고리의 다른 글

이상탐지 자동화 - 3  (0) 2026.11.05
이상탐지 자동화 - 2  (0) 2026.11.03
이상탐지 자동화 - 1  (0) 2026.11.02
접근통제 자동화 - 6  (0) 2026.11.02
접근통제 자동화 - 5  (0) 2026.10.30

안녕하세요.. 어제 못올려서 부랴부랴 올립니다.

레츠고

 


 

오늘의 수업

교시내용

2교시 지난 7일로 평소 기준과 알림 기준 정하기
3교시 같은 주소의 로그인 실패가 알림 기준(5번) 이상인 곳 찾기
4교시 이벤트의 유형 정하기
5교시 요청이 갑자기 늘어난 분 찾기
6교시 알림을 한 파일로 모으기

지난 7일로 평소 기준과 알림 기준 정하기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「로그인 실패 알림을 만들려는데, 한 주소가 하루 동안 로그인에 몇 번 실패하면 알려야 할지 모르겠습니다. 너무 낮아도, 너무 높아도 곤란합니다.

지난 7일 동안 하루에 로그인에 가장 많이 실패한 주소가 몇 번 실패했는지 표를 드립니다. 하루 평균과 가장 많았던 날의 횟수를 구해서, 알림 기준 숫자를 하나 정해 주세요.」

왜 중요한가

「하루 로그인 실패가 몇 번 이상이면 평소보다 많다고 볼 것인가」는 평소를 모르면 정할 수 없습니다.

평소 기준(baseline, 베이스라인)을 먼저 구해 두면 기준 숫자에 근거가 생기고, 담당자가 바뀌어도 같은 이유로 설명할 수 있습니다.


왜 필요한가

어제까지는 이벤트를 세기만 했습니다.

오늘부터는 세어 낸 숫자가 「많은가?」를 판단합니다.

판단하려면 비교할 평소가 있어야 합니다.

평소를 모르고 정한 기준에는 두 가지 문제가 생깁니다.

  • 너무 낮으면 평범한 날에도 알림이 발생해서, 담당자가 알림을 무시하게 됩니다.
  • 너무 높으면 누군가 비밀번호를 계속 바꿔 가며 로그인을 시도해도 알림이 나지 않습니다.

그래서 지난 며칠의 숫자(평소 기준)를 먼저 구하고, 거기서 알림 기준 숫자를 정합니다.


2.1 평소 기준과 알림 기준

말뜻오늘 예

평소 기준(baseline) 지난 7일에 실제로 나온 숫자들 하루마다 주소 하나가 가장 많이 실패한 횟수
임계값(threshold) 이 숫자 이상이면 알리기로 정한 숫자 5번
알림(alert) 임계값 이상일 때 담당자에게 먼저 확인해 달라고 보내는 통지 한 주소가 하루에 로그인에 5번 이상 실패함

지난 7일의 숫자는 이렇게 생겼습니다.

날짜하루 최다 로그인 실패 횟수

10/26 2
10/27 3
10/28 1
10/29 4
10/30 2
10/31 3
11/1 2

평균은 7개 숫자를 모두 더해 7로 나눈 값으로, 하루에 보통 몇 번인지 알려 줍니다.

최댓값은 7개 중 가장 큰 숫자로, 가장 많았던 날이 몇 번인지 알려 줍니다.

이 수업에서는 최댓값보다 1 큰 숫자를 알림 기준으로 삼습니다.

지난 7일의 최댓값은 4번이므로 알림 기준은 5번입니다.

5번은 지난 7일에 한 번도 없었던 횟수라서, 평소에는 알림이 발생하지 않습니다.

「5번 이상」은

5번 · 6번 · 7번 …

을 모두 포함합니다.

오늘 정한 기준은 이 규칙에서 나온 값 하나로 고정하고, 값을 바꿔 가며 비교하지 않습니다.


🐼 판다스 문법 상자 — CSV 파일을 표로 읽기

pd.read_csv(파일 이름)은 쉼표로 나뉜 CSV 파일을 열어 표로 만듭니다.

첫 줄은 헤더(열 이름)가 되고, 둘째 줄부터 한 줄이 한 행이 됩니다.

import pandas as pd

daily = pd.read_csv("baseline_logins.csv")

shape = daily.shape

print(shape)

실행 결과:

(7, 2)

(7, 2)는 7행 2열입니다.

하루가 7개이고, 열이 date · failed_logins 2개라는 뜻입니다.

JSON 파일이면 read_json, CSV 파일이면 read_csv를 씁니다.


🐼 판다스 문법 상자 — 열의 평균 구하기

열.mean()은 열에 있는 숫자들의 평균을 구합니다.

import pandas as pd

daily = pd.read_csv("baseline_logins.csv")

counts = daily["failed_logins"]

average = counts.mean()

rounded_average = round(average, 1)

print(rounded_average)

실행 결과:

2.4

합은

2 + 3 + 1 + 4 + 2 + 3 + 2 = 17

이고,

17 ÷ 7 = 2.428…

입니다.

round(average, 1)로 소수 첫째 자리까지 남기면 2.4가 됩니다.

2.4는 하루마다 가장 많이 실패한 주소의 실패 횟수 7개를 평균한 값입니다.


🐼 판다스 문법 상자 — 열의 최댓값 구하기

열.max()는 열에 있는 숫자 중 가장 큰 숫자를 구합니다.

import pandas as pd

daily = pd.read_csv("baseline_logins.csv")

counts = daily["failed_logins"]

maximum = counts.max()

print(maximum)

실행 결과:

4

지난 7일 중 가장 많았던 날이 4번이라는 뜻입니다.


한 장 정리

함수받는 것리턴하는 것

load_daily_failures 파일 경로 하루가 한 행인 표
average_failures 표 하루 평균 실패 횟수
maximum_failures 표 하루 최대 실패 횟수
make_limit 평소 최댓값 알림 기준(최댓값 + 1)

2교시 한눈에

하려는 일코드 · 말

CSV 파일을 표로 읽기 daily = pd.read_csv(path)
열 꺼내기 counts = daily["failed_logins"]
평균 counts.mean()
최댓값 counts.max()
알림 기준 limit = maximum + 1

같은 주소의 로그인 실패가 알림 기준 이상인 곳 찾기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「2교시에 정한 대로, 같은 주소가 하루에 로그인에 5번 이상 실패하면 담당자에게 알리기로 했습니다.

샘플 이벤트 57개 중에서 로그인 요청(POST /login)이 실패한 것이 모두 몇 개인지 세 주세요. 주소별로 몇 번씩 실패했는지 정리하고, 5번 이상 실패한 주소가 있으면 알려 주세요.」

왜 중요한가

로그인에 연달아 실패하는 것은 다른 사람이 비밀번호를 맞혀 보는 대표적인 모습입니다.

이벤트 수만 개를 사람이 하나씩 읽지 않고, 알림 기준(5번) 이상인 주소만 뽑아 보여 주면 담당자가 먼저 볼 곳이 정해집니다.


찾는 순서

① 로그인 요청이면서 실패한 것만 고른다
↓
② 접속 주소별로 센다
↓
③ 5번 이상인 주소만 남긴다

샘플 이벤트

203.0.113.50이 보낸 이벤트입니다.

시각actionresult로그인 요청인가실패인가둘 다인가

09:12:01 GET /login 성공 아니오 아니오 아니오
09:12:03 POST /login 실패 예 예 예
09:12:06 POST /login 실패 예 예 예
09:12:09 POST /login 실패 예 예 예
09:12:12 POST /login 실패 예 예 예
09:12:15 POST /login 실패 예 예 예
09:12:18 POST /login 성공 예 아니오 아니오
09:12:25 GET /admin 실패 아니오 예 아니오
09:12:27 GET /admin/users 실패 아니오 예 아니오

GET /login은 로그인 화면을 보여 달라는 요청이고,

POST /login은 아이디와 비밀번호를 보내는 요청입니다.

오늘은 POST /login이 실패한 이벤트만 셉니다.

SaaS의 login 실패 1건은 오늘 세지 않습니다.

그래서 로그인 실패 이벤트는 5개입니다.


🐍 문법 상자 — =와 ==

=는 오른쪽 값을 왼쪽 이름에 담습니다.

==는 두 값이 같은지 비교합니다.

age = 20

is_adult = age == 20

print(is_adult)

실행 결과:

True

is_fail = results == "실패"를

is_fail = results = "실패"

로 잘못 쓰면 is_fail이 문자열 "실패"가 됩니다.

비교는 항상 ==로 씁니다.


🐼 판다스 문법 상자 — 조건에 맞는 행만 걸러 내기

import pandas as pd

events = pd.read_json("normalized_events.json")

results = events["result"]

is_fail = results == "실패"

failed = events[is_fail]

print(failed.shape)

실행 결과:

(23, 6)

실패한 이벤트 23개만 남았다는 뜻입니다.


🐼 판다스 문법 상자 — 두 조건을 함께 걸기

「로그인 요청이면서 실패」처럼 조건이 둘이면 &로 합칩니다.

import pandas as pd

events = pd.read_json("normalized_events.json")

actions = events["action"]

is_login = actions == "POST /login"

results = events["result"]

is_fail = results == "실패"

both_true = is_login & is_fail

login_failures = events[both_true]

print(login_failures.shape)

실행 결과:

(5, 6)

로그인 요청이면서 실패한 이벤트가 5개라는 뜻입니다.

코드만들어지는 것

is_login = actions == "POST /login" 로그인 요청인 행만 True
is_fail = results == "실패" 실패인 행만 True
both_true = is_login & is_fail 둘 다 True인 행만 True
events[both_true] 조건을 만족하는 행만 남긴 표

표의 열끼리 이을 때는 and가 아니라 &를 씁니다.


🐼 판다스 문법 상자 — 기준 이상인 것만 남기기

counts = failed.groupby("ip").size()

is_many = counts >= 3

many = counts[is_many]

print(many)

예:

198.51.100.90    3
203.0.113.50     7

counts >= 3은 주소마다 「3번 이상인가?」를 물어 True 또는 False를 만듭니다.

문제에서는 로그인 요청의 실패만 세므로 203.0.113.50은 5번입니다.


한 장 정리

함수받는 것리턴하는 것

load_events 파일 경로 이벤트 표
pick_login_failures 이벤트 표 로그인 요청이면서 실패한 행
count_by_ip 로그인 실패 표 주소별 실패 횟수
pick_alert_ips 주소별 횟수 · 알림 기준 기준 이상인 주소와 횟수

3교시 한눈에

하려는 일코드 · 말

로그인 요청인지 actions == "POST /login"
실패인지 results == "실패"
두 조건 모두 is_login & is_fail
주소별 세기 login_failures.groupby("ip").size()
기준 이상 counts >= limit
알림 대상만 남기기 counts[counts >= limit]

이벤트의 유형 정하기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「알림이 와도 담당자는 어떤 유형의 일인지부터 알아야 다음에 할 일을 정합니다. 이벤트마다 유형을 분류하고, 유형별로 몇 개씩인지 보여 주세요.

오늘은 두 유형만 구분하면 됩니다. 하나는 로그인에 실패한 것, 다른 하나는 관리자 경로에 접근했다가 실패한 것입니다. 나머지 실패는 「기타 실패」, 성공은 「정상」으로 묶어 주세요.」

왜 중요한가

알림이 100개 와도 유형을 모르면 처음부터 하나씩 읽어야 합니다.

유형별 개수를 보면 비정상 로그인이 많은지, 비인가 접근이 많은지 보고 어디부터 볼지 정할 수 있습니다.


4.1 이벤트 유형과 오늘 쓰는 규칙

유형로그에서 보이는 모양언제

비정상 로그인 로그인 요청이 실패함 오늘
비인가 접근 관리자 경로(/admin)를 요청했다가 실패함 오늘
트래픽 급증 짧은 시간에 요청이 많이 들어옴 오후
SaaS 이상 사용 평소와 다른 서비스 사용 11/5
악성 행위 징후 없는 경로를 차례로 확인 11/5

오늘 유형 이름을 정하는 순서입니다.

순서조건유형 이름

1 결과가 실패이고 행동이 POST /login 비정상 로그인
2 결과가 실패이고 행동에 /admin이 들어 있음 비인가 접근
3 결과가 실패 기타 실패
4 그 밖 정상

1번 규칙부터 차례로 확인해서 처음 맞는 규칙의 유형 이름을 붙이고 멈춥니다.

로그인에 1번만 실패한 이벤트도 유형은 「비정상 로그인」입니다.

알림을 낼지는 실패 횟수로 따로 정합니다.

「정상」은 결과가 성공이라는 유형 이름이며, 공격이 아니라는 뜻이 아닙니다.

실패 뒤 성공처럼 전후의 이벤트를 함께 봐야 알 수 있는 일도 있습니다.


🐍 문법 상자 — 문자열 안에 다른 문자열이 있는지 in

action = "GET /admin/users"

has_admin = "/admin" in action

print(has_admin)

실행 결과:

True

==는 문자열 전체가 같아야 True이지만,

in은 일부만 들어 있어도 True입니다.

그래서

GET /admin
GET /admin/users

를 모두

"/admin" in action

으로 찾을 수 있습니다.


🐼 판다스 문법 상자 — 표를 딕셔너리 리스트로 바꾸기

import pandas as pd

events = pd.read_json("normalized_events.json")

event_list = events.to_dict("records")

first_event = event_list[0]

print(first_event)

실행 결과:

{
    "time": "2026-11-02 02:14:08",
    "source": "saas",
    "user": "jiwoo",
    "ip": "203.0.113.80",
    "action": "login",
    "result": "실패"
}

to_dict("records")는 표의 한 행을 딕셔너리 하나로 바꿉니다.

이후에는 판다스가 아니라

event["action"]
event["result"]

처럼 하나씩 처리할 수 있습니다.


분류 규칙

if result == "실패" and action == "POST /login":
    return "비정상 로그인"

if result == "실패" and "/admin" in action:
    return "비인가 접근"

if result == "실패":
    return "기타 실패"

return "정상"

이때 and는 이벤트 한 건의 값 하나씩을 이어 판단하므로 and를 씁니다.

표의 열끼리 조건을 이을 때만 &를 씁니다.


한 장 정리

함수받는 것리턴하는 것

load_events 파일 경로 이벤트 표
classify_event 이벤트 하나의 행동과 결과 유형 이름
count_types 이벤트 표 {유형: 개수} 딕셔너리

4교시 한눈에

하려는 일코드 · 말

비정상 로그인 if result == "실패" and action == "POST /login":
비인가 접근 if result == "실패" and "/admin" in action:
표를 리스트로 events.to_dict("records")
유형별 세기 counts[type_name] = counts.get(type_name, 0) + 1

요청이 갑자기 늘어난 분 찾기

📨 오늘 들어온 일

A사 서버 담당자가 보낸 메시지입니다.

「오늘 오전 9시대에 홈페이지가 잠깐 느려졌다는 문의가 들어왔습니다. 웹 서버 이벤트 28개에서 요청이 갑자기 늘어난 때가 있는지 찾아 주세요.

지난 7일 같은 시간대에는 1분에 평균 2번쯤 요청이 들어왔습니다. 그 3배(6번) 이상이면 급증했다고 보기로 했습니다.」

왜 중요한가

요청이 갑자기 늘면 서버가 느려지거나 멈출 수 있고, 공격의 징후일 수도 있습니다.

하루 전체 요청 수로는 1분 동안의 급증이 보이지 않으므로, 분마다 세어 평소와 비교합니다.


5.1 트래픽 급증 규칙

말뜻오늘 예

트래픽 급증 짧은 시간에 요청이 평소보다 크게 늘어남 한 분에 요청이 많이 들어옴
평소 기준 지난 7일 같은 시간대의 분당 평균 요청 수 2번
급증 기준 평소 평균의 3배 6번 이상

지난 7일의 숫자:

날짜1분 평균 요청 수

10/26 2
10/27 2
10/28 3
10/29 2
10/30 1
10/31 2
11/1 2

평균은

(2 + 2 + 3 + 2 + 1 + 2 + 2) ÷ 7
= 2.0

급증 기준은

2.0 × 3
= 6.0

입니다.

오전 2교시와 같은 순서입니다.

평소를 구한다
↓
그 숫자에서 기준을 정한다

달라진 점은 「최댓값 + 1」 대신 「평균의 3배」를 쓴다는 것입니다.


🐼 판다스 문법 상자 — 시각에서 초를 버려 분으로 맞추기

.dt.floor("min")은 시각에서 초를 버리고 분까지만 남깁니다.

09:12:03 → 09:12:00
09:12:15 → 09:12:00

두 이벤트가 같은 분의 값이 됩니다.

import pandas as pd

events = pd.read_json("normalized_events.json")

times = pd.to_datetime(events["time"])

minutes = times.dt.floor("min")

비교

원래 time분 단위

2026-11-02 09:01:05 2026-11-02 09:01:00
2026-11-02 09:01:06 2026-11-02 09:01:00
2026-11-02 09:20:10 2026-11-02 09:20:00

같은 값이 몇 번 나오는지 세면 한 분에 요청이 몇 개 있었는지 알 수 있습니다.

minutes_counts = minutes.value_counts()

minutes_max = minutes_counts.max()

print(minutes_max)

실행 결과:

9

어떤 한 분에 이벤트가 9개 있습니다.

이것이 오늘 찾으려는 트래픽 급증입니다.

문제에서는 웹 서버 이벤트만 골라서 셉니다.


dtype

value_counts()나 groupby().size()의 결과 아래에는 dtype이 나옵니다.

dtype은 data type의 줄임말로 값의 종류를 뜻합니다.

dtype뜻예

int64 정수 5 · 200
float64 소수 2.4
object 문자열 "실패"
bool True · False is_fail
datetime64[ns] 시각 값 2026-11-02 09:12:03

숫자인데 object로 나오면 문자열로 읽힌 것이라 합계 · 비교가 맞지 않습니다.


한 장 정리

함수받는 것리턴하는 것

load_events 파일 경로 이벤트 표
add_minute_column 이벤트 표 minute 열이 붙은 이벤트 표
count_web_per_minute minute 열이 있는 표 웹 서버 요청이 분마다 몇 개인지
make_traffic_limit 파일 경로 · 배수 급증 기준
pick_spike_minutes 분마다 요청 수 · 급증 기준 기준 이상인 분과 요청 수

5교시 한눈에

하려는 일코드 · 말

초를 버려 분으로 minutes = times.dt.floor("min")
새 열로 붙이기 events["minute"] = minutes
웹 서버 이벤트만 events["source"] == "web"
급증 기준 limit = average * multiple
오늘 기준 2.0 × 3 = 6.0
기준 이상 counts >= limit

알림을 한 파일로 모으기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「오전에 로그인 실패 알림 1건, 오후에 트래픽 급증 알림 1건이 나왔는데 형식이 달라서 담당자가 따로따로 처리해야 합니다.

두 알림을 alerts.json 한 파일에 같은 모양으로 모아 주세요. 알림마다 종류 · 대상 · 횟수 · 메모를 넣어 주세요. 공격으로 확정된 것은 아니니 처리 상태는 모두 「확인 필요」로 해 주세요.」

왜 중요한가

알림 형식이 종류마다 다르면 담당자가 하나씩 해석해야 합니다.

형식이 같으면 사람도 자동 대응 도구도 같은 방법으로 처리할 수 있습니다.


왜 필요한가

SIEM의 네 단계는

수집
↓
정규화
↓
탐지
↓
알림

입니다.

오늘 알림으로 모으는 탐지 결과는 두 가지입니다.

  1. 로그인 실패 규칙
  2. 트래픽 급증 규칙

두 규칙의 결과는 모양이 다릅니다.

로그인 실패

203.0.113.50
5번

트래픽 급증

2026-11-02 09:12:00
9개

알림은

  • 종류
  • 대상
  • 횟수
  • 메모
  • 처리 상태

를 같은 키로 담아야 한 파일에 모을 수 있습니다.


6.1 알림 한 건의 모양

키뜻로그인 실패 알림트래픽 급증 알림

type 알림 종류 비정상 로그인 트래픽 급증
target 무엇에 대한 알림인가 203.0.113.50 2026-11-02 09:12:00
count 몇 번이었나 5 9
note 담당자에게 보일 메모 로그인 실패가 기준 이상 요청이 평소의 3배 이상
status 처리 상태 확인 필요 확인 필요

처리 상태는 담당자가 조사하면서 바꿉니다.

확인 필요
↓
조사 중
↓
조사 완료

알림을 다룰 때 지킬 점

지킬 점뜻이 문서에서

알림 있음 ≠ 공격 확정 정상적인 일도 기준 이상이 될 수 있음 상태를 모두 「확인 필요」로 둠
알림 없음 ≠ 안전 규칙에 해당하지 않았다는 뜻일 뿐 규칙에 해당하지 않으면 알림이 나지 않음

🐼 판다스 문법 상자 — 인덱스와 값을 한 쌍씩 꺼내기

.items()는 판다스로 센 결과에서

인덱스, 값

을 한 쌍씩 꺼냅니다.

import pandas as pd

events = pd.read_json("normalized_events.json")

counts = events["source"].value_counts()

for source, count in counts.items():
    print(source, count)

실행 결과:

web 28
server 15
saas 14

오늘 문제에서는 인덱스가

  • 주소
  • 분

이고 값이 횟수입니다.


🐼 판다스 문법 상자 — 시각 값을 문자열로 바꾸기

분 단위로 맞춘 시각은 판다스의 시각 값입니다.

JSON에 저장하려면 문자열로 바꿉니다.

text = str(first_minute)

예:

import pandas as pd

events = pd.read_json("normalized_events.json")

times = pd.to_datetime(events["time"])

minutes = times.dt.floor("min")

first_minute = minutes[1]

text = str(first_minute)

message = text + " 에 이벤트가 있었음"

print(message)

실행 결과:

2026-11-02 09:01:00 에 이벤트가 있었음

str()을 거치면 문자열이 되어 글을 이어 붙일 수 있습니다.


알림 한 건

alert = {
    "type": alert_type,
    "target": target,
    "count": count,
    "note": note,
    "status": "확인 필요"
}

로그인 실패 알림

type   → 비정상 로그인
target → IP 주소
count  → 실패 횟수
note   → 로그인 실패가 기준 이상
status → 확인 필요

트래픽 급증 알림

type   → 트래픽 급증
target → 분
count  → 요청 수
note   → 요청이 평소의 3배 이상
status → 확인 필요

두 알림 리스트 합치기

리스트는 +로 이어 붙일 수 있습니다.

alerts = login_alerts + traffic_alerts

예:

[1, 2] + [3]

결과:

[1, 2, 3]

JSON 파일로 저장하기

with open(
    path,
    "w",
    encoding="utf-8"
) as file:

    json.dump(
        alerts,
        file,
        ensure_ascii=False,
        indent=2
    )

결과는

alerts.json

한 파일에 저장됩니다.


한 장 정리

함수받는 것리턴하는 것

make_alert 종류 · 대상 · 횟수 · 메모 알림 한 건
make_login_alerts 알림 대상 주소와 횟수 로그인 실패 알림 리스트
make_traffic_alerts 요청이 급증한 분과 요청 수 트래픽 급증 알림 리스트
save_alerts 알림 리스트와 파일 경로 저장한 알림 건수

6교시 한눈에

하려는 일코드 · 말

알림 한 건 {"type": ..., "target": ..., "count": ..., "note": ..., "status": "확인 필요"}
센 결과 한 쌍씩 for ip_address, count in alert_ips.items():
시각 값을 문자열로 target = str(minute)
두 알림 리스트 합치기 login_alerts + traffic_alerts
파일 열기 with open(path, "w", encoding="utf-8") as file:
JSON 저장 json.dump(alerts, file, ensure_ascii=False, indent=2)

오늘 정리

오늘은 단순히 이벤트를 세는 것에서 끝나지 않고, 평소와 비교해 알림을 만드는 단계까지 진행했습니다.

지난 7일 데이터
↓
평소 기준 계산
↓
임계값 결정
↓
로그인 실패 탐지
↓
이벤트 유형 분류
↓
트래픽 급증 탐지
↓
alerts.json으로 알림 통합

SIEM의 흐름으로 보면

수집
↓
정규화
↓
탐지
↓
알림

가 이어집니다.

 

 


 

뭔가 어제보다 상태가 좀더 별로인 느낌,,,

그럼 쫌따봐여!

'공부 > SKT ALEPH' 카테고리의 다른 글

이상탐지 자동화 - 4  (0) 2026.11.05
이상탐지 자동화 - 2  (0) 2026.11.03
이상탐지 자동화 - 1  (0) 2026.11.02
접근통제 자동화 - 6  (0) 2026.11.02
접근통제 자동화 - 5  (0) 2026.10.30

안녕하세요,,,

오늘은 몸상태가 좀 메롱이네요...

약도 계속 먹는중 빨리 나았으면 좋겠어요..

레츠고

 

 


 

오늘의 수업

교시내용

2교시 웹 서버 로그를 공통 이벤트로 바꾸기
3교시 판정 서버 로그와 SaaS 로그를 공통 이벤트로 바꾸기
4교시 세 로그를 하나로 합쳐 시간순으로 저장하기
5교시 정규화된 이벤트를 표로 읽고 실패만 골라 세기
6교시 실패를 사용자별 · 시간대별로 묶어 세고 저장하기

웹 서버 로그를 공통 이벤트로 바꾸기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「로그가 세 군데에서 쌓이는데 적는 방식이 다 다릅니다.

시각
웹 서버: 02/Nov/2026:09:12:03 +0900
판정 서버: 2026-11-02T09:40
SaaS: 2026-11-02 02:14:08

성공 · 실패
웹 서버: 200
판정 서버: true
SaaS: success

이대로는 같은 계정이 여러 곳에서 한 일을 시간 순서로 볼 수 없습니다. 먼저 웹 서버 로그 한 줄을 언제 · 누가 · 무엇을 · 결과를 같은 키에 담은 이벤트로 바꿔 주세요. 시각은 2026-11-02 09:12:03 모양으로 맞추고, 원래 줄은 그대로 남겨 주세요.」

왜 중요한가

모양이 다른 로그는 시간순으로 놓을 수도, 같은 기준으로 셀 수도 없습니다.

같은 키로 맞추면 탐지 규칙을 로그 종류마다 따로 만들지 않고 한 번만 만들면 됩니다.

원래 줄을 남겨 두면 변환이 틀렸을 때 원본과 대조할 수 있습니다.


왜 필요한가

SIEM(심)은 여러 곳의 로그를 한곳에 모아 같은 기준으로 찾습니다.

모양이 다르면 같은 기준을 적용할 수 없습니다.

그래서 로그를 모은 다음 같은 키 · 같은 모양으로 맞추는 일이 필요합니다.

이것을 **정규화(normalization, 노멀라이제이션)**라고 합니다.

오늘 오전에는 로그 3종류를 같은 키로 맞춘 공통 이벤트로 바꿉니다.


2.1 세 로그의 모양과 공통 이벤트

세 로그는 모두 언제 · 누가 · 어디서 · 무엇을 · 성공했나를 적고 있지만, 적는 방식이 다릅니다.

로그샘플의 실제 한 줄

웹 서버 203.0.113.50 - - [02/Nov/2026:09:12:03 +0900] "POST /login HTTP/1.1" 401 312 "-" "python-requests/2.31.0"
판정 서버 {"time": "2026-11-02T09:40", "user": "seoyeon", "permission": "SALES:QUERY", "network": "office", "allowed": true, "reason": "역할 권한"}
SaaS 2026-11-02 02:14:08,jiwoo@a-corp.example,login,203.0.113.80,failure

오늘 맞출 차이

정보웹 서버판정 서버SaaS공통 이벤트에서는

시각 02/Nov/2026:09:12:03 +0900 2026-11-02T09:40 2026-11-02 02:14:08 모두 2026-11-02 09:12:03 모양
사용자 없음 seoyeon 메일 주소 jiwoo@a-corp.example 이름만. 원래 없으면 None
접속 주소 있음 없음 있음 원래 없으면 None
성공 · 실패 상태 코드 true · false success · failure 모두 성공 또는 실패

접속 주소(ip)는 사람이 아니라 접속한 곳의 주소입니다.

한 사무실의 여러 사람이 같은 주소로 보일 수 있고, 한 사람이 집과 회사에서 다른 주소로 접속할 수도 있습니다.

판정 서버 로그에는 주소 대신 접속 위치(office · vpn · external)만 있습니다.

이 수업의 203.0.113.50 · 198.51.100.x · 192.0.2.x는 문서에서 예시로 쓰라고 정해 둔 주소 범위라서 실제 장비를 가리키지 않습니다.


공통 이벤트의 키

로그 한 줄이 답하는 질문은 세 로그가 같습니다.

언제?
어느 로그에서?
누가?
어디서?
무엇을?
성공했나?

이 여섯 질문의 답을 같은 키에 담고, 원래 줄을 보관하는 키 하나를 더한 것이 공통 이벤트입니다.

이벤트는 「일어난 일 1개」입니다.

키답하는 질문값의 모양

time 언제? 2026-11-02 09:12:03 문자열
source 어느 로그에서? web · server · saas
user 누가? 사용자 이름. 원래 없으면 None
ip 어디서? 주소 문자열. 원래 없으면 None
action 무엇을? POST /login · SALES:CHANGE · file.download
result 성공했나? 성공 또는 실패
raw 원본 보관 원래 줄 또는 원래 행

바뀐 결과

키웹 서버 한 줄판정 서버 한 줄SaaS 한 줄

time 2026-11-02 09:12:03 2026-11-02 09:40:00 2026-11-02 02:14:08
source web server saas
user None seoyeon jiwoo
ip 203.0.113.50 None 203.0.113.80
action POST /login SALES:QUERY login
result 실패 성공 실패
raw 원래 줄 원래 줄 원래 행

원래 로그에 없는 값은 임의로 만들어 넣지 않고 None으로 둡니다.

없는 값을 지어내면 분석 결과가 틀려집니다.

raw는 바꾸지 않은 원래 로그입니다.

정규화 과정에서 사라지는 정보를 나중에 찾아볼 수 있게 남깁니다.


2.2 시각 문자열 바꾸기

🐍 문법 상자 — 시각 값을 원하는 모양의 문자열로

.strftime(형식)은 시각 값을 원하는 모양의 문자열로 만듭니다.

로그마다 시각 모양이 달라서, 먼저 시각 값으로 바꾼 뒤 공통 모양의 문자열로 다시 만듭니다.

from datetime import datetime

time_text = "02/Nov/2026:09:12:03 +0900"

parsed = datetime.strptime(
    time_text,
    "%d/%b/%Y:%H:%M:%S %z"
)

print(parsed)

time_out = parsed.strftime(
    "%Y-%m-%d %H:%M:%S"
)

print(time_out)

실행 결과:

2026-11-02 09:12:03+09:00
2026-11-02 09:12:03

형식 코드는 %Y · %m처럼 %로 시작하는 기호로, 시각 문자열의 어느 부분이 일 · 달 · 연도인지 알려 줍니다.

문자열 부분형식 코드뜻

02 %d 일
Nov %b 영어 달 이름
2026 %Y 연도 4자리
09 %H 시
12 %M 분
03 %S 초
+0900 %z 시간대

문자열

02/Nov/2026:09:12:03 +0900

에서 부분 사이의 / · : · 공백은 형식에도 똑같이 적습니다.

그래서 형식은

%d/%b/%Y:%H:%M:%S %z

입니다.

.strftime(형식)은 반대로 시각 값을 같은 형식 코드로 문자열로 만듭니다.


🐍 문법 상자 — 시간대 정보 떼기

시각 값에 붙은 시간대(+0900)는 .replace(tzinfo=None)으로 떼어 낼 수 있고, 떼면 한국 시각만 남은 시각 값이 됩니다.

from datetime import datetime

time_text = "02/Nov/2026:09:12:03 +0900"

parsed = datetime.strptime(
    time_text,
    "%d/%b/%Y:%H:%M:%S %z"
)

print(parsed.tzinfo)

local_time = parsed.replace(
    tzinfo=None
)

print(local_time)

실행 결과:

UTC+09:00
2026-11-02 09:12:03

시각 값이 +0900을 갖고 있으면 시간대 정보가 없는 시각 값과 직접 비교할 때 오류가 납니다.

세 로그의 시각은 모두 한국 시간이므로, 오늘은 시간대 정보를 떼고 한국 시각만 남겨 세 로그의 시각 값을 같은 종류로 맞춥니다.


한 장 정리

함수받는 것리턴하는 것

read_time_text 요청 앞 조각 대괄호 안의 시각 문자열
convert_time nginx 시각 문자열 공통 모양 시각 문자열
convert_result 상태 코드 "성공" 또는 "실패"
normalize_web 로그 한 줄 공통 이벤트 딕셔너리. 깨진 줄이면 None

2교시 한눈에

하려는 일코드 · 말

문자열 시각을 시각 값으로 parsed = datetime.strptime(time_text, NGINX_TIME_FORMAT)
시간대 정보 떼기 local_time = parsed.replace(tzinfo=None)
공통 모양 문자열로 time_out = local_time.strftime(COMMON_TIME_FORMAT)
상태 코드 → 결과 if status >= 400: → "실패", 아니면 "성공"

판정 서버 로그와 SaaS 로그를 공통 이벤트로 바꾸기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「웹 서버 로그는 공통 이벤트로 바꿨습니다. 나머지 두 로그도 같은 모양으로 맞춰 주세요.

판정 서버 로그: 접속 주소가 없습니다.
SaaS 로그: 사용자가 메일 주소(seoyeon@a-corp.example)로 적혀 있으니 이름만 꺼내 주세요.

두 로그 모두 성공 · 실패를 웹 서버 로그처럼 성공 · 실패로 통일해 주세요.」

왜 중요한가

같은 사람이 로그마다 seoyeon과 seoyeon@a-corp.example로 적히면 사용자별로 세거나 이어 볼 수 없습니다.

접속 주소가 원래 없는 로그에는 임의의 주소를 채우지 말고 None으로 둡니다.

가짜 주소로 세어지는 이벤트가 생기기 때문입니다.


왜 필요한가

2교시에서는 웹 서버 로그 한 종류를 바꿨습니다.

같은 방법을 나머지 두 종류에 적용합니다.

로그마다 어려운 곳이 다릅니다.

판정 서버 로그

2026-11-02T09:40

처럼 T로 나뉘어 있고 초가 없습니다.

접속 주소가 없습니다.

SaaS 로그

시각은 이미 공통 모양입니다.

사용자가 메일 주소입니다.


3.1 CSV 읽기와 값 바꾸는 표

🐍 문법 상자 — CSV 파일을 딕셔너리로 읽기

csv.DictReader는 CSV 파일의 첫 줄을 열 이름으로 삼아, 둘째 줄부터 한 줄씩 「열 이름: 값」 딕셔너리로 읽어 줍니다.

import csv

rows = []

with open(
    "saas_events.csv",
    encoding="utf-8",
    newline=""
) as saas_file:

    reader = csv.DictReader(
        saas_file
    )

    for row in reader:
        rows.append(row)

first_row = rows[0]

print(first_row)

row_count = len(rows)

print(row_count)

실행 결과:

{'time': '2026-11-02 02:14:08', 'account': 'jiwoo@a-corp.example', 'event': 'login', 'ip_address': '203.0.113.80', 'outcome': 'failure'}

14

CSV 한 줄이 딕셔너리 하나로 바뀌는 모양입니다.

모양내용

CSV 첫 줄 time,account,event,ip_address,outcome
CSV 둘째 줄 2026-11-02 02:14:08,jiwoo@a-corp.example,login,203.0.113.80,failure
DictReader 결과 {"time": "...", "account": "...", "event": "...", ...}

CSV는 값을 쉼표로 나눠 적은 표 파일입니다.

첫 줄은 열 이름입니다.

csv.DictReader(saas_file)로 만든 reader를 반복하면 행마다 딕셔너리가 나옵니다.

그래서

row["account"]

처럼 이름으로 값을 꺼냅니다.

CSV 파일을 열 때는 newline=""을 함께 씁니다.

줄바꿈을 csv 도구가 직접 처리하게 하는 관례입니다.


🐍 문법 상자 — 값을 다른 값으로 바꾸는 표

딕셔너리를 「바꾸는 표」로 쓰면

표[키]

한 줄로 값을 다른 값으로 바꿀 수 있습니다.

RESULT_MAP = {
    "success": "성공",
    "failure": "실패"
}

outcome = "failure"

result = RESULT_MAP[outcome]

print(result)


ALLOWED_MAP = {
    True: "성공",
    False: "실패"
}

allowed = True

allowed_result = ALLOWED_MAP[allowed]

print(allowed_result)

실행 결과:

실패
성공

RESULT_MAP["failure"]는 "실패"입니다.

판정 서버의 allowed는 true / false이고 파이썬에서는 True / False입니다.

표에 없는 키로 꺼내면 KeyError 오류가 납니다.

예상하지 못한 값이 들어오면 멈추기 때문에 오류가 나는 것이 오히려 단서가 됩니다.


한 장 정리

함수받는 것리턴하는 것

convert_server_time 판정 서버 시각 문자열 공통 모양 시각 문자열
normalize_server 판정 서버 로그 한 줄 공통 이벤트. ip는 None
normalize_saas CSV 한 행 공통 이벤트. user는 메일에서 꺼낸 이름
load_server · load_saas 파일 경로 공통 이벤트 리스트

3교시 한눈에

하려는 일코드 · 말

판정 서버 시각 바꾸기 datetime.strptime(...) → .strftime(...)
allowed → 성공 · 실패 result = ALLOWED_MAP[allowed]
메일 주소에서 이름 꺼내기 row["account"].split("@") → 첫 번째 값
outcome → 성공 · 실패 result = RESULT_MAP[outcome]
CSV를 딕셔너리로 읽기 csv.DictReader(saas_file)

세 로그를 하나로 합쳐 시간순으로 저장하기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「세 로그가 같은 모양이 되었습니다. 하나로 합쳐 시간 순서로 정렬해 파일로 저장해 주세요. 오후에 이 파일로 실패가 몇 번 났는지 세겠습니다.

소스별로 이벤트가 몇 개씩인지, 사용자나 주소가 비어 있는 이벤트가 몇 개인지도 알려 주세요. 합친 결과로 gaeun이 오늘 한 일을 시간순으로 보여 주세요.」

왜 중요한가

로그가 따로 있을 때는 보이지 않던 같은 계정의 일이 시간순으로 합치면 보입니다.

값이 비어 있는 이벤트 수는 분석에서 빠지는 이벤트가 얼마나 되는지 미리 알려 줍니다.

저장한 파일은 오후 분석의 입력이 됩니다.


왜 필요한가

2 · 3교시에서 만든 함수는 로그 한 종류씩 공통 이벤트 리스트를 만듭니다.

이제 세 리스트를 합치고, 시간순으로 정렬하고, 파일로 저장합니다.

순서는 이렇습니다.

웹 서버 이벤트 28개
+
판정 서버 이벤트 15개
+
SaaS 이벤트 14개
↓
57개
↓
시간순 정렬
↓
normalized_events.json

time 문자열은

2026-11-02 09:12:03

처럼 연 · 월 · 일 · 시 · 분 · 초 순서라서 문자열 순서가 곧 시간 순서입니다.


4.1 이어 붙이기 · 정렬하기 · 저장하기

🐍 문법 상자 — 리스트를 함수가 알려 주는 값으로 정렬하기

리스트.sort(key=함수)는 각 이벤트를 함수에 넣어 나온 값의 순서로 리스트를 정렬합니다.

web_events = [
    {
        "time": "2026-11-02 11:00:00",
        "source": "server"
    },
    {
        "time": "2026-11-02 09:12:03",
        "source": "web"
    }
]

saas_events = [
    {
        "time": "2026-11-02 10:12:55",
        "source": "saas"
    }
]

merged = web_events + saas_events


def get_time(event):
    return event["time"]


merged.sort(
    key=get_time
)

for event in merged:
    print(
        event["time"],
        event["source"]
    )

실행 결과:

2026-11-02 09:12:03 web
2026-11-02 10:12:55 saas
2026-11-02 11:00:00 server

a + b는 리스트 a 뒤에 b를 이어 붙인 새 리스트입니다.

events.sort(key=get_time)은 리스트의 각 이벤트를 get_time 함수에 넣어 나온 time 순서로 정렬합니다.

get_time 뒤에 괄호를 붙이지 않습니다.

함수를 부르는 것이 아니라 함수 자체를 알려 주는 것입니다.


정렬 과정

정렬 전get_time이 리턴하는 값

server 이벤트 2026-11-02 11:00:00
web 이벤트 2026-11-02 09:12:03
saas 이벤트 2026-11-02 10:12:55

sort는 이 값을 비교해 작은 값부터 큰 값 순서로 놓습니다.

결과:

web
↓
saas
↓
server

sort는 리스트 자체를 바꾸고 아무것도 리턴하지 않습니다.

따라서

events = events.sort(...)

라고 쓰면 events가 None이 됩니다.


🐍 문법 상자 — 리스트를 JSON 파일로 저장하기

import json

events = [
    {
        "user": "seoyeon",
        "result": "성공"
    }
]

with open(
    "demo_events.json",
    "w",
    encoding="utf-8"
) as demo_file:

    json.dump(
        events,
        demo_file,
        ensure_ascii=False,
        indent=2
    )

with open(
    "demo_events.json",
    encoding="utf-8"
) as demo_file:

    text = demo_file.read()

print(text)

실행 결과:

[
  {
    "user": "seoyeon",
    "result": "성공"
  }
]

json.dumps는 값을 JSON 문자열로 만들고,

json.dump는 값을 JSON 파일에 바로 씁니다.

ensure_ascii=False는 한글을 그대로 저장합니다.


한 장 정리

함수받는 것리턴하는 것

load_web 웹 서버 로그 파일 경로 공통 이벤트 리스트
merge_events 이벤트 리스트 세 개 하나로 합쳐 시간순으로 정렬한 리스트
count_by_source 이벤트 리스트 {소스: 개수} 딕셔너리
count_missing 이벤트 리스트와 키 이름 해당 값이 None인 이벤트 수
save_events 이벤트 리스트와 파일 이름 없음. 파일로 저장

4교시 한눈에

하려는 일코드 · 말

리스트 세 개를 하나로 events = web + server + saas
시간순으로 정렬 events.sort(key=get_time)
소스별 개수 counts[source] = counts.get(source, 0) + 1
JSON 파일로 저장 json.dump(events, f, ensure_ascii=False, indent=2)

정규화된 이벤트를 표로 읽고 실패만 골라 세기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「이벤트 파일이 생겼습니다. 오늘은 57개뿐이지만 실제로는 하루에 수만 개가 쌓입니다. 지금까지는 파이썬 리스트와 딕셔너리를 for로 돌려 하나씩 세었는데, 이벤트가 늘수록 코드도 길어집니다.

이 파일을 표로 읽어 몇 개가 들어 있는지 알려 주세요. 성공과 실패가 각각 몇 개인지, 실패는 어느 소스에서 몇 개씩 났는지도 알려 주세요.」

왜 중요한가

실패한 이벤트만 걸러 내고 소스별로 세는 일은 로그를 조사할 때마다 합니다.

표로 읽으면 이 일이 몇 줄로 끝나고, 이벤트가 57개든 5만 개든 같은 코드를 씁니다.


왜 필요한가

지금까지 이벤트나 요청은 파이썬 리스트와 딕셔너리로 다뤘습니다.

값별로 세려면 for로 하나씩 꺼내 딕셔너리에 더해 가야 했습니다.

**판다스(pandas)**는 데이터를 표로 읽고, 짧은 코드로 걸러 내고 셀 수 있게 해 주는 파이썬 라이브러리입니다.

판다스의 표를 **데이터프레임(DataFrame)**이라고 부릅니다.

가로 한 줄이 행, 세로 한 줄이 열입니다.

이 수업에서는 행 하나가 이벤트 1개이고, 열 하나는 time 같은 키 하나의 값 전체입니다.

5교시는

읽기 · 걸러 내기 · 세기

6교시는

묶기 · 시간 다루기 · 저장

입니다.


파이썬과 판다스의 관계

파이썬

프로그래밍 언어입니다.

변수, 리스트, 딕셔너리, 함수, 반복문 같은 기본 문법을 제공합니다.

판다스

먼저 설치하고

pip install pandas

코드 맨 위에서

import pandas as pd

로 불러와야 씁니다.

판다스 코드는 파이썬 파일 안에서 실행됩니다.

판다스 문법은 두 가지 모양입니다.

pd.read_json(...)

처럼 pd.로 시작하는 것과

events.shape
sources.value_counts()

처럼 표나 열 뒤에 .이름이 붙는 것입니다.


5.1 정규화된 이벤트의 모양

로그는 세 종류입니다.

source무엇이 남기나한 줄이 생기는 때예

web 웹 서버 nginx 페이지를 열거나 로그인할 때 203.0.113.50이 POST /login 요청
server 권한 판정 서버 어떤 권한을 써도 되는지 물을 때 seoyeon이 SALES:CHANGE 요청
saas SaaS 서비스 로그인하거나 파일을 올리고 내려받을 때 seoyeon이 file.download

이벤트 하나는 위 세 로그 중 한 곳의 로그 한 줄을 같은 형식으로 맞춘 것입니다.

오늘 쓰는 파일의 키는 여섯 개입니다.

오전의 공통 이벤트에는 raw 키가 하나 더 있었는데, 이 샘플 파일은 길어서 raw를 뺐습니다.

키뜻예

time 일어난 시각 2026-11-02 09:12:03
source 어느 로그에서 왔는지 web · server · saas
user 누가 했는지 seoyeon
ip 어느 주소에서 왔는지 203.0.113.50
action 무엇을 했는지 POST /login · SALES:CHANGE · file.download
result 성공했는지 성공 또는 실패

로그별 값

키webserversaas

ip 웹 서버에 요청을 보낸 주소 없음(null) SaaS에 접속한 주소
user 없음(null) 요청한 계정 메일 주소 앞부분
result 상태 코드가 400 이상이면 실패 allowed가 거짓이면 실패 outcome이 failure이면 실패

주소(ip)는 사람이 아니라 접속한 곳입니다.

한 사무실의 여러 사람이 같은 주소로 보일 수 있고, 한 사람이 집과 회사에서 다른 주소로 접속할 수도 있습니다.

원래 로그에 없는 값은 임의로 채우지 않고 null로 둡니다.

JSON에서는 null, 파이썬과 판다스 표에서는 None 또는 NaN으로 보이며 모두 「값이 없다」는 뜻입니다.


판다스로 읽었을 때

행 번호timesourceuseripactionresult

0 2026-11-02 02:14:08 saas jiwoo 203.0.113.80 login 실패
1 2026-11-02 09:01:05 web None 198.51.100.21 GET / 성공
2 2026-11-02 09:01:06 web None 198.51.100.21 GET /static/app.css 성공

맨 왼쪽 숫자는 판다스가 붙이는 행 번호, **인덱스(index)**입니다.


5.2 판다스로 표 읽기 · 세기

🐼 판다스 문법 상자 — 파일을 표로 읽기

import pandas as pd

events = pd.read_json(
    "normalized_events.json"
)

row_count = len(events)

print(row_count)

실행 결과:

57

57은 표의 행 수이고, 행 하나가 이벤트 하나이므로 이벤트 57개라는 뜻입니다.


표의 크기 보기

import pandas as pd

events = pd.read_json(
    "normalized_events.json"
)

shape = events.shape

print(shape)

실행 결과:

(57, 6)

(57, 6)은 57행 6열입니다.


🐼 판다스 문법 상자 — 열 하나 꺼내기

sources = events["source"]

source_count = len(sources)

print(source_count)

실행 결과:

57

events["source"]는 표에서 source 열만 꺼냅니다.

판다스에서는 열 하나를 **Series(시리즈)**라고 부릅니다.

열 이름은 파일에 적힌 키와 똑같이 써야 합니다.

없는 열 이름을 쓰면 KeyError가 납니다.


열의 앞 몇 개만 보기

times = events["time"]

first_times = times.head(3)

for time_text in first_times:
    print(time_text)

실행 결과:

2026-11-02 02:14:08
2026-11-02 09:01:05
2026-11-02 09:01:06

시각 57개 중 앞의 3개입니다.


🐼 판다스 문법 상자 — 값별로 개수 세기

.value_counts()는 열에서 같은 값이 몇 번 나왔는지 세어 많은 순서로 알려 줍니다.

sources = events["source"]

counts = sources.value_counts()

print(counts)

실행 결과:

source
web       28
server    15
saas      14
Name: count, dtype: int64

읽는 법:

web       28
server    15
saas      14

28 + 15 + 14 = 57이라 이벤트 전체 개수와 같습니다.

value_counts()는 3과목에서 썼던

counts[value] = counts.get(value, 0) + 1

과 같은 일을 한 줄로 합니다.

실무에서는 다음처럼 한 줄로 자주 씁니다.

counts = events["source"].value_counts()

조건에 맞는 행만 걸러 내기

results = events["result"]

is_fail = results == "실패"

failed = events[is_fail]

failed_shape = failed.shape

print(failed_shape)

실행 결과:

(23, 6)

실패한 이벤트 23개만 남았다는 뜻입니다.

표[조건]은 조건이 True인 행만 남긴 새 표를 만듭니다.

이 일을 **필터링(filtering)**이라고 합니다.


한 장 정리

함수받는 것리턴하는 것

load_events 파일 경로 이벤트 표
describe_events 이벤트 표 표 크기와 time 열의 처음 3개
pick_failed 이벤트 표 result가 실패인 행만 담은 표
count_values 표와 열 이름 해당 열의 값별 개수

5교시 한눈에

하려는 일코드 · 말

판다스 불러오기 import pandas as pd
JSON 파일을 표로 events = pd.read_json(path)
표 크기 events.shape
열 하나 꺼내기 events["time"]
처음 3개 .head(3)
실패한 행만 events[events["result"] == "실패"]
값별 개수 values.value_counts()

실패를 사용자별 · 시간대별로 묶어 세고 저장하기

📨 오늘 들어온 일

A사 보안 팀장이 보낸 메시지입니다.

「실패한 이벤트가 23개라는 것은 알았습니다. 이제 두 가지를 알려 주세요.

누가 얼마나 실패했는지: 사용자별로, 많은 순서대로
하루 중 언제 많이 났는지: 몇 시에 몇 개인지

그리고 실패한 이벤트만 따로 파일로 저장해 주세요. 담당자가 엑셀로 열어 근거를 확인합니다.」

왜 중요한가

실패 23개를 전체 합계로만 보면 평범한 숫자입니다.

사용자별 · 시간대별로 묶으면 한 사용자나 한 시간대에 많이 난 실패가 드러납니다.

근거 이벤트를 파일로 저장해야 담당자가 알림이 왜 났는지 확인할 수 있습니다.

11/4에 평소 기준을 세우는 기초 계산이기도 합니다.


왜 필요한가

5교시에는 열 하나의 값별 개수를 셌습니다.

이번에는 조건에 맞는 행들을 다른 열 기준으로 묶어 셉니다.

예:

실패한 이벤트를 사용자별로

time 열의

2026-11-02 09:01:05

는 문자열이라서 「시」만 꺼낼 수 없습니다.

그래서 먼저 날짜·시간 값으로 바꾼 뒤 「시」를 꺼냅니다.

분석한 결과는 CSV로 저장하면 엑셀 같은 도구로 열어 볼 수 있습니다.


6.1 묶고 세기 · 시간 다루기 · 저장하기

🐼 판다스 문법 상자 — 같은 값끼리 묶기

groupby("열")은 그 열의 값이 같은 줄끼리 묶습니다.

import pandas as pd

events = pd.read_json(
    "normalized_events.json"
)

results = events["result"]

is_fail = results == "실패"

failed = events[is_fail]

grouped = failed.groupby(
    "source"
)

print(grouped)

실행 결과:

<pandas.core.groupby.generic.DataFrameGroupBy object at ...>

이 출력에는 그룹의 개수나 내용이 없습니다.

묶기만 했고 아직 세지 않았기 때문입니다.

그룹이 몇 개인지는

len(grouped)

로 셉니다.

group_count = len(grouped)

print(group_count)

실행 결과:

3

source 값이

  • saas
  • server
  • web

세 가지라 그룹이 3개입니다.


🐼 판다스 문법 상자 — 그룹마다 행 수 세기

.size()는 그룹마다 행이 몇 개인지 셉니다.

grouped = failed.groupby(
    "source"
)

counts = grouped.size()

print(counts)

실행 결과:

source
saas       1
server     9
web       13
dtype: int64

1 + 9 + 13 = 23이라 실패한 이벤트 전체 개수와 같습니다.

개수만 세려면 value_counts()로도 같은 결과가 나옵니다.

그래도 groupby를 배우는 것은 그룹마다 평균 같은 다른 계산도 하기 때문입니다.

묶기와 세기는 한 줄로도 쓸 수 있습니다.

counts = failed.groupby(
    "source"
).size()

🐼 판다스 문법 상자 — 큰 값부터 정렬하기

.sort_values(ascending=False)는 값이 큰 것부터 다시 정렬합니다.

grouped = failed.groupby(
    "source"
)

counts = grouped.size()

sorted_counts = counts.sort_values(
    ascending=False
)

print(sorted_counts)

실행 결과:

source
web       13
server     9
saas       1
dtype: int64

그룹 이름 순서에서

saas → server → web

개수가 큰 순서

web → server → saas

가 되었습니다.

ascending=False는 큰 값부터 정렬하라는 뜻입니다.

실무에서는 다음처럼 이어 쓰기도 합니다.

sorted_counts = (
    failed
    .groupby("source")
    .size()
    .sort_values(ascending=False)
)

왼쪽부터

묶기
↓
세기
↓
큰 값부터 정렬

순서입니다.


문자열 시각을 시각 값으로 바꾸기

pd.to_datetime은

2026-11-02 09:01:05

같은 문자열을 날짜·시간 값으로 바꿉니다.

날짜·시간 값이어야 「시」나 「분」 같은 부분만 꺼낼 수 있습니다.

time_texts = events["time"]

times = pd.to_datetime(
    time_texts
)

first_times = times.head(3)

print(first_times)

실행 결과:

0   2026-11-02 02:14:08
1   2026-11-02 09:01:05
2   2026-11-02 09:01:06
Name: time, dtype: datetime64[ns]

겉모습은 같지만 datetime64는 이 열이 날짜·시간 값으로 바뀌었다는 표시입니다.


시각 값에서 「시」만 꺼내기

.dt.hour는 시각 값에서 「시」만 숫자로 꺼냅니다.

time_texts = events["time"]

times = pd.to_datetime(
    time_texts
)

hours = times.dt.hour

first_hours = hours.head(3)

for hour in first_hours:
    print(hour)

실행 결과:

2
9
9

첫 이벤트 02:14는 2,

둘째와 셋째 09:01은 9입니다.


표에 새 열 붙이기

time_texts = events["time"]

times = pd.to_datetime(
    time_texts
)

hours = times.dt.hour

events["hour"] = hours

shape = events.shape

print(shape)

실행 결과:

(57, 7)

행은 그대로 57개이고, 열이 6개에서 7개로 늘었습니다.


표를 CSV 파일로 저장하기

failed.to_csv(
    "failed_events.csv",
    index=False
)

index=False는 행 번호를 저장하지 않는다는 뜻입니다.

실행하면 같은 폴더에

failed_events.csv

파일이 생깁니다.

VS Code로 열면 첫 줄은 열 이름이고, 둘째 줄부터 이벤트가 한 줄씩 있습니다.

엑셀로 열었을 때 한글이 깨져 보이면 VS Code로 열어 확인합니다.


한 장 정리

함수받는 것리턴하는 것

load_events · pick_failed 이벤트 파일 · 표 이벤트 표 · 실패한 행만 담은 표
count_by_user 실패한 이벤트 표 사용자별 실패 횟수
add_hour 이벤트 표 hour 열이 더해진 표
count_by_hour 실패한 이벤트 표 시간대별 실패 횟수
save_failed 표와 파일 이름 없음. 파일로 저장

count_by_user는 사용자별로 묶어 세고 큰 순서로 정렬합니다.

count_by_hour는 시간대별로 묶어 셉니다.

add_hour는 시각을 바꿔 hour 열을 더합니다.

save_failed는 실패한 이벤트를 CSV로 저장합니다.


6.2 묶을 때 비어 있는 값은 빠집니다

groupby("user")

는 user가 비어 있는 행(null)을 그룹에 넣지 않고 제외합니다.

웹 서버 이벤트는 user가 없어서 사용자별 개수에 들어가지 않습니다.

그래서 사용자별 개수를 모두 더하면 실패한 이벤트 전체 개수보다 작아질 수 있습니다.

숫자를 읽을 때

빠진 행이 있는가

를 함께 봅니다.

오류 메시지는 나오지 않으므로 모르고 지나치기 쉽습니다.


6교시 한눈에

하려는 일코드 · 말

같은 값끼리 묶어 개수 세기 grouped = failed.groupby("user") → counts = grouped.size()
큰 값부터 정렬 counts.sort_values(ascending=False)
문자열 시각을 시각 값으로 pd.to_datetime(events["time"])
「시」만 꺼내 새 열로 hours = times.dt.hour → events["hour"] = hours
CSV로 저장 failed.to_csv(path, index=False)
비어 있는 값 groupby는 null인 행을 빼고 센다

 

 

오늘 헬스 할 수 있을까요,,,, 일단 ,, 아됴,,

'공부 > SKT ALEPH' 카테고리의 다른 글

이상탐지 자동화 - 4  (0) 2026.11.05
이상탐지 자동화 - 3  (0) 2026.11.05
이상탐지 자동화 - 1  (0) 2026.11.02
접근통제 자동화 - 6  (0) 2026.11.02
접근통제 자동화 - 5  (0) 2026.10.30

+ Recent posts