Watch at Ease세상을 읽고, 일상의 답을 찾다.

기술 · 실용 가이드

Bitwarden 내보내기가 정말 완전한가요? 보관함 삭제 전 ZIP 첨부 파일 감사

로컬 감사로 Bitwarden ZIP에 존재하는 파일의 목록과 해시를 만들 수 있지만 ZIP만으로 모든 서버 첨부가 내보내졌음을 입증하지 못합니다. 오프라인으로 구조를 확인하고 별도 기준과 비교해 원본 보존과 삭제·이전 보류가 필요한 조건을 판단하세요.

원본 개념 일러스트: 노트북에서 로컬 ZIP 복사본을 검사하며 원래 보관함을 따로 유지합니다.
원본 개념 일러스트: 원본을 보존하고 복사본을 감사해 증거를 비교합니다. 삭제는 별도 결정이며 녹색 체크가 자동 삭제 허가는 아닙니다. Watch at Ease

성공 메시지만 보고 Bitwarden 계정, 조직 보관함 또는 원래 기기를 삭제하지 마세요. 먼저 ZIP의 존재를 확인하고 작업 복사본을 만들어 오프라인으로 감사하세요. 결과는 삭제 허가가 아니라 독립적인 예상 목록과 비교할 수 있는 기록된 목록입니다.

확인한 클라이언트 코드는 data.json과 attachments/를 만들지만 내보낸 JSON 항목 모델에는 첨부 메타데이터가 없습니다. 로컬 감사로 존재하는 멤버의 목록과 해시를 만들 수 있습니다. ZIP만으로 빠진 서버 첨부 파일이 원래 예상되었는지 확정할 수는 없습니다.

1. 파괴적 작업 보류하기

원래 보관함과 계정을 유지하세요. 내려받은 ZIP을 로컬 작업 디렉터리에 복사하고 가능하다면 그 디렉터리의 클라우드 동기화를 끄세요. 온라인 ZIP 검사기에 업로드하지 마세요. 암호화하지 않은 Bitwarden 내보내기에는 비밀번호, 메모, 신원 정보와 첨부 내용이 있을 수 있습니다. data.json과 그로부터 만든 보고서도 민감하게 다루세요.

다른 작업 전에 파일의 존재와 크기를 기록하세요. 앱이 성공을 알렸지만 파일이 없다면 감사할 대상이 없습니다. 원본을 유지하면서 공식 클라이언트로 다시 내보내거나 문제를 해결하세요. 성공 알림은 쓸 수 있는 파일이 디스크에 저장되었다는 증거가 아닙니다.

2. 확인한 ZIP 생성 방식 이해하기

확인한 Bitwarden clients 커밋 df9641824436b65207f393d35b8d07225129797b에서 개인 ZIP 생성기는 data.json과 attachments/를 추가하고 첨부 수집 시 삭제되었거나 조직 소유인 항목을 제외합니다. 위험한 이름 문자를 바꾸고 연속 밑줄을 합칩니다. 항목 폴더 카운터는 원래 항목 이름을, 첨부 카운터는 정리된 기본 파일명을 키로 사용합니다.

중복 이름에 숫자 접미사가 붙을 수 있으므로 Personal_1이나 invoice_1.pdf가 자동으로 오류를 뜻하지는 않습니다. 그러나 충돌이 없다는 증거도 아닙니다. 다른 원래 항목 이름이 같은 폴더명으로 바뀌거나 생성된 접미사가 기존 파일명과 겹칠 수 있습니다. 실제 바이트와 독립 기준을 감사하고, 과거 충돌 보고를 모든 버전에 일반화하거나 접미사가 모든 충돌을 없앤다고 주장하지 마세요.

3. 복사본에 감사 실행하기

아래 표준 라이브러리 전체 코드를 bitwarden_zip_audit.py로 저장해 고정된 작업 복사본 옆에 두세요. python3 bitwarden_zip_audit.py bitwarden_export.zip --output bitwarden-audit.json을 실행합니다. 멤버를 추출하거나 ZIP을 수정하지 않습니다. 최종 경로 구성요소가 심볼릭 링크인 입력과 보고서 덮어쓰기를 거부합니다. POSIX에서는 새 보고서를 모드 0600으로 만듭니다. Windows 접근은 파일 권한에 따라 달라지며 이번 테스트에서는 검증하지 않았습니다.

최상위 data.json 하나를 요구하고 명시적으로 UTF-8로 디코딩하며 중복 JSON 속성을 거부합니다. encrypted=false와 항목 객체의 items 배열이 필요합니다. 위험한 원래 경로, ZIP 중복 이름, 대소문자 충돌, 0바이트 첨부와 모호한 폴더 대응을 기록합니다. 암호화 및 일반 파일이 아닌 멤버는 지원하지 않고, 크기 및 압축률 한도는 내용 읽기 전에 거부합니다.

존재하는 각 첨부의 정확한 경로, 비압축 및 압축 크기, CRC32와 SHA-256, ZIP 전체 해시를 기록합니다. 보고서는 압축 파일 밖에 보관하고 항목 및 파일명을 보호하며 검사 중 두 입력 복사본을 고정하세요.

한도는 ZIP 멤버 50,000개, 선언된 전체 확장 크기 20 GiB, 멤버당 4 GiB, data.json 100 MiB, 기준 16 MiB, 기준 50,000행입니다. 1 MiB보다 큰 멤버의 압축률이 1,000을 넘으면 거부합니다. 첨부 스트림도 4 GiB에서 읽기를 제한합니다. ZIP 디렉터리 분석과 전체 압축 ZIP의 해시 계산은 일부 한도 전에 수행됩니다. 악성 압축 파일용 샌드박스도 범용 CPU·메모리 한도 보장도 아닙니다.

```python #!/usr/bin/env python3 """Read-only structural and integrity audit for Bitwarden ZIP exports. The report deliberately does not claim attachment completeness: current Bitwarden data.json exports do not include attachment metadata. It inventories what is in the archive, validates bounded structure, and highlights conditions that require a separate pre-export manifest or manual verification. """ from __future__ import annotations import argparse import hashlib import json import os import re import stat import sys import zipfile import zlib from collections import Counter, defaultdict from pathlib import Path, PurePosixPath MAX_MEMBERS = 50_000 MAX_UNCOMPRESSED = 20 * 1024**3 MAX_MEMBER = 4 * 1024**3 MAX_RATIO = 1_000 class AuditError(Exception): pass def sha256_stream(stream, limit=None) -> str: digest = hashlib.sha256() count = 0 for chunk in iter(lambda: stream.read(1024 * 1024), b""): count += len(chunk) if limit is not None and count > limit: raise AuditError("member stream exceeds the selected read limit") digest.update(chunk) return digest.hexdigest() def safe_member_name(name: str) -> bool: if not name or "\\" in name or "\x00" in name or name.startswith("/") or re.match(r"^[A-Za-z]:", name): return False return all(part not in ("", ".", "..") for part in name.split("/")) def unique_object(pairs): value = {} for key, item in pairs: if key in value: raise AuditError("duplicate JSON property is outside the audit scope") value[key] = item return value def sanitize_cipher_name(name: str) -> str: # Mirrors the current Bitwarden client ZIP implementation. return re.sub(r"_+", "_", re.sub(r'[/\\><:"|?*]', "_", name)) def refuse_unsafe_path(path: Path, role: str, must_exist: bool) -> Path: if path.is_symlink(): raise AuditError(f"{role} must not be a symlink: {path}") resolved = path.resolve(strict=must_exist) if must_exist and not resolved.is_file(): raise AuditError(f"{role} is not a regular file: {path}") if must_exist and not stat.S_ISREG(resolved.stat().st_mode): raise AuditError(f"{role} is not a regular file: {path}") return resolved def load_expected_manifest(path: Path) -> dict[str, dict]: path = refuse_unsafe_path(path, "expected manifest", True) if path.stat().st_size > 16 * 1024**2: raise AuditError("expected manifest exceeds the 16 MiB limit") try: document = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AuditError(f"expected manifest is not valid UTF-8 JSON: {exc}") from exc if not isinstance(document, dict) or type(document.get("schemaVersion")) is not int or document.get("schemaVersion") != 1: raise AuditError("unsupported expected manifest: schemaVersion must be 1") rows = document.get("attachments") if not isinstance(rows, list): raise AuditError("unsupported expected manifest: attachments must be an array") if len(rows) > MAX_MEMBERS: raise AuditError("expected manifest has too many rows") expected = {} for index, row in enumerate(rows): if not isinstance(row, dict) or not isinstance(row.get("path"), str): raise AuditError(f"expected manifest row {index} requires a string path") member = row["path"] if not member.startswith("attachments/") or not safe_member_name(member): raise AuditError(f"expected manifest row {index} has an unsafe attachment path") if member in expected: raise AuditError(f"expected manifest repeats path: {member}") if "size" in row and (type(row["size"]) is not int or row["size"] < 0): raise AuditError(f"expected manifest row {index} has an invalid size") if "sha256" in row and not re.fullmatch(r"[0-9a-f]{64}", str(row["sha256"])): raise AuditError(f"expected manifest row {index} has an invalid sha256") expected[member] = row return expected def audit_zip(source: Path, expected_manifest: Path | None = None) -> dict: source = refuse_unsafe_path(source, "source ZIP", True) if not zipfile.is_zipfile(source): raise AuditError("source is not a readable ZIP archive") with source.open("rb") as archive_stream: archive_hash = sha256_stream(archive_stream) findings: list[dict] = [] members: list[dict] = [] with zipfile.ZipFile(source) as zf: infos = zf.infolist() if len(infos) > MAX_MEMBERS: raise AuditError(f"archive has {len(infos)} members; limit is {MAX_MEMBERS}") raw_counts = Counter(info.filename for info in infos) duplicates = sorted(name for name, count in raw_counts.items() if count > 1) if duplicates: findings.append({"severity": "error", "code": "duplicate-member-name", "members": duplicates}) casefold_groups: dict[str, list[str]] = defaultdict(list) total_uncompressed = 0 data_infos = [] attachment_files = [] for info in infos: name = info.filename casefold_groups[name.casefold()].append(name) if not safe_member_name(name[:-1] if info.is_dir() else name): findings.append({"severity": "error", "code": "unsafe-member-path", "member": name}) if info.flag_bits & 0x1: raise AuditError("encrypted ZIP members are outside scope") mode = stat.S_IFMT(info.external_attr >> 16) if not info.is_dir() and mode not in (0, stat.S_IFREG): raise AuditError("non-regular ZIP member is outside scope") total_uncompressed += info.file_size if info.file_size > MAX_MEMBER: raise AuditError("member exceeds the 4 GiB limit") ratio = info.file_size / max(1, info.compress_size) if ratio > MAX_RATIO and info.file_size > 1024 * 1024: raise AuditError("member exceeds the selected compression-ratio scope") if name == "data.json": data_infos.append(info) if name.startswith("attachments/") and not info.is_dir(): attachment_files.append(info) if total_uncompressed > MAX_UNCOMPRESSED: raise AuditError(f"archive expands to {total_uncompressed} bytes; limit is {MAX_UNCOMPRESSED}") collisions = [sorted(set(names)) for names in casefold_groups.values() if len(set(names)) > 1] if collisions: findings.append({"severity": "warning", "code": "casefold-name-collision", "groups": collisions}) if len(data_infos) != 1: raise AuditError(f"expected exactly one top-level data.json; found {len(data_infos)}") data_info = data_infos[0] if data_info.file_size > 100 * 1024**2: raise AuditError("data.json exceeds the 100 MiB audit limit") try: with zf.open(data_info) as stream: raw = stream.read(100 * 1024**2 + 1) if len(raw) > 100 * 1024**2: raise AuditError("data.json stream exceeds the 100 MiB limit") document = json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AuditError(f"data.json is not valid UTF-8 JSON: {exc}") from exc if not isinstance(document, dict) or not isinstance(document.get("items"), list): raise AuditError("unsupported data.json schema: expected an object with an items array") if document.get("encrypted") is not False: raise AuditError("unsupported data.json schema: ZIP audit expects encrypted=false") if any(not isinstance(item, dict) for item in document["items"]): raise AuditError("items must contain objects") item_names = [item.get("name", "") for item in document["items"] if isinstance(item, dict)] expected_folders: dict[str, list[str]] = defaultdict(list) for item_name in item_names: if isinstance(item_name, str): expected_folders[sanitize_cipher_name(item_name)].append(item_name) folders: dict[str, list[zipfile.ZipInfo]] = defaultdict(list) for info in attachment_files: parts = PurePosixPath(info.filename).parts if len(parts) < 3: findings.append({"severity": "warning", "code": "unexpected-attachment-path", "member": info.filename}) continue folders[parts[1]].append(info) for folder in sorted(folders): base = re.sub(r"_\d+$", "", folder) candidates = expected_folders.get(folder, []) + (expected_folders.get(base, []) if base != folder else []) possible = sorted(set(candidates)) if not possible: findings.append({"severity": "warning", "code": "unmapped-attachment-folder", "folder": folder}) elif len(candidates) > 1: findings.append({"severity": "warning", "code": "ambiguous-attachment-folder", "folder": folder, "possibleItems": possible}) for info in attachment_files: with zf.open(info) as stream: member_hash = sha256_stream(stream, MAX_MEMBER) entry = { "path": info.filename, "size": info.file_size, "compressedSize": info.compress_size, "crc32": f"{info.CRC:08x}", "sha256": member_hash, } members.append(entry) if info.file_size == 0: findings.append({"severity": "warning", "code": "zero-byte-attachment", "member": info.filename}) baseline = None if expected_manifest is not None: expected = load_expected_manifest(expected_manifest) actual = {entry["path"]: entry for entry in members} missing = sorted(set(expected) - set(actual)) unexpected = sorted(set(actual) - set(expected)) mismatches = [] for path in sorted(set(expected) & set(actual)): wanted, present = expected[path], actual[path] if "size" in wanted and wanted["size"] != present["size"]: mismatches.append({"path": path, "field": "size", "expected": wanted["size"], "actual": present["size"]}) if "sha256" in wanted and wanted["sha256"] != present["sha256"]: mismatches.append({"path": path, "field": "sha256", "expected": wanted["sha256"], "actual": present["sha256"]}) if missing: findings.append({"severity": "error", "code": "baseline-missing-attachments", "members": missing}) if unexpected: findings.append({"severity": "warning", "code": "baseline-unexpected-attachments", "members": unexpected}) if mismatches: findings.append({"severity": "error", "code": "baseline-mismatch", "mismatches": mismatches}) baseline = { "name": expected_manifest.name, "entries": len(expected), "matched": len(set(expected) & set(actual)) - len({m["path"] for m in mismatches}), "missing": len(missing), "unexpected": len(unexpected), "mismatches": len(mismatches), "provenanceWarning": "This comparison proves equality only to the supplied manifest, not to live Bitwarden server state.", } findings.append({ "severity": "info", "code": "completeness-not-provable-from-zip-alone", "detail": "Current data.json exports do not carry attachment metadata. Compare this inventory with a separately captured pre-export manifest before any destructive action.", }) status = "fail" if any(f["severity"] == "error" for f in findings) else "review" return { "schemaVersion": 1, "status": status, "source": {"name": source.name, "bytes": source.stat().st_size, "sha256": archive_hash}, "scope": { "proves": ["ZIP readability", "bounded member structure", "attachment member byte hashes", "data.json parseability"], "doesNotProve": ["every server-side attachment was exported", "organization-vault coverage", "restore success"], }, "summary": {"itemsInDataJson": len(item_names), "attachmentFiles": len(members), "attachmentFolders": len(folders)}, "baseline": baseline, "findings": findings, "attachments": sorted(members, key=lambda item: item["path"]), } def write_report(report: dict, destination: Path) -> None: if destination.exists() or destination.is_symlink(): raise AuditError(f"refusing to overwrite output: {destination}") parent = destination.parent.resolve(strict=True) if not parent.is_dir(): raise AuditError(f"output parent is not a directory: {parent}") payload = (json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode() flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL fd = os.open(destination, flags, 0o600) with os.fdopen(fd, "wb") as stream: stream.write(payload) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("zip", type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--expected-manifest", type=Path, help="optional schemaVersion=1 JSON baseline of attachment paths, sizes and/or SHA-256 values") args = parser.parse_args(argv) try: source = refuse_unsafe_path(args.zip, "source ZIP", True) output = refuse_unsafe_path(args.output, "output", False) if source == output: raise AuditError("output must not replace the source ZIP") manifest = args.expected_manifest if manifest is not None: manifest = refuse_unsafe_path(manifest, "expected manifest", True) write_report(audit_zip(source, manifest), output) except (AuditError, OSError, ValueError, RuntimeError, EOFError, zlib.error, zipfile.BadZipFile, NotImplementedError) as exc: print(f"audit failed: {exc}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main()) ```

명령은 보고서를 쓰면 0으로 종료하며 보고서 status가 fail이어도 같습니다. status와 모든 finding을 읽으세요. 0은 삭제 허가가 아닙니다. 2는 감사 또는 보고서를 완료하지 못했다는 뜻입니다. 보고서가 없거나 프로세스가 중단되거나 결과가 불명확하면 원본을 유지하세요.

4. 상태를 올바르게 해석하기

fail은 적어도 하나의 구조 또는 기준 오류를 조사해야 한다는 뜻입니다. 원본을 삭제하거나 이전하면서 없애지 마세요. 원래 중복 멤버 경로, 위험한 경로, 평문 ZIP을 예상하는 절차에서의 암호화 멤버, 기준 멤버 누락, 크기 또는 해시 불일치는 중단 조건입니다.

구조를 읽을 수 있는 ZIP에서는 review가 정상 결과입니다. 경고에도 판단이 필요합니다. 0바이트가 정상일 수 있지만 안전한 복사본에서 열어보세요. 대소문자 충돌은 Windows, macOS, Linux마다 다르게 동작할 수 있습니다. 매핑되지 않거나 모호한 폴더는 이름 정리나 중복 항목 때문일 수 있고, 예상 밖 멤버는 새 첨부 또는 오래된 기준 때문일 수 있습니다.

스크립트는 data.json 전체 객체나 그 안의 로그인 자격 증명 및 메모 필드를 출력하지 않습니다. 다만 보고서에는 첨부 경로와 때로는 항목 이름이 포함되며 이름 자체가 비공개 정보나 비밀을 드러낼 수 있습니다. 모든 보고서를 내보낸 파일처럼 보호하세요.

5. 원본이 있을 때 기준 추가하기

가장 강한 오프라인 비교에는 삭제 전에 별도로 만든 목록을 사용합니다. 형식은 schemaVersion 1과 attachments 배열입니다. 각 행에는 attachments/ 아래 path가 있고 size와 SHA-256을 추가할 수 있습니다. 경로는 ZIP 이름과 정확히 맞아야 합니다. 정상으로 확인한 이전 내보내기, 신중히 확인한 수동 목록 또는 출처를 설명할 수 있는 다른 로컬 자료에서 만드세요.

python3 bitwarden_zip_audit.py bitwarden_export.zip --expected-manifest expected-attachments.json --output bitwarden-audit.json을 실행하면 누락, 예상 밖 경로, 크기 차이와 해시 차이를 보여줍니다. 완전 일치는 해당 기준과의 일치만 입증합니다. 기준이 불완전했거나 이미 데이터가 사라진 후 만들어졌다면 그 제한도 이어집니다.

목록에 보관함 자격 증명, 세션 키 또는 원시 비밀 필드를 넣지 마세요. 최소한 정확한 첨부 경로가 필요하고 크기와 SHA-256은 뜻하지 않은 교체를 찾는 데 도움이 됩니다. 믿을 수 있는 기준을 못 만들면 원본을 유지하고 중요한 첨부를 수동 확인하세요. review를 완전하다고 해석하지 마세요.

6. ZIP이 다루지 못하는 범위 확인하기

공식 안내에 따르면 개인 내보내기에는 개인 보관함 데이터가 포함됩니다. 조직 데이터는 권한 있는 조직 구성원이 별도로 내보내야 합니다. 휴지통과 Sends도 포함된다고 가정하지 마세요. 개인 ZIP 감사가 문제없어도 이전 체크리스트에 제외 범위를 기록하세요.

확인한 ZIP 응답은 첨부 다운로드나 복호화가 실패하면 건너뛴 수를 클라이언트에 반환할 수 있습니다. 실행 시 정보는 data.json에 예상 첨부 목록으로 저장되지 않습니다. 클라이언트가 건너뛰기 경고를 보였다면 화면이나 로그 메모를 보존하고 중단 조건으로 다루세요. 오프라인 ZIP은 쓰이지 않은 파일을 복원하지 못합니다.

읽히는 ZIP은 복원 테스트가 아닙니다. 파괴적 작업 전에 일회용 권한 있는 테스트 환경으로 가져오는 작업은 현재 공식 지침이 지원하며 그 과정의 평문 노출을 이해한 경우에만 고려하세요. 그렇지 않다면 다른 매체에 암호화한 복사본을 적어도 두 개 보관하고 이전을 독립적으로 검증할 때까지 원래 계정을 유지하세요.

7. 진행 또는 중단 체크리스트 사용하기

다음을 모두 확인하기 전에는 진행하지 마세요. 파일 존재와 ZIP 해시 기록, data.json 파싱, 오류 finding 없음, 모든 경고에 대한 서면 설명, 안전한 복사본에서 중요한 첨부 열기, 조직 데이터 별도 계획, 휴지통과 Sends 제외 이해, 독립 기준 비교의 누락과 불일치 없음입니다.

모든 항목을 충족해도 목적지를 정상 사용으로 확인할 때까지 원본을 유지하세요. 법적 또는 보안 이유로 삭제해야 한다면 보고서, ZIP 해시와 결정 기록을 보존하세요. 결론은 한정됩니다. 가진 바이트와, 기준을 썼다면 특정 기준과의 일치를 검증한 것이며 ZIP만으로 알 수 없는 서버 목록을 입증한 것은 아닙니다.

8. 테스트가 실제로 입증하는 범위

최종 14,377바이트 스크립트의 SHA-256은 6108379a2377560f2a0e52b5e3a9cd9d4120a140b79fabc7a3b747ae188708aa이며 Python 3/Linux에서 로컬 합성 단위 테스트 15개를 통과했습니다. 유효·빈 목록, 누락·손상 JSON, 중복 이름과 대소문자 충돌, 정리된 이름 모호성, 기준 누락·추가·불일치·정확한 일치, 링크와 덮어쓰기 거부, 위험한 원래 경로, UTF-16과 중복 키 거부, 기준의 불리언 필드를 시험했습니다.

크기와 압축률 중단 조건은 작은 한도나 압축 가능한 합성 데이터로 시험했으며 실제 여러 GiB ZIP은 읽지 않았습니다. 정확한 기준 사례에서 입력 바이트는 동일했습니다. 실제 Bitwarden 보관함, 클라이언트 다운로드, 조직 내보내기, 목적지 가져오기 또는 Windows 권한은 테스트하지 않았습니다. 코드는 로컬 목록 보조 도구이며 Bitwarden 공식 완전성 인증이 아닙니다.

자주 묻는 질문

review면 모든 첨부가 내보내졌나요? 아니요. 지원하는 ZIP을 오류 finding 없이 목록화했다는 뜻입니다. 별도 증거 없이는 서버의 예상 집합이 여전히 알려지지 않았습니다.

숫자 접미사는 파일 손실이 없음을 보장하나요? 아니요. 확인한 카운터는 반복 이름을 처리하지만 일반적인 충돌 증명은 아닙니다. ZIP은 쓰기 전에 누락된 첨부를 보여주지 못합니다.

기준 일치는 현재 서버의 완전성을 입증하나요? 아니요. 주어진 기준의 경로와 그 안의 크기 및 해시에 대한 일치만 입증합니다. 기준 출처를 기록하세요.

암호화 JSON 내보내기에 쓸 수 있나요? 아니요. 평문 ZIP의 data.json과 encrypted=false를 요구합니다. 제한된 내보내기를 복호화하거나 비밀번호를 요청하지 않습니다.

종료 코드 0이면 보관함을 삭제해도 되나요? 아니요. 0은 보고서가 쓰였다는 뜻입니다. 보고서를 읽고 문제를 해결하며 목적지를 검증하고 별도의 삭제 결정에 근거가 생길 때까지 원본을 유지하세요.

원본 개념 일러스트: 해시를 확인한 로컬 멤버와 증거 경계 밖의 알 수 없는 원격 파일을 구분합니다.
원본 개념 일러스트: 해시는 가진 멤버를 검증합니다. 독립 기준이 없으면 ZIP 밖 원격의 예상 집합은 여전히 알려지지 않습니다. Watch at Ease