새 이름을 넣고 첫 화면 사진을 바꾸면 홈페이지가 달라 보인다. 그런데 이미지의 대체 설명이나 공유 제목, 아래쪽 안내, 남겨 둔 파일 이름에는 이전 자료가 남을 수 있다. 나는 교체 작업을 화면의 몇 군데를 바꾸는 일로 끝내지 않고 공개되는 자료의 범위를 정해 검사하는 일로 이어간다.

현장 교체 엔진에는 새 정보로 공개 메타데이터를 만들고 알려진 이전 식별자가 남으면 생성을 멈추는 코드가 있다. 이번 편집실에는 비공개 근거를 본문에서 제외하고 검토된 미디어만 발행 참조에 따라 공개하는 흐름을 더했다. 여기서는 이 경험을 작은 읽기 전용 검사기로 정리했다. 텍스트 검사가 이미지 속 글자까지 판단했다고 설명하지 않는다.

검사할 것은 원본 폴더가 아니라 공개 산출물이다

작업 폴더에는 원본, 비공개 검수 메모, 자격증명, 이전 버전이 함께 있을 수 있다. 이것들을 모두 공개 파일처럼 검사하면 실제 노출 범위를 오해한다. 반대로 원고 파일만 검사하면 빌드 과정에서 들어온 제목과 공유 설명, 복사된 자산을 놓칠 수 있다. 나는 실제 배포되는 파일 목록과 서버에서만 쓰는 묶음을 먼저 구별한다.

아래 모듈은 호출한 사람이 명시한 공개 폴더를 읽는다. 이 사이트의 배포기처럼 서버 모듈을 별도 Worker 묶음으로 전달하는 계약을 선택한 경우에만 그 파일을 제외한다. 이름이 비슷하다는 이유로 임의 파일을 빼지 않는다. 원격 CMS의 본문과 미디어는 이 정적 폴더 검사에 포함되지 않으므로 별도 API 검토가 필요하다.

큰 글씨 밖에도 이전 설명이 남을 수 있다

화면에서 크게 보이는 제목은 교체하기 쉽다. 하지만 대체 텍스트, 메타 설명, 공유 제목, 링크 속성, 파일 경로는 직접 눈에 들어오지 않을 수 있다. 교체 엔진의 코드는 새 자료를 기준으로 제목과 공유 정보를 다시 만들고 이미지 설명도 바꾼다. 그 뒤에도 알려진 이전 식별자를 공개 HTML에서 확인한다.

이 작은 검사기는 알려진 마커를 비공개 입력으로 받는다. HTML 엔티티와 유니코드 표기를 풀고 공백을 정리해 본문·속성·파일 이름을 비교한다. 태그 사이에 나뉜 문구와 유효한 JSON 문자열도 확인한다. 알지 못하는 이름이나 모든 난독화 표현을 찾는 보편적인 판별기는 아니다. 결과에는 일치한 문장 대신 마커 번호를 남긴다.

이미지 해시는 검토한 파일이 그대로인지를 묻는다

이미지의 이름을 바꾸는 것과 이미지 안의 식별 정보를 없애는 것은 다르다. 사진 속 간판, 도면의 표제, 영상의 자막과 음성, 문서의 마지막 페이지에는 옛 정보가 남을 수 있다. 이런 자료는 실제 내용을 보고 공개 범위와 사용 조건을 확인해야 한다. 텍스트 검사 결과가 깨끗하다는 이유로 시각 자료를 승인하지 않는다.

직접 검토한 파일에는 그 시점의 SHA256을 기록할 수 있다. 이후 같은 경로의 해시가 다르면 다시 검토하도록 하는 것이다. 해시가 같다는 것은 승인했던 바이트와 같다는 뜻이며, 그 승인 판단이 올바르다는 증명은 아니다. 아래 구현은 승인 없는 미디어와 바뀐 미디어를 각각 표시하고 이미지 OCR이나 권리 판단을 수행하지 않는다.

문의 수신 설정은 화면 문구와 따로 확인한다

문의 버튼의 이름을 바꿨다고 실제 수신 대상이 바뀌는 것은 아니다. 폼이 호출하는 함수, 서버의 수신 설정, 사용하는 서비스 연결에는 별도 정보가 들어갈 수 있다. 이를 공개 코드 예시에 그대로 적는 대신 비공개 설정에서 새 사이트의 대상과 연결을 검토한다. 필요하다면 승인된 시험 요청의 전달 결과도 따로 확인한다.

이 모듈은 폼을 전송하지 않으며 이메일 전달을 검증하지 않는다. 이번 홈페이지의 공개 연락 방식은 이메일 작성 연결로 정했다. 다른 프로젝트의 폼을 적용할 때는 버튼의 이동과 실제 서버 전달을 각각 확인해야 한다. 화면에 올바른 글자가 있다는 사실을 수신 성공으로 바꾸어 말하지 않는 것이 중요하다.

원본을 바꾸지 않는 검사 구현

다음 코드는 이번 글을 위해 구현한 재사용 검사기다. 공개 폴더와 비공개 마커 목록, 직접 검토한 미디어 해시 목록을 입력받아 결과만 돌려준다. 파일을 지우거나 자동 교체하지 않는다. 링크와 폴더 연결이 검사 범위 밖으로 나가면 확인 항목으로 남기고 따라가지 않는다.

기본값은 선택한 폴더의 모든 파일을 공개 후보로 본다. 특정 배포 계약을 고르는 책임은 호출한 쪽에 있다. 읽을 수 없는 텍스트, 너무 큰 텍스트, 정체를 확인하지 않은 파일도 통과로 처리하지 않는다. 이 확인 항목을 해결한 뒤 다시 실행하고, 실제 배포 파일 목록과 같은 범위인지 대조한다.

코드와 구현 살펴보기

Python · 읽기 전용 공개 산출물 검사

"""Read-only checks for an explicitly selected public artifact directory.

Marker values are private inputs. Results carry marker indexes, never excerpts.
Media approvals are prior human reviews tied to exact bytes, not OCR or rights checks.
"""
import argparse
import hashlib
import html
from html.parser import HTMLParser
import json
from pathlib import Path
import re
import unicodedata

TEXT = {'.html', '.htm', '.css', '.js', '.mjs', '.json', '.xml', '.txt', '.svg', '.webmanifest'}
MEDIA = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.svg', '.mp4', '.webm', '.mov', '.mp3', '.wav', '.ogg', '.pdf', '.zip', '.woff', '.woff2', '.ico'}
SERVER = {'_worker.js', '_headers', '_redirects', '_routes.json'}

def normalized(value):
    return ''.join(unicodedata.normalize('NFKC', html.unescape(value)).casefold().split())

class PublicHTML(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.text = []
        self.attributes = []
    def handle_data(self, value):
        self.text.append(value)
    def handle_starttag(self, tag, attributes):
        self.attributes.extend(value for _, value in attributes if value)
    handle_startendtag = handle_starttag

def representations(value, suffix):
    result = [value, html.unescape(value)]
    if suffix in {'.html', '.htm', '.svg', '.xml'}:
        parser = PublicHTML()
        parser.feed(value)
        result += [''.join(parser.text), *parser.attributes]
    # Decode only valid JSON string literals, without evaluating JavaScript.
    for match in re.finditer(r'"(?:[^"\\]|\\.)*"', value):
        try:
            decoded = json.loads(match.group())
            if isinstance(decoded, str):
                result.append(decoded)
        except ValueError:
            pass
    return [normalized(v) for v in result]

def digest(path):
    value = hashlib.sha256()
    with path.open('rb') as stream:
        for part in iter(lambda: stream.read(1024 * 1024), b''):
            value.update(part)
    return value.hexdigest()

def scan(root, markers, approved_media=None, contract='all-files', max_text_bytes=16 * 1024 * 1024):
    root = Path(root).resolve(strict=True)
    if not root.is_dir() or contract not in {'all-files', 'pages-worker-bundle'}:
        raise ValueError('Explicit public directory and supported deployment contract required')
    markers = [normalized(v) for v in markers]
    if not markers or any(len(v) < 3 for v in markers):
        raise ValueError('Nonempty private markers of at least three normalized characters required')
    approved_media = approved_media or {}
    findings, reviewed, excluded = [], 0, []
    def flag(relative, reason, **extra):
        public_path = relative
        if any(marker in normalized(relative) for marker in markers):
            public_path = '[identity-path:' + hashlib.sha256(relative.encode('utf-8')).hexdigest()[:16] + ']'
        findings.append({'path': public_path, 'reason': reason, **extra})
    # Do not follow directory links/junctions. A link itself requires review.
    pending = [root]
    while pending:
        folder = pending.pop()
        for path in sorted(folder.iterdir()):
            relative = path.relative_to(root).as_posix()
            resolved = path.resolve()
            if path.is_symlink() or (hasattr(path, 'is_junction') and path.is_junction()) or not resolved.is_relative_to(root):
                flag(relative, 'linked-or-outside-root')
                continue
            if contract == 'pages-worker-bundle' and (relative == '_worker_modules' or relative in SERVER):
                excluded.append(relative)
                continue
            if path.is_dir():
                if path.name.casefold() in {'private', '.git', 'node_modules'}:
                    flag(relative, 'private-directory-in-public-output')
                else:
                    pending.append(path)
                continue
            reviewed += 1
            suffix = path.suffix.lower()
            path_text = normalized(relative)
            for index, marker in enumerate(markers, 1):
                if marker in path_text:
                    flag(relative, 'known-identity-in-path', marker_index=index)
            if path.name.casefold().startswith('.env') or suffix in {'.pem', '.key', '.sqlite', '.db', '.map'}:
                flag(relative, 'private-or-unreviewed-artifact')
                continue
            if suffix in TEXT:
                if path.stat().st_size > max_text_bytes:
                    flag(relative, 'text-too-large-to-check')
                else:
                    try:
                        views = representations(path.read_text('utf-8-sig'), suffix)
                        for index, marker in enumerate(markers, 1):
                            if any(marker in view for view in views):
                                flag(relative, 'known-identity-in-text', marker_index=index)
                    except (UnicodeError, OSError):
                        flag(relative, 'text-unreadable')
            if suffix in MEDIA:
                expected = approved_media.get(relative)
                if not expected:
                    flag(relative, 'media-needs-human-review')
                elif not isinstance(expected, str) or digest(path) != expected:
                    flag(relative, 'reviewed-media-bytes-changed')
            elif suffix not in TEXT:
                flag(relative, 'unknown-artifact-needs-review')
    return {'passed': not findings, 'reviewed_files': reviewed, 'excluded_by_explicit_contract': excluded,
            'findings': findings, 'scope': 'Known markers in selected static output; exact reviewed media hashes. No OCR, remote CMS/R2 enumeration, form delivery or browser rendering.'}

if __name__ == '__main__':
    cli = argparse.ArgumentParser(description=__doc__)
    cli.add_argument('public_root')
    cli.add_argument('--markers', required=True, help='Private JSON array of old identity strings')
    cli.add_argument('--media-approvals', help='Private JSON object: public relative path -> reviewed SHA256')
    cli.add_argument('--contract', choices=['all-files', 'pages-worker-bundle'], default='all-files')
    args = cli.parse_args()
    result = scan(args.public_root, json.loads(Path(args.markers).read_text('utf-8-sig')),
                  json.loads(Path(args.media_approvals).read_text('utf-8-sig')) if args.media_approvals else {}, args.contract)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    raise SystemExit(0 if result['passed'] else 1)

이번에 확인한 범위와 남은 직접 검토

이번에는 별도의 합성 파일로 14가지 검사를 실행했다. 화면 문구, 엔티티로 적힌 대체 설명, 태그로 나뉜 이름, JSON 유니코드, 파일 이름, 전각 문자, 미승인 미디어, 승인 해시와 변경 해시, 읽을 수 없는 텍스트, 비공개 파일, 모르는 파일, 명시한 서버 묶음 계약과 기본 계약을 확인했다. 검사 전후의 파일 해시도 같았다.

이 결과는 해당 조건의 코드 동작을 뒷받침한다. 모든 과거 홈페이지를 새로 전수 검사했다는 뜻은 아니다. 원격 미디어 내용, 실제 브라우저의 공유 화면, 이메일 전달, 이미지 속 식별자의 시각 검토는 이번 검사에서 수행하지 않았다. 통과한 자동 검사와 수행하지 않은 직접 검토를 같은 완료 상태로 합치지 않는다.

교체 후에도 예전 공개 주소를 확인한다

새 파일이 올바르게 만들어져도 이전 배포 주소가 그대로 열릴 수 있다. 이번 홈페이지 작업에서는 처음 공개했던 이전 미리보기의 정리 기록도 별도로 남겼다. 현재 파일 검사와 이전 공개 주소의 유지 여부는 서로 다른 항목이다. 사용하는 호스팅에서 실제 배포와 연결을 확인한 뒤 필요한 조치를 해야 한다.

나는 교체할 문구, 검토할 미디어, 비공개 수신 설정, 현재 배포 파일, 이전 공개 주소를 나누어 기록한다. 이렇게 해야 어느 검사가 무엇을 확인했는지 다음 작업에서도 알 수 있다. 이 모듈은 그중 공개 산출물의 알려진 텍스트와 미디어 변경을 맡는다. 나머지 확인을 대신했다는 과장 없이 다시 쓸 수 있는 도구로 남긴다.

  1. 공개되는 파일 목록을 먼저 정한다.
  2. 알려진 이전 정보는 비공개 마커로 검사한다.
  3. 미디어 내용과 사용 범위를 직접 검토한 뒤 해시를 기록한다.
  4. 문의 수신 설정과 실제 전달은 별도로 확인한다.
  5. 검사 통과와 미검증 항목을 각각 남긴다.