diff --git a/docs/compliance/TENANT_ISOLATION_REVIEW.md b/docs/compliance/TENANT_ISOLATION_REVIEW.md new file mode 100644 index 0000000..a8798fe --- /dev/null +++ b/docs/compliance/TENANT_ISOLATION_REVIEW.md @@ -0,0 +1,15 @@ +# Tenant Isolation Review Packet + +Run the read-only pre-audit against the deployed tenant state root and retain +the JSON output with the deployment evidence: + +```bash +python3 packages/casan-harness/scripts/bash/tenant-isolation-review.py \ + --root /srv/casan/state/tenants --out tenant-isolation-review.json +``` + +The tool checks partition permissions, unexpected entries, forbidden symlinks, +tenant key permissions and reused local tenant-key material. A non-zero result +blocks promotion until findings are resolved. It does not assess identity +provider claims, database row-level controls, network isolation, application +authorization logic or cloud KMS policy; those must be assessed independently. diff --git a/docs/plans/CASAN_BACKLOG_STATUS.md b/docs/plans/CASAN_BACKLOG_STATUS.md index 34d8038..4007a07 100644 --- a/docs/plans/CASAN_BACKLOG_STATUS.md +++ b/docs/plans/CASAN_BACKLOG_STATUS.md @@ -74,6 +74,7 @@ | APPI/data-processing documentation | 🟡 template ready | `docs/compliance/APPI_DATA_PROCESSING.md` records the customer/project/model data register, retention, transfer and incident-review evidence. Complete and approve it with the Japanese privacy/legal owner for each production tenant. | | Japanese SLA/support/escalation | 🟡 draft ready | `docs/jp-poc/07_本番SLA・サポート・エスカレーション案.md` defines severity, ownership and evidence expectations. Finalize service hours, credits, uptime, RPO/RTO and 24x7 coverage only after managed operations/DR are proven. | | Certification and audited operations roadmap | 🟡 roadmap ready | `docs/compliance/CERTIFICATION_ROADMAP.md` maps PoC → pilot → ISO/cloud privacy/AI management/government procurement evidence. It expressly forbids claims before the relevant accredited audit or procurement process completes. | +| Tenant isolation pre-audit | 🟡 CLI + test ready | `tenant-isolation-review.py` checks filesystem partition permissions, symlink escapes and local key separation; retain its output for the independent review. It does not replace an independent identity/database/network isolation assessment. | --- ## Trần điểm & điều kiện lên "Strong (81+)" diff --git a/packages/casan-harness/scripts/bash/ci-harness-gate.sh b/packages/casan-harness/scripts/bash/ci-harness-gate.sh index 93afb4b..6acc582 100755 --- a/packages/casan-harness/scripts/bash/ci-harness-gate.sh +++ b/packages/casan-harness/scripts/bash/ci-harness-gate.sh @@ -133,6 +133,7 @@ run "phase-sec23-state-isolation" bash "$TESTS/phase-sec23-state-isolation-tests run "phase-sec23-rbac-tenant" bash "$TESTS/phase-sec23-rbac-tenant-tests.sh" run "phase-sec23-scope" bash "$TESTS/phase-sec23-scope-tests.sh" run "phase-sec23-registry-crypt" bash "$TESTS/phase-sec23-registry-crypt-tests.sh" +run "phase-tenant-isolation-review" bash "$TESTS/phase-tenant-isolation-review-tests.sh" # Plan-16 SEC-24/25 supply-chain (offline slice) run "phase-sec24-supplychain" bash "$TESTS/phase-sec24-tests.sh" run "phase-sec25-attestation" bash "$TESTS/phase-sec25-tests.sh" diff --git a/packages/casan-harness/scripts/bash/tenant-isolation-review.py b/packages/casan-harness/scripts/bash/tenant-isolation-review.py new file mode 100755 index 0000000..4778096 --- /dev/null +++ b/packages/casan-harness/scripts/bash/tenant-isolation-review.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Read-only tenant-isolation pre-audit for CASAN state partitions. + +It validates filesystem boundaries and records concrete findings. It is not an +independent audit; use its JSON output as input to an independent review. +""" +import argparse +import hashlib +import json +import os +import re +import stat +import sys +from pathlib import Path + +TENANT = re.compile(r"^[A-Za-z0-9_-]+$") + +def mode(path: Path) -> int: + return stat.S_IMODE(path.lstat().st_mode) + +def insecure(mode_value: int) -> bool: + return bool(mode_value & 0o077) + +def review(root: Path) -> dict: + findings: list[dict] = [] + tenants: list[str] = [] + keys: dict[str, str] = {} + if not root.is_dir() or root.is_symlink(): + findings.append({"severity": "critical", "code": "tenant_root_invalid", "path": str(root)}) + return {"ok": False, "root": str(root), "tenants": tenants, "findings": findings} + if insecure(mode(root)): + findings.append({"severity": "high", "code": "tenant_root_permissions", "path": str(root), "mode": oct(mode(root))}) + for candidate in sorted(root.iterdir()): + if not TENANT.fullmatch(candidate.name): + findings.append({"severity": "medium", "code": "unexpected_root_entry", "path": str(candidate)}) + continue + tenants.append(candidate.name) + if candidate.is_symlink() or not candidate.is_dir(): + findings.append({"severity": "critical", "code": "tenant_partition_not_directory", "tenant": candidate.name}) + continue + if insecure(mode(candidate)): + findings.append({"severity": "high", "code": "tenant_permissions", "tenant": candidate.name, "mode": oct(mode(candidate))}) + for current, dirs, files in os.walk(candidate, followlinks=False): + base = Path(current) + for name in dirs + files: + item = base / name + if item.is_symlink(): + findings.append({"severity": "critical", "code": "tenant_symlink_forbidden", "tenant": candidate.name, "path": str(item)}) + key = candidate / "keys" / "at-rest.key" + if key.exists(): + if key.is_symlink() or mode(key) != 0o600: + findings.append({"severity": "high", "code": "tenant_key_permissions", "tenant": candidate.name, "path": str(key), "mode": oct(mode(key))}) + else: + keys[candidate.name] = hashlib.sha256(key.read_bytes()).hexdigest() + values = list(keys.values()) + if len(values) != len(set(values)): + findings.append({"severity": "critical", "code": "tenant_key_material_reused"}) + return {"ok": not findings, "root": str(root), "tenants": tenants, "keyed_tenants": sorted(keys), "findings": findings} + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", required=True) + parser.add_argument("--out") + args = parser.parse_args() + result = review(Path(args.root).resolve()) + payload = json.dumps(result, sort_keys=True, indent=2) + "\n" + if args.out: + Path(args.out).write_text(payload, encoding="utf8") + print(payload, end="") + return 0 if result["ok"] else 2 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/casan-harness/tests/phase-tenant-isolation-review-tests.sh b/packages/casan-harness/tests/phase-tenant-isolation-review-tests.sh new file mode 100755 index 0000000..3253b80 --- /dev/null +++ b/packages/casan-harness/tests/phase-tenant-isolation-review-tests.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +R="$SCRIPT_DIR/../scripts/bash/tenant-isolation-review.py" +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +ROOT="$WORK/tenants"; mkdir -p "$ROOT/alpha/keys" "$ROOT/beta/keys"; chmod 700 "$ROOT" "$ROOT/alpha" "$ROOT/beta" +printf 'alpha-key\n' > "$ROOT/alpha/keys/at-rest.key"; printf 'beta-key\n' > "$ROOT/beta/keys/at-rest.key"; chmod 600 "$ROOT"/*/keys/at-rest.key +PASS=0; FAIL=0 +pass(){ echo "PASS: $1"; PASS=$((PASS+1)); }; fail(){ echo "FAIL: $1"; FAIL=$((FAIL+1)); } +if python3 "$R" --root "$ROOT" >/dev/null; then pass "isolated tenant partitions pass review"; else fail "valid partitions rejected"; fi +chmod 755 "$ROOT/alpha" +if python3 "$R" --root "$ROOT" >/dev/null 2>&1; then fail "world-readable tenant accepted"; else pass "world-readable tenant denied"; fi +chmod 700 "$ROOT/alpha"; ln -s /tmp "$ROOT/beta/escape" +if python3 "$R" --root "$ROOT" >/dev/null 2>&1; then fail "tenant symlink accepted"; else pass "tenant symlink denied"; fi +echo "===== TENANT ISOLATION REVIEW SUMMARY: PASS=$PASS FAIL=$FAIL ====="; [[ "$FAIL" -eq 0 ]]