
안녕하세요
저희 학원 윗층에서 철거 공사 진행중인데 정말 시끄럽고 쿵쿵거리네요..
아직 상태가 멀쩡한게 아니라서 살짝 어지럽습니다..
그럼 레츠고
오늘의 수업
교시내용
| 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로 사용자를 찾습니다.
세 로그를 표 하나로 합쳐 사용자 순, 같은 사용자는 시각 순으로 놓으면 사용자별 이벤트를 시각 순서로 확인할 수 있습니다.
연결됐다고 보는 조건은 둘입니다.
- 서로 다른 로그(source)가 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 |


