Hostveil / 문서 / 점수 산정

점수 산정

Hostveil은 모든 발견 항목을 하나의 0–100 숫자로 합칩니다. 이 페이지가 그 모델 전부입니다 — 이 숫자가 무엇을 재는지, 그것을 만들어 내는 산술은 무엇인지, 왜 뻔한 쪽이 아니라 그 산술인지, 그리고 이 숫자가 답할 수 없는 질문은 무엇인지. 아래의 모든 수치는 정확한 값이며, 따라가는 예시는 실제 스캔입니다.

이 숫자가 재는 것

점수는 들여다볼 수 있었던 도메인에 한해, 설정이 얼마나 단단한가를 잽니다. 이 호스트의 sshd_config가, Compose 파일이, 방화벽이, 유닛 파일이, 커널이 무엇이라고 말하는지 읽고, 그것과 방어할 수 있는 설정 사이의 거리를 채점합니다. 운이 아니라 사용자가 내린 결정을 재는 숫자입니다.

일부러 재지 않는 것이 세 가지 있습니다.

위험도 점수가 아닙니다. 위험을 알려면 침해당했을 때 무엇을 잃는지 알아야 하는데, 파일을 읽는 스캐너는 그것을 알 수 없습니다. 특권 모드로 도는 컨테이너는 사진첩을 서빙하든 회계 장부를 담고 있든 똑같은 발견 항목입니다. Hostveil은 얼마나 급한지 — 지금 이 순간 공격자와 그 문제 사이에 무엇이 얼마나 서 있는지 — 를 채점하고 거기서 멈춥니다.

호스트끼리의 비교가 아닙니다. 실행되지 못한 도메인은 총합에서 통째로 빠지므로, 두 호스트는 대개 서로 다른 분모 위에서 채점됩니다. Docker가 없는 호스트는 컨테이너 노출도, Docker 데몬도, 이미지 CVE도 채점되지 않습니다. 그 호스트의 88과 이 호스트의 88은 서로 다른 축 집합에 대한 평균입니다. 둘을 나란히 놓는 것은 서로 다른 두 질문을 비교하는 일입니다.

인증이 아닙니다. Hostveil은 셀프호스터가 실제로 털리는 경로를 점검하며, 그것은 엄선된 목록이지 빠짐없는 목록이 아닙니다. 만점은 실행된 점검들이 실질적인 것을 아무것도 찾지 못했다는 뜻이지, 찾을 것이 없다는 뜻이 아닙니다.

이 숫자가 잘하는 일은 한 호스트의 시간에 따른 변화, 그리고 그 아래에 붙는 축별 내역입니다. 요약 숫자는 지난주에 한 일이 무언가를 움직였는지 알려주고, 축별 점수는 다음에 무엇을 할지 알려줍니다.

점수를 부르는 이름

모든 인터페이스가 전체 점수를 보고 “이 호스트는 …”라는 문장을 완성합니다. 구간은 넷이고 문턱은 고정입니다. 아래 경계는 포함이라, 정확히 80점이면 상태가 좋습니다입니다.

점수구간화면에 뜨는 문장
80–100good이 호스트는 상태가 좋습니다.
50–79fair이 호스트는 그저 그렇습니다.
25–49poor이 호스트는 노출돼 있습니다.
0–24critical이 호스트는 활짝 열려 있습니다.

구간은 문구뿐 아니라 게이지 색도 결정하며, 세 인터페이스 모두에서 같습니다. 아무것도 스캔할 수 없었던 호스트는 N/A가 뜨고 구간도 붙지 않습니다. 분류할 점수 자체가 없기 때문입니다.

구간은 심각도와 방향이 반대라는 점만 한 번 짚어 둡니다. 점수가 높으면 좋은 호스트이고, 심각도가 높으면 나쁜 발견 항목입니다.

공식

탐지 도메인마다 이 하나씩 붙습니다. 축은 걸린 것이 없으면 100에서 시작하고, 그 축에 속한 발견 항목은 각각 남은 몫의 일정 비율을 가져갑니다.

remaining = 1.0
for each finding on this axis:
    remaining *= 1 - weight(finding)

score   = round(100 × remaining)
penalty = round(cap × (1 - remaining))

무게는 오직 심각도에서만 나옵니다. 패치가 없는 취약점이 왜 따로 봐주지 않는지는 고칠 방법이 없는 항목도 무게는 같습니다에 있습니다.

심각도이 호스트에 대해 주장하는 것가져가는 몫소수
HIGH아무것도 가지지 않은 사람이 지금 호스트 밖에서 닿을 수 있습니다.남은 몫의 1/20.5
MEDIUM발판이나 추측한 자격 증명, 로컬 계정이 있으면 무너집니다.1/80.125
LOW오늘 알려진 경로는 없고, 앞으로의 침해가 닿을 범위를 좁혀 줍니다.1/160.0625

기준점은 첫 줄입니다. HIGH 항목 하나가 그 축에 남아 있는 것의 정확히 절반을 가져갑니다. 나머지 두 단계는 전부 거기에 상대적으로 정의되며 독립적인 의미를 갖지 않습니다. HIGH와 MEDIUM 사이가 4배로 벌어져 있는 것은 부작용이 아니라 요점입니다. 이름은 익숙한 셋이지만 단계도 셋뿐입니다 — 이것이 대체한 체계에는 HIGH 위에 네 번째 단계가 있었고 위 두 단계가 8과 5였는데, 그것은 몸으로 느낄 수 있는 차이가 아닙니다. 그 둘을 합친 것이 이 간극을 벌어 준 것입니다.

축을 읽을 때 염두에 둘 만한 결과가 네 가지 있습니다.

같은 실수를 두 번 한 것은 한 번보다 나쁘지만, 두 배로 나쁘지는 않습니다. 서비스 네 개가 전부 user:를 빠뜨린 compose 파일은 한 줄이 네 번 빠진 것이지 독립된 위험 네 개가 아닙니다. 그래서 같은 ID의 두 번째 항목은 가중치의 절반, 세 번째는 1/3, 네 번째는 1/4만 가져갑니다. 이것이 없으면 축은 더 나쁜 호스트가 아니라 서비스가 많은 호스트에서 0으로 붙박입니다 — 템플릿 하나로 컨테이너 스무 개를 띄우는 셀프호스터에게는 빠진 한 줄이 축을 통째로 0으로 만듭니다. 첫 번째 이후가 공짜인 것도 아닙니다. 열 번째 항목은 그 실수가 구조적이라는 뜻이고 첫 번째보다 나쁩니다 — 열 배는 아닐 뿐입니다. 같은 ID 안에서는 가장 무거운 항목이 제값을 물기 때문에, 항목이 도착한 순서는 여전히 결과를 바꾸지 않습니다.

같은 항목이라도 성한 축에서 더 비쌉니다. 아무것도 걸리지 않은 축에 붙은 HIGH 항목은 50점을 가져갑니다. 이미 remaining = 0.25까지 내려간 축에 붙은 똑같은 항목은 12.5를 가져갑니다. 이것은 모델이 제대로 도는 모습입니다. 사용자의 sshd가 인증 없는 root를 두 번째로 내주는 일이 첫 번째의 두 배로 나쁘지는 않습니다.

축은 실제로 0에 도달하지 않습니다. 가장 큰 무게가 1/2이므로 곱해지는 값은 언제나 1/2 이상이고, 곱은 영원히 0보다 큽니다. HIGH 항목 여덟 개는 축을 100 → 50 → 25 → 13 → 6 → 3 → 2 → 1 → 0으로 끌어내리는데, 여덟 번째에 표시되는 0은 round(0.390625)이지 산술적으로 바닥난 것이 아닙니다. 아홉 번째 항목도 remaining을 더 깎고, 0에 붙박인 축에서 항목을 없애면 축이 다시 올라오되 그 움직임이 선형으로 보이지는 않습니다.

순서는 결과에 영향을 주지 않습니다. 곱셈은 교환법칙이 성립하므로 항목이 도착한 순서가 곱을 바꿀 수는 없습니다. 다만 반복 항목의 감쇠는 그것만으로는 이 성질을 깨뜨립니다 — 같은 ID가 서로 다른 심각도를 가질 수 있고, 그러면 먼저 본 쪽이 제값을 물게 되니까요. 그래서 같은 ID 안에서는 가장 무거운 항목을 앞으로 정렬해 그것이 제값을 뭅니다. 그러면 어떤 순서로 도착하든 곱해지는 값들의 나열이 같아지고, 계산 결과도 비트 단위로 같아집니다. 심각도 무게(1/2, 1/8, 1/16)는 2의 거듭제곱 분수라 이진에서 정확하지만 반복 감쇠의 나눗수(1/3, 1/6 …)는 그렇지 않은데, 고정된 순서가 그 차이를 드러나지 않게 합니다.

무언가 하나라도 붙은 축은 100이 될 수 없습니다. 이 모델에서 가장 작은 무게가 1/64이므로, 아무도 패치를 내놓지 않은 LOW 항목 하나만으로도 멀쩡하던 축은 round(98.4375) = 98이 됩니다.

축 점수와 감점은 서로 다른 숫자입니다

축은 둘 다 보고하며, 같은 remaining에서 각각 따로 반올림됩니다. 축 점수는 그 축을 자기 기준으로 본 0–100 값입니다. 감점은 그 축이 요약 숫자에 얼마나 기여했는지를 그 축 자신의 가중치로 나타낸 값입니다.

축 점수가 100 - penalty × 100 / cap이 아니라 round(100 × remaining)인 이유는 해상도입니다. 가중치 6짜리 축에는 정수 감점이 일곱 가지밖에 없으므로, 감점에서 점수를 유도하면 그 축은 서로 다른 점수를 일곱 개만 가질 수 있습니다. remaining에서 직접 뽑으면 가중치가 아무리 작아도 모든 축이 온전한 범위를 씁니다.

눈에 보이는 결과는 화면의 감점을 다 더해도 100 - 총점이 나오지 않을 수 있다는 것이고, 이것은 버그가 아닙니다. 열세 도메인이 모두 실행된 호스트에서 파일 권한(가중치 5)에 LOW 항목이 정확히 하나, 커널 하드닝(가중치 5)에 하나, 서비스 하드닝(가중치 6)에 하나 있다고 해 보겠습니다.

가중치remaining축 점수감점
파일 권한50.937594round(0.3125) = 0
커널 하드닝50.937594round(0.3125) = 0
서비스 하드닝60.937594round(0.375) = 0

세 축이 감점 0을 보고하는데 총점은 99입니다. 총점은 반올림하지 않은 기여값(0.3125 + 0.3125 + 0.375 = 1.0)을 더한 뒤 마지막에 한 번만 반올림하기 때문입니다. 축마다 먼저 반올림했다면 그 세 항목은 공짜가 됐을 텐데, 그것이 바로 다음 절에서 다룰 실패가 더 작은 문으로 들어온 모습입니다.

반올림, 정확하게

이 모델에서 반올림은 어디서나 0에서 먼 쪽입니다 — 12.5는 13이 되고 1.5는 2가 됩니다. 은행가 반올림이 아닙니다. 두 층위가 반올림하는 대상이 서로 다르기 때문에 딱 절반인 값이 갈리는 방향도 반대입니다. 축은 남긴 몫을 반올림하므로 절반은 호스트에 유리하게 가고, 총점은 잃은 몫을 반올림해서 빼므로 절반은 호스트에 불리하게 갑니다. SSH의 LOW 항목 하나(14 × 0.0625 = 0.875)와 CVE의 LOW 항목 하나(10 × 0.0625 = 0.625)를 더하면 정확히 1.5이고, 이것은 2로 반올림되어 총점 98이 됩니다. 남긴 몫을 반올림했다면 99가 나왔을 자리입니다.

무엇이 한 항목으로 세어지는가

발견 항목이 무언가를 깎기 전에 관문 세 개가 차례로 지나갑니다. 세 번째는 정상적인 빌드에서는 눈에 띄지 않지만 알아 둘 만합니다. 채점 축이 없는 도메인에서 나온 항목은 버려집니다. 깎을 대상이 없기 때문입니다.

수정됨으로 표시된 항목은 통째로 건너뜁니다. 수정을 적용하면 다시 스캔하기 전에 숫자가 곧바로 움직입니다 — 축을 깎지 않고, 심각도별 개수에서도 빠집니다.

중복은 하나로 접힙니다. 항목의 정체성은 도메인|ID|서비스이므로, 같은 규칙이 같은 서비스에서 두 번 걸려도 한 번만 채점됩니다. 같은 규칙이 다른 서비스에서 걸리면 별개의 두 항목이고 두 번 깎으며, 컨테이너 열두 개에 걸친 Compose 규칙에서는 그것이 정상입니다 — 서비스 열한 개에 걸린 compose.ds008은 열한 개의 항목이고, 그래야 맞습니다. 도메인이 정체성의 일부이므로 중복 제거가 축을 넘나드는 일은 결코 없습니다.

수정됨 항목은 지나가면서 자기 정체성을 차지하지 않으므로, 아직 고쳐지지 않은 동일 항목을 어떤 순서로 도착하든 가릴 수 없습니다. 그리고 축이 보고하는 심각도별 개수는 침식과 똑같이 관문을 통과한 단계에서 올라가므로, 그 개수에 들어 있는 항목은 전부 실제로 축을 움직인 항목입니다 — high 2, medium 1, low 2라고 적혀 있으면 그 다섯 개가 각각 몫을 가져간 것입니다. 다만 이 보장은 한 방향으로만 돕니다. 이 빌드가 알지 못하는 심각도는 축을 깎으면서도 어떤 개수 칸에도 걸리지 않으므로, 개수가 전부 0인 채로 축이 움직이는 일은 생길 수 있습니다.

공짜인 항목은 없습니다

이 빌드가 알지 못하는 심각도 — 열거형에 새로 추가됐는데 이 코드 경로가 배운 적 없는 단계 — 는 0이 아니라 MEDIUM으로 값이 매겨집니다. (파일에서 들어올 수는 없습니다. 스캔 스냅샷을 읽을 때는 알 수 없는 심각도를 추측하지 않고 그 자리에서 거부합니다.) 채점 모델이 무너지는 방식은 조용히 아무 값도 갖지 않는 항목이고, 척도의 한가운데를 기본값으로 두는 것은 그런 항목을 아예 만들지 않는 값싼 방법입니다.

왜 더하지 않고 곱하는가

뻔한 모델은 심각도마다 점수를 정해 축별로 더한 뒤 그 축의 가중치에서 자르는 것입니다. Hostveil은 실제로 그렇게 했고, 그 결과는 호스트에 더 이상 반응하지 않는 점수였습니다.

8 / 2 / 1점과 가중치 14짜리 축에서 HIGH 항목 둘이면 합이 16, 14에서 잘리고, 축은 0입니다. 그 뒤의 모든 항목은 공짜입니다. 세 번째도, 네 번째도, 쉰 번째도 아무 값을 갖지 않습니다. 가져갈 것이 남아 있지 않으니까요. 컨테이너 발견 항목이 27개인 호스트가 3개인 호스트와 똑같은 점수를 받았고, 사실상 모든 실제 머신에서 취약점 축과 컨테이너 축이 0에 붙박여 있었습니다.

측정으로서 나쁘지만 더 나쁜 쪽은 그것이 가르치는 내용입니다. SSH 항목 다섯 개 중 둘을 고친 운영자는 숫자가 움직이지 않는 것을 봅니다. 작업을 시작하기 전에 이미 잘려 있었고 끝난 뒤에도 여전히 잘려 있기 때문입니다. 도구는 그 사람에게 그 작업이 헛수고였다고 말하는 셈입니다. 하드닝에 반응하지 않는 하드닝 점수는 점수가 아닙니다.

곱셈 침식에는 부딪힐 절단선이 없습니다. 각 항목이 남아 있는 것의 일부를 가져가므로 축은 0을 향해 다가갈 뿐 도착하지 않습니다 — 쉰 번째 항목도 여전히 대가를 치르고, 어느 항목이든 하나를 없애면 숫자가 되돌아옵니다. 축이 망가질수록 항목 하나의 값이 싸지는 것은 옳은 모양입니다. 열 번째로 노출된 서비스의 한계 위험은 실제로 첫 번째보다 낮으니까요. 다만 그 값이 0에 닿지 않으므로, 이 모델은 듣기를 멈추지 않습니다.

실제 스캔으로 따라가기

일부러 엉망으로 만들어 둔 호스트의 실제 스캔입니다. 발견 항목 83개, scan은 1로 종료했습니다. 열두 도메인을 시도해서 열 개가 실행됐습니다 — Trivy가 설치되어 있지 않았고 systemd가 답하지 않았습니다. 그 열 개 중 하나인 Docker 데몬은 자기 영역의 일부만 확인했으므로, 83개 항목은 열 개 도메인에서 나왔고 그중 아홉 개가 완전했습니다.

이 스캔은 리버스 프록시 축이 생기기 전의 것입니다

도메인은 열둘이고 가중치도 그 릴리스의 것이라, 이 절의 숫자들은 위 표와 더 이상 맞지 않습니다. 오늘의 가중치로 다시 쓰지 않고 그대로 두었습니다. 이유는 분명히 적어 두는 편이 낫습니다. 모델의 성질이 아니라 이 페이지를 관리하는 방식의 구멍이기 때문입니다. 여기 정확한 기여값들은 손으로 유도한 것이고, 그 값이 나온 스캔은 저장소 어디에도 남아 있지 않으며, ScoreAxis는 반올림된 점수와 정수 감점만 내놓습니다 — 즉 저장소의 무엇으로도 이 숫자들을 재현하거나 검증할 수 없습니다. 새 가중치로 다시 쓴다는 것은 아무도 확인할 수 없는 값을 다시 유도한다는 뜻이고, 그것은 이 절의 목적과 정반대입니다. 이 절이 가르치는 바는 영향을 받지 않습니다. 가중치는 마지막 단계에서만 들어와, 가중치 없이 계산된 remaining에 곱해질 뿐이며, 아래의 모든 수치는 그 스캔에 대해 여전히 정확합니다.

축 하나, 한 단계씩

SSH는 끝까지 점검했고 항목 다섯 개를 보고했습니다.

발견 항목심각도무게1 − 무게남은 몫
(시작)1
ssh.emptypasswordsHIGH1/21/21/2 = 0.5
ssh.rootloginHIGH1/21/21/4 = 0.25
ssh.passwordauthMEDIUM1/87/87/32 = 0.21875
ssh.logingracetimeLOW1/1615/16105/512 = 0.205078125
ssh.maxauthtriesLOW1/1615/161575/8192 = 0.1922607421875

소수는 반올림한 값이 아니라 정확한 값입니다 — 곱해지는 값이 전부 2의 거듭제곱 분수이기 때문입니다. 표는 위에서 아래로 읽지만 결과가 거기에 의존하지는 않습니다. 다섯 줄의 순서를 바꿔도 곱은 그대로입니다.

score   = round(100 × 0.1922607421875)  = round(19.226074…)  = 19
lost    = 1 - 1575/8192 = 0.8077392578125
penalty = round(14 × 0.8077392578125)   = round(11.308349…)  = 11

이 축은 가중치 14에 대해 감점 11과 함께 19를 보고합니다. 11/14로 되짚으면 21이 나왔으리라는 점을 짚어 둘 만합니다 — 축 점수는 반올림된 감점이 아니라 remaining에서 나옵니다.

열두 축, 그리고 총점

가중치상태축 점수감점기여 (가중치 × 잃은 몫)
컨테이너 노출14완료01413.998940
SSH 하드닝14완료191111.308350
호스트 방화벽9완료5054.500000
자동 업데이트7완료4443.937500
취약점10건너뜀N/A제외
노출된 서비스8완료1277.062500
계정 위생7완료2555.250000
파일 권한5완료10000.000000
AI 에이전트 런타임8완료187.952148
커널 하드닝5완료6821.620417
Docker 데몬7부분7721.616699
서비스 하드닝6건너뜀N/A제외
ranCapSum = 100 - 10 - 6 = 84
total     = 57.246554

overall   = 100 - round(57.246554 × 100 / 84)
          = 100 - round(68.150660)
          = 100 - 68
          = 32

이 표에서 짚고 갈 것이 두 가지 있습니다. Docker 데몬 축은 부분입니다 — 자기 영역의 일부는 확인했고 전부는 못 했다는 뜻입니다 — 그래서 정상적으로 채점하고 표시를 남깁니다. 데몬을 절반 본 도메인은 어쨌든 절반은 본 것이기 때문입니다. 그리고 건너뛴 두 도메인은 100점을 받는 것이 아니라 자기 가중치를 분모에서 빼 갑니다. 만약 100점으로 채웠다면 분모 100에 감점 0을 얹는 셈이 되어 이 호스트는 43으로 읽혔을 것입니다. 아무도 들여다보지 못한 두 가지에 11점을 얹어 주는 셈입니다.

같은 호스트를 합산 모델로 채점하면

같은 83개 항목을 놓고 축마다 심각도 점수를 더한 뒤 가중치에서 자릅니다. SSH를 한 항목씩 보겠습니다.

이 항목까지더한 값누적잘린 뒤 (가중치 14)합산 모델 점수실제 점수
ssh.emptypasswords+8884350
ssh.rootlogin+81614025
ssh.passwordauth+21814022
ssh.logingracetime+11914021
ssh.maxauthtries+12014019

축은 두 번째 항목에서 바닥납니다. 이 데몬의 다섯 항목 중 셋이 공짜이고, 점수는 문제가 둘인 SSH 서버와 다섯인 SSH 서버를 구별하지 못합니다. 이제 사용자가 무언가를 고치러 갔을 때 두 모델이 어떻게 반응하는지 보겠습니다.

고친 것합산 모델곱셈 모델
LOW 두 개0 → 0 (합은 여전히 18이라 그대로 잘림)19 → 22
HIGH 두 개0 → 7119 → 77

호스트 전체로 보면 합산 모델은 이렇게 읽힙니다.

가중치Σ 심각도잘린 뒤합산 모델 점수실제 점수
컨테이너 노출141241400
SSH 하드닝142014019
호스트 방화벽9881150
자동 업데이트7107044
노출된 서비스8258012
계정 위생7167025
파일 권한500100100
AI 에이전트 런타임860801
커널 하드닝565068
Docker 데몬7444377

합산 모델의 총점은 100 - round(75 × 100 / 84) = 11이고, 실제 총점은 32입니다. 해당되는 열 축 중 일곱 개가 정확히 0으로 읽히는데, 그중에는 MEDIUM 하나와 LOW 넷을 갖고 실제로는 68점인 커널 하드닝도 들어 있습니다. 일곱 축이 모두 "0"인 호스트는 사용자에게 내놓을 다음 단계가 없습니다.

요점을 가장 깨끗하게 보여 주는 것은 컨테이너 축입니다. 이 축에는 발견 항목이 52개 있습니다 — HIGH 8, MEDIUM 16, LOW 28. 합산으로는 그중 둘에서 이미 가중치를 넘고 나머지 50개가 공짜입니다. 곱셈으로는 remaining이 7.5698 × 10⁻⁵인데, 이것은 0으로 표시될 뿐 0이 아닙니다. 52개 전부가 그 값을 움직였고, 그중 아무거나 하나를 없애면 값이 되돌아옵니다.

가중치는 비중이지 한도가 아닙니다

축마다 가중치가 하나씩 붙고, 열세 가중치의 합은 정확히 100입니다. 이 합계는 산술적 요구가 아니라 읽기 편하라는 결정입니다. 재정규화는 합계가 얼마든 동작하지만, 100이라는 합계가 축의 감점을 요약 숫자에서 깎인 점수로 읽을 수 있게 해 주고, 모든 도메인이 실행된 호스트에서 총점을 정확히 100 − round(total)로 만들어 줍니다.

가중치는 그 축이 총점에서 차지하는 몫이고 그것뿐입니다. 순수하게 그 도메인이 얼마나 중요한가에 대한 값입니다. 특히 이런 뜻입니다.

가중치는 그 축이 무너지기 전까지 흡수할 수 있는 피해의 한도가 아닙니다. 축 점수 계산에는 아예 등장하지 않습니다. 모든 축은 똑같은 침식으로 자기 기준의 0–100으로 채점됩니다. 가중치 5짜리 축과 14짜리 축에 똑같은 발견 항목이 있으면 축 점수도 똑같이 나옵니다.

가중치는 딱 한 번, 집계 단계에서만 등장합니다. cap × (1 - remaining)이 그 자리입니다. 그래서 40/100인 커널 하드닝이 총점을 끄는 힘은 40/100인 컨테이너 노출의 대략 3분의 1입니다. 커널 항목이 덜 참이라서가 아니라, 셀프호스팅 서버에서 그 도메인이 덜 중요하다고 논증됐기 때문입니다.

따라서 가중치가 작다고 그 호스트에서 작은 문제라는 뜻이 아닙니다. 축 점수가 그 도메인에 대한 정직한 값이고, 가중치는 그 위에 얹힌 편집상의 비중일 뿐입니다. 커널 하드닝이 12로 읽힌다면 총점이 무엇을 말하든 이 호스트에는 커널 하드닝 문제가 있는 것입니다.

도메인마다의 몫과 그 근거

아래의 논증은 전부 상대적입니다. 동률과 간격과 이전만 설명하는데, 가능한 논증이 그것뿐이기 때문입니다. 어느 도메인을 따로 떼어 놓고 13이 아니라 14가 나오게 하는 유도는 존재하지 않습니다. 몇몇 가중치는 그 정확한 숫자에 대한 적극적인 근거가 없고 그렇게 적어 두었습니다 — 그들이 가진 것은 이웃에 대해 방어할 수 있는 위치입니다.

가중치논증
컨테이너 노출1313이어야 할 적극적인 근거는 없습니다. 1점을 떼어 줘도 표현할 수 있는 것이 달라지지 않을 만큼 큰 두 축 중 하나입니다. 예전에는 Docker 관련 위험이 전부 여기로 떨어지던 유일한 자리라 데몬 몫까지 얹어서 매겨져 있었습니다. 이제는 그럴 필요가 없고, 이 축의 항목은 서비스가 무엇을 선언했는지에 대한 것만 남았습니다.
SSH 하드닝1313이어야 할 적극적인 근거는 없습니다. 해상도를 잃지 않고 위에서 떼어 줄 수 있을 만큼 큰 나머지 한 축이며, Docker 데몬 축이 자기 위치를 논증할 때 기준으로 삼는 원격 관리 피해 범위의 기준점입니다.
취약점99여야 할 적극적인 근거는 없습니다. 이미 후하다고 규정된 몫이며, 데몬 축과 마찬가지로 Docker가 있어야 성립하되 더 느리게 움직입니다. 열린 API는 오늘 당장 모든 컨테이너를 넘겨주지만 이미지의 패치 수준은 그보다 느린 문제입니다.
호스트 방화벽88이어야 할 적극적인 근거는 없습니다. 이미 후하다고 논증된 몫을 갖고 있었고, firewall.docker-bypass가 이미 데몬이 방화벽을 곧장 통과해 걸어 나간다고 말하고 있습니다.
노출된 서비스77이어야 할 적극적인 근거는 없습니다. AI 에이전트의 동률이 논증되는 기준점이며, 셀프호스팅 머신이 여는 가장 위험한 두 포트를 이 축은 판단할 수 없습니다 — 번호만으로는 Docker API가 TLS 검증을 요구하는지도, 443에서 응답하는 것이 건네받은 것을 어떻게 다루는지도 알 수 없습니다.
AI 에이전트 런타임7노출된 서비스와 일부러 동률이며, 그 동률 자체가 근거입니다. 둘 다 닿으면 안 되는 네트워크 서비스에 대한 이야기입니다. 에이전트 쪽 최악의 경우가 엄밀히 더 나쁘지만 — 인증 없는 게이트웨이는 실제 사용자 권한의 원격 코드 실행입니다 — 해당되는 호스트가 훨씬 적고 에이전트 런타임이 설치되지 않은 곳에서는 통째로 제외되므로, 후한 가중치가 보통 호스트에는 아무 비용도 아니면서 해당되는 곳에서는 제 무게를 갖습니다.
계정 위생7기준점입니다. 규칙이 적고 그중 하나가 파국적이며 나머지는 위생 수준인 집합입니다. 사용자 데이터베이스를 아예 읽을 수 없는 경우에만 제외됩니다.
자동 업데이트7기준점이며, Docker 데몬 축이 일부러 동률을 맞춘 두 축 중 하나입니다.
Docker 데몬7계정 위생 및 자동 업데이트와 일부러 동률이며, 그 동률 자체가 근거입니다. 계정 위생과 마찬가지로 규칙이 적고 그중 하나가 파국적이며 나머지는 위생 수준입니다. 그리고 둘 다와 마찬가지로 해당되지 않는 호스트에서는 통째로 제외됩니다.
서비스 하드닝6그 7의 무리 바로 한 칸 아래이고, 그 간격이 근거입니다. 유닛으로 뜬 서비스에 대해 컨테이너 노출이 Compose 서비스에 하는 일을 그대로 합니다 — 권한을 더 얻을 수 있는가, 자기 데이터 밖에 쓸 수 있는가, 모든 홈 디렉터리를 읽을 수 있는가 — 그래서 서비스를 네이티브로 돌리는 호스트에서는 그 축의 일을 대신합니다. 한 칸 아래인 이유는 이 축의 어떤 항목도 그 자체로 입구가 아니기 때문입니다. 각각은 침해를 내주는 것이 아니라 침해가 닿는 범위를 넓힐 뿐입니다.
리버스 프록시6서비스 하드닝과 일부러 동률입니다. 이 사용자층이 가장 갖고 있을 법하면서 가장 제대로 설정해 두지 않았을 구성 요소이고, 여기의 다른 모든 것이 443에서 응답하는 무언가 뒤에 있습니다 — 더 큰 몫을 주장할 만한 이유입니다. 6에 묶어 두는 것은 세 가지입니다. 규칙 집합이 일부러 좁습니다. 모호하지 않다는 기준으로 고른 항목 셋뿐이고, 평문 HTTP로 서비스하는 프록시도, 빠진 보안 헤더도, 인증서에 관한 것도 전부 사양했습니다 — 올바른 설정과 잘못된 설정이 파일만으로는 구별되지 않기 때문입니다. 프록시를 돌리지 않는 호스트에서는 통째로 제외되는데, 이는 이 축이 아래에 자리한 7의 무리와 같습니다. 그리고 서비스 하드닝과 마찬가지로 이 축의 어떤 항목도 그 자체가 들어오는 길은 아닙니다 — 인증 없는 Traefik 대시보드는 백엔드를 열어 주는 것이 아니라 열거해 줍니다.
파일 권한5커널 하드닝과 일부러 동률입니다. 둘 다 조용한 로컬 하드닝 안전망입니다. 어느 쪽도 공격자가 들어오는 구멍이 아니고, 각각은 발판이 root가 되는 것을 막는 쪽입니다.
커널 하드닝5같은 논증, 같은 문장, 같은 숫자입니다.
합계100

새 축 셋은 기존 축들이 갹출했습니다

합이 100이어야 하므로 도메인을 하나 더하는 일은 언제나 이전이지 부풀리기가 아닙니다. 최근에 생긴 축은 모두 누가 얼마를 왜 냈는지를 밝혀서 자금을 마련했습니다. 표에서 논증이 가장 많이 붙는 부분이기도 한데, 이전은 두 번 방어해야 하기 때문입니다 — 받는 쪽에 대해 한 번, 내는 축마다 한 번씩.

Docker 데몬 (7), 여섯 축에서:

낸 축낸 점수이유
컨테이너 노출2Docker 관련 위험이 전부 떨어지던 유일한 자리라 암묵적으로 데몬 몫까지 얹어서 매겨져 있었습니다. 이제는 그럴 필요가 없습니다.
SSH 하드닝1Docker가 도는 호스트에서 데몬 소켓은 두 번째 원격 관리 채널입니다. SSH만 한 피해 범위에, SSH의 하드닝은 하나도 없는.
취약점1둘 다 Docker가 있어야 성립하는데, 열린 API는 오늘 당장 모든 컨테이너를 넘겨주고 이미지의 패치 수준은 그보다 느린 문제입니다.
호스트 방화벽1firewall.docker-bypass가 이미 데몬이 호스트 방화벽을 통과해 걸어 나간다고 말하고 있습니다.
노출된 서비스1Docker 호스트가 열 수 있는 가장 위험한 포트를 데몬 축이 넘겨받습니다. 포트 표로는 결코 판단할 수 없었던 포트입니다.
AI 에이전트 런타임1후한 가중치의 근거가 "대부분의 호스트에서 N/A"였는데, 이제 그 근거를 주장하는 축이 하나 더 생겼습니다.

서비스 하드닝 (6), 여섯 축이 1점씩:

낸 축낸 점수이유
컨테이너 노출11점이 표현할 수 있는 것을 바꾸지 않을 만큼 커서 위에서 냈습니다.
SSH 하드닝1같은 이유입니다. 1점을 내도 표현력에 아무 비용이 들지 않을 만큼 큽니다.
취약점1이미 후한 몫입니다 — 그리고 패치되지 않은 이미지는 프로세스에서 끝나는데, 이 축은 그 프로세스가 그다음 무엇에 닿을 수 있는지에 대한 것입니다.
노출된 서비스1이미 후한 몫입니다 — 노출된 포트는 프로세스에서 끝납니다.
호스트 방화벽1이미 후한 몫입니다 — 우회당한 방화벽은 프로세스에서 끝납니다.
AI 에이전트 런타임1이미 후한 몫입니다 — 같은 "그다음 단계" 논증입니다.

마지막 넷은 하나의 논증을 공유합니다. 샌드박싱되지 않은 서비스는 그 넷 모두의 다음 단계입니다. 노출된 포트도, 패치되지 않은 이미지도, 우회당한 방화벽도 결국 프로세스 하나에서 끝나고, 이 축은 그 프로세스가 그다음 무엇에 닿을 수 있는지에 대한 것입니다.

리버스 프록시 (6), 같은 여섯 축에서 1점씩:

낸 축낸 몫이유
컨테이너 노출1이번에도 위에서 떼어 줬고, 두 번째 이유가 있습니다. 이 도메인이 읽는 호스트에서 프록시는 곧 Compose 서비스이며, 그 커맨드라인이 무엇을 켜 놓았는지가 지금까지는 컨테이너로만 채점되고 있었습니다.
SSH 하드닝1앞서와 같은 이유입니다. 1점이 표현력에 아무 비용도 되지 않을 만큼 큽니다.
취약점1이미 후하고, 둘은 같은 소프트웨어에 대한 같은 모양의 주장입니다 — 하나는 듣고 있는 것의 버전에 대해, 하나는 그것이 어떻게 설정됐는지에 대해. 설정 쪽이 운영자가 고른 절반입니다.
노출된 서비스1Docker 데몬 축이 폈던 것과 같은 관할 논증입니다. 포트 표는 443이 열려 있다고 말할 수 있지만, 셀프호스팅 머신에서 그것은 의도된 상태이므로 발견 항목이 아닙니다. 이 축은 거기서 응답하는 것을 판단할 수 있는 첫 번째 자리입니다.
호스트 방화벽1방화벽의 일은 무엇이 포트에 닿아도 되는지를 정하는 데서 끝납니다. 이 축이 보고하는 것은 전부 운영자가 일부러 연 포트의 건너편에 있습니다.
AI 에이전트 런타임1후한 가중치의 근거가 대부분의 호스트에서 N/A라는 점이었는데, 그 논증에 이제 세 번째 주장자가 생겼습니다. nginx도 Traefik도 없는 호스트는 저 축에서 그렇듯 이 축에서도 제외됩니다.

세 표가 함축하는 산술은 이렇습니다. 세 이전 이전에 컨테이너 노출은 17, SSH 하드닝은 16이었고, 취약점·노출된 서비스·호스트 방화벽·AI 에이전트 런타임은 각각 지금보다 3점씩 높았습니다.

그중 두 축은 같은 1점을 두 번 냈고, 그것이 드러나는 자리가 7의 무리입니다. 노출된 서비스와 AI 에이전트 런타임은 이제 둘 다 8에서 내려왔고 여전히 동률인데, 이는 이전의 결과가 아니라 이전을 맞춰 넣은 제약이었습니다. 그 동률은 위에서 논증돼 있고, 새 축이 지나가는 길에 깨도 되는 것이 아닙니다.

재정규화: 도메인이 실행되지 않으면 어떻게 되는가

"들여다보지 못했다"와 "거기에 아무것도 없었다"는 똑같이 빈 목록을 만들어 내면서 정반대를 뜻합니다. 재정규화의 요점은 그 둘을 산문에서만이 아니라 산술에서도 갈라놓는 것입니다.

도메인은 실행된 경우에만 총점에 기여하며, 실행된 것으로 세는 상태는 정확히 둘입니다 — 완료부분. 대기, 건너뜀, 실패는 세지 않고, 상태가 아예 기록되지 않은 도메인도 세지 않습니다. "기록 없음"은 점검기가 끝까지 돌았다는 증거가 아니기 때문입니다.

상태실행된 것으로 세나?총점에 미치는 영향
완료채점되고 가중치가 분모에 들어갑니다.
부분확인한 범위로 채점하고 가중치도 분모에 들어가며, 모든 UI에 표시가 남습니다.
건너뜀아니오N/A. 가중치가 분자와 분모 양쪽에서 빠집니다.
실패아니오N/A, 건너뜀과 같습니다 — 0점이 아니라 제외입니다.
대기 / 기록 없음아니오N/A.
ranCapSum = Σ cap        over axes that ran
total     = Σ cap × lost over axes that ran

overall   = 100 - round(total × 100 / ranCapSum)

열세 도메인이 모두 실행되면 ranCapSum은 100이고 총점은 100 - round(total)입니다. Trivy가 없으면 91이 되고, 나머지 열두 축이 그 빈자리를 채우도록 재정규화됩니다 — Trivy를 돌리지 않았다고 벌점을 받지도 않고, 그 덕에 공짜 9점을 받지도 않습니다.

부분은 채점되고, 언제나 표시됩니다. 자기 영역의 일부를 확인한 도메인에는 진짜 발견 항목과, 불완전할지언정 진짜 그림이 있습니다. 제외해 버리면 본 것을 버리는 셈입니다. 그러나 표시 없이 반쪽 도메인에서 뽑은 점수는 건너뛴 도메인에 100점을 주는 것과 같은 거짓말이므로, 이 표시는 선택 사항이 아닙니다 — TUI는 42~로 쓰고, 대시보드는 호박색으로 씁니다.

아무것도 실행되지 않았을 때

모든 도메인이 건너뛰거나 실패했다면 분모가 없고, 가중치 0으로 재정규화하면 깔끔한 100이 떨어집니다 — 아무도 들여다볼 수 없었던 호스트가 흠 없는 것으로 보고됩니다. Hostveil은 그 계산을 수행하는 대신 거부합니다. 실행된 축이 하나도 없으면 별도의 적용 가능 플래그가 거짓이 되고, 세 UI 모두 숫자 대신 N/A를 그립니다. 그 자리에 남는 값은 0이고 아무 뜻도 없으니 읽지 마십시오. 총점 0은 정말 형편없는 호스트이거나 이 자리 채우기이며, 둘을 구별하는 것은 그 플래그뿐입니다.

고칠 방법이 없는 항목도 무게는 같습니다

Unavailable로 표시된 항목 — 아무도 패치를 내놓지 않은 취약점 — 도 심각도가 정한 몫을 그대로 가져갑니다. 따로 깎지 않습니다.

심각도가져가는 몫
HIGH1/2
MEDIUM1/8
LOW1/16

예전에는 이런 항목의 무게를 4로 나눠서 반영했습니다. Debian 계열 이미지는 전부 업스트림 수정이 없는 CVE를 싣고 있으니, 그걸 전액으로 매기면 이미지를 공개된 날 전부 받아 오는 호스트에서도 취약점 축이 0 근처에 고정된다는 논리였습니다. 그 논리 자체는 지금도 맞습니다. 그런데도 이 감면을 없앤 것은, HIGH 하나가 정확히 절반을 가져간다는 기준이나 반복 항목의 조화급수 감쇠와 달리, 나눗값 4에는 그럴 만한 근거가 없었기 때문입니다 — 모델의 다른 부분에서 자연스럽게 나온 값이 아니라 그냥 골라잡은 숫자였습니다. 그 결과는 해결한 게 아니라 그대로 받아들인 것입니다: 고칠 수 없는 CVE가 하나라도 있는 호스트는 취약점 축에서 영영 100점에 못 미칠 수 있습니다. 패치가 있든 없든 위험은 똑같이 실재하니까요.

Unavailable은 Manual과 다릅니다

이제 둘 다 무게를 온전히 내므로, 차이는 무게가 아니라 “안 고쳐진 이유”에 있습니다. Manual 항목 — Hostveil이 고치는 대신 설명하는 것들, compose.ds016이나 모든 dockerd. 항목 같은 — 은 안전하게 자동화할 방법이 없다는 뜻이라 사용자가 직접 가서 고칠 수 있습니다. Unavailable어디에도 수정이 존재하지 않는다는 뜻이라 가서 할 일 자체가 없습니다. 점수에 미치는 영향은 이제 똑같지만, 왜 아직 열려 있는지는 이 두 상태로 계속 구분해 둡니다.

이 점수가 알려주지 않는 것

정직한 한계들입니다. 버그처럼 보이지만 버그가 아닌 것들도 포함해서요.

100이 발견 항목 0을 뜻하지는 않습니다. 모든 도메인이 실행된 호스트에서 가중치 5짜리 축의 LOW 항목 하나는 5 × 0.0625 = 0.3125를 기여하고, 이것은 반올림하면 0이 됩니다. 화면에 항목이 떠 있는 채로 총점은 100으로 읽힙니다. 남은 것 중 실질적인 것이 없다로 읽어야지 아무것도 찾지 못했다로 읽으면 안 되며, 축별 내역도 함께 읽어야 합니다. 거기서 그 축은 94로 나옵니다.

두 번째 숫자 — “조치 후”

점수 옆에, Hostveil이 제공하는 조치를 전부 적용하면 몇 점이 되는지를 함께 보여 줍니다. Auto-fix 조치와 Review 조치 양쪽 모두를 적용한 값이고, 축별로도 같은 숫자가 나오므로 여유가 어디에 남아 있는지 바로 보입니다.

이 숫자가 있는 이유는, 점수 하나만으로는 아무도 묻지 않은 질문에 답하기 때문입니다. 컨테이너가 많은 호스트에서 fix --all을 돌려도 컨테이너 축이 여전히 2로 보일 수 있습니다. 잘못된 것은 없습니다. 남아 있는 항목이 설계상 Manual이기 때문입니다 — Portainer에 마운트된 Docker 소켓, 두 번째 UID 0 계정, 호스트 네트워킹. 각각 Hostveil이 이유를 말해 줍니다. 하지만 거의 움직이지 않은 숫자는 “도구가 아무 일도 안 했다”로 읽히고, 그 읽기는 틀렸습니다. 두 번째 숫자가 어느 쪽인지 말해 줍니다.

100까지의 간격은 천장이 아닙니다. 그것은 Hostveil이 무인으로 하지 않는 부분이지, 할 수 없는 부분이 아닙니다. 그 계정을 지우거나 소켓 마운트를 걷어내는 일은 오늘 오후에 할 수 있고, 그러면 점수도 따라옵니다.

다른 선택지는 Manual 항목의 가중치를 깎는 것이었고, 그럴듯해 보이는 바로 그 지점에서 잘못된 거래입니다. Manual은 Hostveil이 손대지 않는다는 뜻이지 위험이 작다는 뜻이 아닙니다 — 사용자가 직접 가서 없앨 수 있으니까요. 충분히 제거할 수 있는 위험을 깎아 주는 것은 이 모델이 다시 만들어지면서 거부한 바로 그 아첨이고, 고칠 방법이 없는 취약점을 깎지 않는 것도(위 참고) 같은 이유입니다 — 오늘 당장 손댈 수 없다고 위험이 작아지지는 않습니다.

고칠 것이 하나도 없으면 이 숫자는 점수와 같아지고, 그럴 때 모든 인터페이스는 제자리를 가리키는 화살표 대신 아무것도 표시하지 않습니다.

축의 0은 바닥이 아닙니다. 표시용 값일 뿐이고, remaining은 여전히 양수이며 여전히 움직이고 있습니다. 0인 축에서 항목을 고치는 일은 실제로 효과가 있고, 충분히 없애고 나면 숫자가 올라가기 시작합니다. 축별 내역이 점수뿐 아니라 개수까지 함께 싣는 이유가 이것입니다.

아직 적용되지 않은 수정은 숫자를 움직이지 않습니다. Hostveil이 쓴 파일이 호스트가 실제로 돌고 있는 근거가 아닌 경우 — 컨테이너를 아직 다시 만들지 않은 Compose 파일, 유닛이 아직 다시 읽지 않은 systemd 드롭인, 다음 부팅에야 적용되는 sysctl 드롭인 — 수정은 적용된 것으로 기록되지만 해당 항목은 점수에서 계속 감점된 채로 남습니다. 그런 수정은 항목 아래 문장과 fix --all 요약에서 그 사실을 밝히고, 무엇을 실행해야 실제로 적용되는지 알려 줍니다. 그 명령을 실행하면 다음 스캔이 개선을 반영합니다.

그래도 데몬보다 숫자가 먼저 움직일 수 있습니다. 위 규칙은 “무언가가 이 파일을 다시 읽어야 한다”는 사실을 수정 자체가 알고 있을 때 작동하며, 한 가지 경우는 의도적으로 빠져 있습니다. sshd_config 편집입니다. SSH 점검기가 바로 그 파일을 읽으므로 재점검은 변경을 실제로 확인해 주지만, 그동안 sshd는 시작할 때 읽어 둔 설정으로 계속 서비스하며 방금 끈 것을 여전히 허용하고 있을 수 있습니다. 수정이 안내하는 대로 재시작하면 둘이 다시 일치합니다.

두 호스트의 점수는 비교할 수 없습니다. 위에서 말한 그대로입니다. 설치된 것이 바뀐 전후의 같은 호스트도 마찬가지입니다. Trivy를 설치하면 취약점 축이 평균에 합류하고, 설정에 대해서는 아무것도 바뀌지 않았는데 총점이 움직입니다.

사용자의 서비스가 무엇을 하는지에 대해서는 아무 말도 하지 않습니다. 설정에서 유도된 어떤 점수도 그럴 수 없습니다. 완벽하게 하드닝된 호스트 위에서 인증 우회가 있는 애플리케이션이 돌고 있어도 점수는 100이고, 그 점수는 호스트에 대해서는 옳고 사용자의 상황에 대해서는 쓸모없습니다.

Hostveil이 점검하지 않는 것에 대해서도 아무 말도 하지 않습니다. 백업, git 이력에 들어간 비밀값, 리버스 프록시가 내주는 인증서, 로그를 읽는 사람이 있는지 여부. 점수는 그 전부에 대해 침묵하며, 침묵은 합격이 아닙니다.