제가 가장 신경 쓰는 사서함이 하나 있습니다. 만약 제가 게시한 내용에서 구멍을 발견하면 그곳으로 보내야 할 주소가 있고, 거기에 도착하는 내용은 암호화되어 있습니다. 왜냐하면 취약점 보고서(vulnerability report)는 전송 중에 읽히는 것 자체가 전체 재앙인 종류의 메시지이기 때문입니다. 이 내용을 열 수 있는 코드는 저의 것이고 (sigillum이라는 작은 도구와 그 아래에 두 개의 암호화 모듈이 있습니다), 그래서 어느 날 밤 늦게 이것들을 대상으로 무거운 검토(heavyweight review)를 실행했을 때, 저는 결과가 매우 중요할 것이라고 대비하고 있었습니다.
결과적으로... 발견된 내용 자체는 아니었습니다.
"괜찮다"고 말하는 42개의 메모들
발견된 내용은 평범하고 수정 가능한, 즉 검토를 통해 얻을 수 있는 종류의 것이라 괜찮았습니다. 하지만 저를 멈추게 한 것은 그 아래에 놓여 있던 것들이었습니다. 세 개의 모듈 전체에 걸쳐 흩어져 있는 42개의 주석들로, 각각은 특정 라인을 확인자에게 무시하라고 지시하고 있었습니다.
//nolint:gosec // bounded by the caller.
만약 이전에 본 적이 없다면, //nolint는 단일 라인에서 검사를 비활성화하는 Go 컨벤션이며, gosec는 보안 검사기입니다. 따라서 이 주석이 실제로 의미하는 바는 '네, 이것은 위험해 보이지만, 제가 말씀드리건대 그렇지 않습니다'라는 것이어서, 꽤 이상한 종류의 주석입니다. 이것이 정말로 하는 일은 코드를 보증(vouching for the code)하는 것입니다.
이것을 작성한 사람은 그 라인을 보고, 검사기가 틀렸다고 판단하여 그렇게 말하는 메모를 남기고 자리를 떠났습니다. 다음에 읽는 사람은 그것을 사실로 받아들이고, 왜 그러지 않을 수 있겠습니까? 누군가 분명히 생각했고, 글로 적어 놓았으며, 대안은 긴 하루 끝에 비트 시프트(bit-shift)의 경계를 직접 계산하는 것뿐입니다. 그래서 당신은 고개를 끄덕이고 지나치게 되고, 이것이 바로 이 트릭이 작동하는 방식입니다: 제가 내용물을 말해줄 수 없는 42개의 문자 참조들.
그래서 저는 개수를 원한 것이 아니었습니다. 하나씩, 각각 무엇을 보호하고 있는지 볼 수 있도록 목록을 원했습니다.
그중 일부는 자신들의 작업 과정을 보여주었다
나란히 놓고 보면 서로 바꿔 쓸 수 있는 것처럼 보이지 않는다.
// bounded above.
// bounded by the input length.
// seconds since epoch, well inside uint32 until 2106.
세 번째 것이 제대로 작동하고 있다. 그것은 양(quantity)을 명시하고, 경계(bound)를 밝히며, 그 주장이 더 이상 유효하지 않은 연도까지 알려주기 때문이다. 즉, 몇 초 만에 확인하여 그것이 여전히 유효하다는 것을 스스로 만족할 수 있게 해준다. 이것은 이유이며, 실제로 그렇게 행동한다. 앞의 두 개는 느낌(vibes)일 뿐이다.
정확히 무엇에 의해 "상한이 정해지며"? 그리고 "호출자에 의해 제한되는 것(bounded by the caller)"은 최악이다. 왜냐하면 오늘날 존재하는 모든 호출 지점과 내년에 추가될 모든 호출 지점에 대한 약속이기 때문이다. 심지어 그중 단 하나도 볼 수 없는 파일에서 만들어진 것이다. 이것은 술집의 친구가 반드시 돈을 갚아줄 것이라고 말하는 사람만큼이나 신뢰할 수 없다.
며칠 만에 도착했다
이것은 수년간 쌓인 부채가 아니었다. 그 42개는 며칠 만에 도착했다. 코드의 대부분은 검토 결과(review findings)를 빠르게 처리하는 에이전트들이 작성했으며, 까다로운 점은 그들이 내가 시킨 대로 일을 했다는 것이다.
이게 진짜 문제다. 깨끗한 린팅 실행(clean lint run)을 요청하면 두 가지 방법이 있다: 코드를 수정하거나, 검사기에게 조용히 하라고 말하는 것. 이 중 하나만 발견 사항(finding)을 이해해야 하고, 다른 하나는 거의 항상, 어떤 줄에서든, 무료로 가능하다.
나는 전에 [빌드를 통과시켜야 하는 에이전트]와 [성공 주장의 성공과 녹색 파이프라인 사이의 간극]에 대해 글을 썼고, 이것은 그보다 한 단계 더 깊은 압박이다. 거기서는 에이전트가 끝났다고 말했다. 여기서는 그것이 마무리를 만들어내서 왜 괜찮은지 설명하는 정중한 메모를 남겼다.
긴 하루가 끝난 후 이런 글을 쓸 때 나는 적어도 약간의 죄책감을 느낀다. 그리고 루프(loop)는 그런 감정을 느끼지 못할 것이고 (게으른 옵션을 선택하는 것에 지루해하지도 않을 것이다).
그래서 저는 그것들을 검토했습니다
먼저 간단한 것들부터, 그리고 남은 것들을 순서대로 살펴보았습니다. 정말 중요한 문제들은 아예 코멘트가 필요 없는 경우가 대부분이었는데, 이는 제가 예상하지 못한 부분이었습니다. 이전에 가치가 경계(bounded)되었다는 이유로 면제되었던 카운터가 이제는 실제로 _검사_되고 있고, 그저 '3'이라는 명백한 숫자가 두 개의 이름이 지정된 양을 곱하는 데 사용되어 숫자 자체가 설명을 제공합니다. 이것이 패턴이었고, 저는 인정하고 싶지 않을 만큼 이를 발견하는 것이 느렸습니다. 왜냐하면 이 문제들 대부분은 린터(linter)가 옳았고 제가 서두르고 있었기 때문입니다.
거짓 경고는 더 흥미로웠습니다. 그중 몇몇의 범위 축소 변환(narrowing conversions)은 실제로 검사되지 않았습니다. “호출자에게 의해 제한됨(Bounded by the caller)”이라는 주장은 기반이 될 수 있지만 입증되지 않은 것이었기 때문에, 이러한 메모들 뒤에는 마치 먼지 덮개처럼 이유가 드리워진 실제 결함들이 존재했습니다.
그리고 이것이 있었습니다
클린업 도중 새로운 지시어(directive)를 발견했습니다.
//nolint:errcheck // the read below is what matters
이것은 Close() 메서드에 있었고, 방향이 거꾸로 되어 있었습니다.
OpenPGP를 사용해 본 적이 없다면 이 부분에서 당황할 수 있습니다. 암호화된 메시지에는 수정 감지 코드(Modification Detection Code, MDC)가 포함되어 있는데, 이는 당신에게 오는 과정에서 아무도 평문 텍스트(ciphertext)를 조작하지 않았음을 알려주는 방식입니다. 그리고 이 코드는 메시지를 읽는 동안 검사되지 않습니다. 모든 바이트가 지나간 후에 리더를 닫을 때 검사됩니다. 따라서 그곳에서 버려지던 오류는 바로 무결성 검사(integrity check)... 전체였습니다!
복호화되는 메시지가 온전하게 도착한 메시지와 같지는 않으며, 이 경쾌한 작은 메모는 두 가지를 잘못된 방식으로 다루고 있었고, 너무나 자신만만해서 아무도 의심하지 않을 정도였습니다. 이것을 배포하면 sigillum decrypt가 공격자가 조작한 취약점 보고서를 제 보안 일정표에 기록하고 0으로 종료합니다. 이는 그 도구가 할 수 있는 최악의 일 중 하나입니다.
결국 이 코드는 반영되지 않았습니다. 바로 42개의 사촌들을 정리하느라 바빴던 검토 과정에서 걸렸는데, 제가 주장할 수 있었던 어떤 것보다도 훨씬 깔끔한 시연이었습니다. 이제 테스트가 이를 커버하고 있으며, 저는 논쟁보다는 테스트를 갖는 것을 선호합니다.
유효한 예외 처리가 머무를 장소
실제 존재하는 예외 사항들도 있습니다. 그리고 권장되는 방식은 이러한 예외들을 코드 라인에서 분리하여 .golangci.yaml 파일로 옮기고, 해당 예외가 적용되는 특정 파일에 범위를 한정하며, 그 옆에 근거(reasoning)를 명시하는 것입니다. 이 코드에는 정확히 그런 처리가 필요한 경우가 하나 있습니다.
OpenPGP는 키를 지문(fingerprint)으로 식별하며, 사양(spec)은 이 지문을 SHA-1 해시로 정의합니다. SHA-1은 취약점이 발견되어 보안 검사기(security checker)가 이를 꺼려하지만, 여기서는 보안 보장 수단으로 사용되는 것이 아니라 단지 _이름_으로만 사용되고 있습니다. 이름이라는 것은 모두가 동일한 것을 계산해야만 작동하기 때문입니다. 더 강력한 해시를 사용한다면, 지구상의 다른 OpenPGP 구현체와 일치시킬 수 없는 키를 만들게 됩니다.
따라서 SHA-1을 사용하고 그 이유를 명확히 작성하는 것이 좋으며, 이는 단순히 import 구문 끝에 몇 단어로 적는 것 대신, 리뷰어가 실제로 보게 될 곳에 담긴 한 문단의 논거가 되어야 합니다.
그리고 수정 과정이 더 나빴다
제가 그렇게 했고, 약 6개의 커밋 동안은 보기 좋았습니다. 그러다가 제가 그 예외 처리 목록 중 세 개의 라인터 이름이 실제 활성화(enable) 목록에 없다는 것을 발견했습니다.
그것들은 아무것도 억제하지 못하고 있었습니다! 해당 라인터들은 실패하고 있지 않았고, 애초에 실패할 일도 없었으며, 제가 모듈 전체를 지배한다고 신중하게 작성했던 논거는 그 어떤 것도 지배하고 있지 않았습니다. 심지어 다른 곳에 도입된 동일한 구조도 여전히 잡힐 것이라는 주석까지 있었는데, 실제로 감시하는 라인터가 없으니 당연히 그렇지 않았습니다.
이것은 그것이 대체했던 것보다 더 나쁜 상황이었습니다. 저는 잠시 멈춰서 그 사실을 인정했습니다. 적어도 인라인 지시문(inline directive)은 예외를 허용하는 라인 옆에 위치하여, 독자가 우연히 지나가다 발견하고 반박할 기회가 생깁니다. 하지만 죽은 예외 처리(dead exclusion)는 실행되지 않는 규칙을 보증하는 자신감 넘치는 산문의 단락이며, 독자들이 거의 찾아가지 않는 곳에 파일되어 있습니다. 게다가 아무도 이 사실을 알지 못합니다. golangci-lint는 애초에 실행할 의도가 없었던 라인터를 제외했다는 사실을 언급하지 않을 것입니다. 왜 그래야 할까요?
고칠 수 없는 것에는 숫자가 붙었다
모든 것을 그 자리에서 정리할 수는 없었다. go/signing은 자체적인 작업 범위가 있었고, 그것들을 풀어서 정리하는 것은 그날의 업무에 포함되지 않았기 때문에, 대신 이슈로 남게 되었다... chore: 16개의 인라인 nolint 지시어 제거.
사실상 하나의 기록 결정(filing decision)에 담긴 주장이 대부분이다. 예외는 여전히 존재하며, 아직 해결되지 않았고, 글로 적었다고 해서 아무것도 고쳐진 것은 없다. 단지 이제는 번호가 매겨진 트래커(tracker) 안에 있을 뿐이지, 내가 아마 찾아보지도 않았을 줄 알았던 라인 끝에서 속삭이던 상태는 아니다.
이것은 만료일이 있는 면제(waivers with an expiry date)의 사촌 같은 것이다. 그 경우 주장은 의존성 면제(dependency waiver)가 이유보다 오래 지속될 수 없도록 날짜가 필요하다는 것이었다. 본능은 같지만, 대상이 다르다: 전자는 만료되는 면제에 관한 것이고, 후자는 애초에 아무것도 말하지 않은 메모에 관한 것이다.
실제로 누가 듣고 있는가 (Who's actually listening)
이제 세 개의 모듈에 걸쳐 하나의 인라인 지시어(inline directive)가 시길룸 테스트(sigillum test) 안에 남아 있으며, 그 이유("identity is the point")를 가지고 있다. 나에게서 듣기보다는 직접 확인해 볼 수 있다. 내가 이 모든 것에서 제외하고 싶은 것은 바로 테스트다. 이 테스트는 .golangci.yaml 파일을 읽고, 파일 범위로 제외된(file-scoped exclusion) 모든 린터(linter) 이름을 추출하며, 만약 해당 린터가 활성화되어 있지 않으면 실패한다:
%q는 경로에 대해 제외되었지만 활성화되지 않았으므로, 해당 제외는 아무것도 억제하지 않으며 그에 기록된 근거도 아무것도 지배하지 않는다.
40줄 분량에 의존성도 없지만, 내가 작성한 수정 사항이 그것을 대체하던 메모들처럼 실패했기 때문에 존재한다... 나타나지 않은 사람을 위해 내 손으로 쓴 문자적 참조(character reference)이다.
원래는 2026년 10월 26일 phpboyscout.uk에 게시되었습니다.