
공격 표면의 뷰를 교차 검증하고 서로를 입증할 수 없는 엔드포인트를 찾아내세요.
공격 표면에 대한 여러 관점을 교차 검증하고, 서로를 입증할 수 없는 엔드포인트를 찾아냅니다.
엔드포인트는 스스로를 설명할 수 있어야 합니다. 코드에 존재한다면, 계약서가 그것을 기술해야 합니다. 계약서에 존재한다면, 무언가가 그것을 구현해야 합니다. 실제 트래픽을 받는다면, 어딘가에 존재하는 편이 좋습니다. 한 관점은 엔드포인트를 알고 있는데 다른 관점들은 모른다면, 그 간극이 바로 발견 사항입니다.
alibi는 OWASP noir를 실행하고, 그 JSON을 읽어, 관점들을 서로 비교합니다.
Noir는 이미 동일한 표면에 대한 다섯 개의 독립적인 관점을 읽습니다:
| 관점 | 읽는 출처 |
|---|---|
| code | 33개 언어에 걸친 200개 이상의 분석기 |
| doc | OpenAPI, RAML, WSDL, GraphQL SDL, AsyncAPI, gRPC, Smithy, TypeSpec, OData, OpenRPC |
| traffic | HAR, mitmproxy, Burp, Caido, ZAP, Postman, Insomnia, Bruno, .http |
| gateway | nginx, Apache, Envoy, Kong, Traefik, APISIX, Caddy, Istio, Kubernetes Ingress 및 Gateway API |
| infra | Terraform, CloudFormation, CDK, Serverless, Vercel, Netlify, Wrangler, Azure Functions, Kamal |
Noir가 하지 않는 일은 그것들을 비교하는 것입니다. 그것이 바로 여기서의 핵심 작업이며, noir를 변경할 필요가 없습니다 — alibi는 관점별로 한 번씩 실행하고 결과를 결합합니다.
관점별 실행이 중요한 이유가 있습니다. Noir는 모든 분석기에 걸쳐 (method, url) 기준으로 중복을 제거하므로, 동일하게 표기된 Flask 라우트와 OpenAPI 경로는 하나의 기술을 가진 하나의 엔드포인트로 합쳐집니다. 이는 발견 도구로서는 옳은 동작입니다 — 그것은 하나의 엔드포인트이니까요 — 하지만 이 도구가 측정하고자 하는 상호 입증을 지워버리며, 최악의 방향으로 지워버립니다: 두 관점이 더 잘 일치할수록 더 많은 것이 사라집니다. Casdoor는 372개의 코드 엔드포인트와 9개의 문서화된 엔드포인트로 스캔됩니다; 그 swagger/ 디렉터리만 스캔하면 명세에는 235개가 있습니다.
--only-techs는 탐지기 풀을 제한하므로, 관점당 한 번의 스캔으로 각 관점을 온전하게 유지합니다. 어떤 기술이 어떤 관점을 대변하는지는 views.yml에 있으며, 어떤 기술이 존재하는지는 noir list techs가 보고하는 바에 따릅니다.
alibi는 자체적으로 API 형식을 파싱하지 않습니다. 유일한 입력은 noir의 JSON입니다.
PATH에 noir 1.0.0 이상이 필요합니다 -- noir list techs가 하위 명령이 된 릴리스가 바로 그것이며, 그 카탈로그가 모든 기술을 관점에 할당하는 기준입니다. 개발은 현재 noir 릴리스를 추적합니다. 구버전 바이너리는 첫 카탈로그 읽기에서 실패하도록 내버려두지 않고 이름을 명시하여 거부됩니다.
$ uv tool install noir-alibi # or: pipx install noir-alibi
$ alibi scan ./my-service
$ alibi scan # the working directory
$ alibi scan ./service ./contracts ./prod.har # or wherever the views live
모든 경로는 소스이며, 뷰당 한 번씩 스캔됩니다. 가지고 있는 무엇이든 — 소스 트리, 스펙 디렉터리, 단일 캡처 파일 — 가리키면, 빠진 뷰들은 보고서를 범람시키는 대신 해당 규칙을 꺼버립니다.
alibi · 1 source · 377 endpoints
code 372 doc 235
230 corroborated -- vouched for by more than one view
19 endpoints nearly matched another view -- these may be matching failures, not real gaps
SHADOW Shadow API -- Implemented, but no contract describes it
134 findings · 4 critical, 57 high, 62 medium, 11 low
critical POST /api/upload-groups router.go:87
upload paths carry more consequence than reads
critical POST /api/upload-permissions router.go:208
...
... and 122 more (SHADOW in full: -f json)
TWO SURFACES?
The doc view is 97% under /api, and 37 of these findings are outside it.
If that is a separate surface the contract never covered, narrow the scan:
alibi scan <paths> --ignore '^/(?!api(/|$))'
If it is the same surface left undocumented, they are the findings that matter most.
그룹은 열두 개에서 멈춘다 — 정렬은 최악 우선이므로 꼬리 부분은 가장 정보가
적은 부분이며, -f json에는 그 전부가 담겨 있다.
noir용 플래그는 빈 -- 뒤에 두거나 --noir-arg를 통해 전달한다.
--exclude-path 같은 필터는 괜찮다. JSON 계약을 대체하거나 alibi의
뷰별 스캔을 무너뜨리는 플래그(--format, --diff-*, --only-techs, …)는
종료 상태 2와 함께 거부된다.
- run: alibi scan . ./contracts -f sarif > alibi.sarif
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: alibi.sarif }
보고서는 뷰들이 서로 일치하지 않는다고 말하며, --endpoints는 각 뷰가 무엇을 담고 있었는지 알려줍니다.
$ alibi scan ./repo -f json --endpoints
모든 뷰에는 목록이 부여됩니다. 키, 해당 뷰를 보증한 뷰들, 그 뒤의 기술들, 파일들, 그리고 정규화 이전의 철자입니다. 두 행이 일치했어야 하는데 일치하지 않았을 때 차이는 항상 여기에 있습니다. 이는 나머지 페이로드의 3~4배에 달하므로 기본값이 아니라 플래그로 제공됩니다.
또는 직접 게이트를 걸 수 있습니다: alibi scan . ./contracts --fail-on high는 발견 사항이 해당 심각도에 도달하면 0이 아닌 값으로 종료됩니다. noir가 전체를 읽지 못한 스캔은 executionSuccessful: false를 보고하므로, 성능이 저하된 실행이 깨끗한 실행으로 통과되지 않습니다. 의도적으로 건너뛴 심볼릭 링크, 바이너리 또는 이미지는 집계되지 않습니다.
Noir는 공통 문법을 새로 만들어내는 대신 각 프레임워크 고유의 라우트 문법을 유지하므로, 동일한 엔드포인트가 여러 방식으로 표기되어 도착합니다:
python_flask /api/users/<int:user_id>
aiohttp /users/{id}
java_spring /api/catalog/{id}
oas3 /v1/pets/{petId}
rails /posts/:id
nginx /admin/.*
이것들을 비교 가능하게 만드는 규칙은 다음과 같다: 경로 매개변수의 이름은 그 정체성의 일부가 아니다. {petId}와 <int:user_id>는 동일한 슬롯을 기술하며, 오직 그 위치와 /를 가로지르는지 여부만이 중요하다. 이름은 증거로서 보존되고 보고되지만, 결코 키에 도달하지 않는다.
발견 항목은 일치가 어떻게 이루어졌는지를 나타낸다:
| 등급 | 의미 |
|---|---|
G1 | 표기가 이미 일치함 |
G2 | 매개변수 구문이 정규화되면 일치함 |
G0 | 한쪽 뷰에만 존재함 — 아무것도 매칭되지 않음 |
이런 도구는 첫 실행에서 수백 건의 발견 항목을 보고하거나, 아무도 이루지 않은 진전을 보고함으로써 죽는다. 여섯 가지가 이를 견제한다:
두 뷰가 모두 없으면 규칙은 발동하지 않는다. 계약이 전혀 없는 코드베이스를 스캔하면 모든 엔드포인트가 기술적으로 문서화되지 않은 섀도 API에 해당한다. 그런 발견 항목은 문서를 제공하지 않았다는 사실 외에는 아무것도 말해주지 않으므로, 규칙은 그것이 추론하는 모든 뷰가 실제로 스캔에 포함되었을 때만 실행된다. 보고서는 제외된 규칙의 이름을 밝힌다.
근접 실패는 발견 항목이 아니라 의심으로 보고된다. "코드에는 있지만 문서에는 없음"은 "양쪽에 있지만 alibi가 이를 정렬하지 못함"과 구별할 수 없다. 따라서 한 뷰에 속한 엔드포인트는 근접 실패 여부를 다른 뷰와 대조하여 확인한다 — 동일한 경로에 다른 동사가 있거나, 한쪽에는 매개변수가 있고 다른 쪽에는 리터럴이 있는 한 세그먼트 차이 등이다. 근접 실패를 동반한 발견 항목은 등급이 낮아지고 검토 대상으로 표시된다. 그 수는 총계 옆에 표시되는데, 모든 발견 항목은 그 수가 적을수록 신뢰할 수 있기 때문이다.
결코 만나지 않은 뷰는 수백 건의 발견 항목이 아니라 하나의 진단이다. Argo CD는 Go에서 /api를 등록하고 그 아래 198개의 경로를 문서화하므로, 그 코드와 명세는 단 하나의 엔드포인트도 공유하지 않는다. 문자 그대로 읽으면 58개의 섀도 API와 198개의 유령 계약이 되지만, 그중 실제는 없다. 채워진 두 뷰 사이의 상호 확인이 전혀 없다는 것은 비교가 작동하지 않았다는 뜻이다 — 마운트 지점이 그 아래의 라우트를 대신하고 있거나, noir가 읽지 못한 스택이 있는 경우다 — 따라서 규칙은 보류되고 그 이유가 대신 출력된다. 다른 뷰로부터 그 아래에 많은 엔드포인트가 있는 것으로 밝혀진 경로는 추정 마운트로 표시된다.
한쪽에서 상수 접두사가 제거되면 두 뷰가 정렬될 때, 진단은 그 사실을 알리고 접두사의 이름을 밝힌다. Gitea의 생성된 명세는 basePath: /GITEA-API-APP-SUBURL/api/v1을 선언하는 반면, 그 Go 라우터는 /api/v1을 마운트한다. 두 뷰는 아무것도 공유하지 않지만, 해당 세 세그먼트를 제거하면 535개의 문서화된 경로 중 154개가 코드 경로와 일치한다. 이는 명세의 basePath, servers[].url, 또는 코드 리더가 누락한 마운트이며 — 보고되지만 결코 적용되지 않는다. 경로를 재정렬하면 그것이 발견한 버그가 숨겨지기 때문이다.
실제로는 하나의 누락된 하위 트리인 홍수는 하나로 명명된다: NodeBB의 354개 유령 계약 중 207개가 /api/v3 아래에 있으며, 코드 뷰에는 그곳에 아무것도 없다.
누락된 뷰와 빈 뷰는 정반대의 의미를 지닌다. Noir는 읽지 못한 것을 보고하며, alibi는 이를 발견 항목 위에 출력한다. NetBox는 308개의 경로를 가진 12.35MB의 OpenAPI 문서를 제공하지만, noir는 파일 크기 제한을 초과하여 이를 건너뛰며, 그 보고가 없으면 alibi는 프로젝트가 아무것도 문서화하지 않는다고 진술한다 — 단지 불완전한 것이 아니라, 확신을 가지고 잘못된 답을 진술하는 것이다.
실행을 멈춘 규칙은 아무것도 해결하지 않았다. 스캔을 기록하고 비교하는 것은 같은 실수를 거리상으로 재도입한다: 한 번의 실행에서 계약 디렉터리를 잊으면 SHADOW는 아무것도 평가하지 않으며, 순진한 차이 비교에는 이것이 모든 섀도 API가 닫힌 것과 정확히 똑같이 보인다. 5개 뷰 픽스처에서 인자 하나를 빼자 일곱 개의 상존 발견 항목이 "해결됨"으로 바뀌었다. 스냅샷은 어떤 규칙이 평가되었는지를 기록하고, 차이는 두 스캔 모두에서 실행된 규칙만 고려하며, 나머지는 NOT COMPARED 아래에 명명된다.
신호가 존재할 때만 부재가 증거가 된다. Noir의 인증 태거는 자신이 아는 프레임워크만 다룬다. 그들이 다루지 않는 스택에서는 어떤 것도 인증 태그를 달지 않으며, 이를 "인증되지 않음"으로 취급하면 모든 발견 사항이 승격되고 심각도 열이 의미를 잃게 된다. 누락된 태그에 반응하는 조정은 해당 태그가 먼저 스캔 어딘가에 나타나야만 작동한다.
| 규칙 | 조건 | 심각도 |
|---|---|---|
ORPHAN | 실제 요청을 받고 있으나 코드에 없음 | 높음 |
LIVE_UNDOC | 실제 요청을 받고 있으나 어떤 계약으로도 설명되지 않음 | 높음 |
SHADOW | 코드에는 있으나 어떤 계약에도 없음 | 중간 |
DANGLING | 구현된 것에 도달하지 못하는 게이트웨이 규칙 | 중간 |
DRIFT | 배포용으로 선언되었으나 코드에 없음 | 중간 |
PHANTOM | 계약에는 있으나 코드에 없음 | 낮음 |
UNEXPOSED | 구현되었으나 어떤 게이트웨이 규칙도 도달하지 못함 | 낮음 |
COLD | 구현되었으나 요청을 받는 것이 관찰된 적 없음 | 정보 |
그런 다음 심각도는 noir의 태거가 발견한 것에 따라 조정된다: 개인 데이터, 파일 업로드, 인증의 흔적 없음, 또는 상태를 변경하는 메서드.
뷰 맵(views.yml)과 규칙(rules.yml) 모두 코드가 아닌 데이터다.
하나의 location /api/는 그 아래의 모든 것을 대표하므로, 게이트웨이 및
인프라 규칙은 이것이 해당 엔드포인트에 도달하는가를 답하지 이것이 해당
엔드포인트를 포함하는가를 답하지 않는다. 집합으로 비교하면 모든 접두사
규칙은 아무도 구현하지 않은 경로처럼 보이고, 구현된 모든 경로는 도달
불가능한 것처럼 보인다.
커버리지는 의도적으로 관대하다. Noir는 규칙이 매칭하는 경로를 보고하지만
그것이 접두사로 매칭되는지 정확히 매칭되는지(location = /x, Ingress
pathType: Exact)는 보고하지 않으므로, 정확성은 복원될 수 없다 — 그리고
모든 규칙을 접두사로 취급하면 발견 사항을 만들어내는 대신 억제한다.
보고서는 각 라우팅 뷰가 코드의 얼마나 많은 부분에 도달하는지 알려주는데, "게이트웨이가 도달하지 못하는 34개의 엔드포인트"가 실제인지 여부는 해당 설정이 서비스를 앞단에서 감싸는 설정인지에 달려 있기 때문이다. 이를 정직하게 구분하는 임계값은 없다: Argo CD의 e2e 테스트 픽스처는 코드의 39%에 도달하고 NetBox의 실제 설정은 100%에 도달한다.