#!/bin/zsh # LaptopsRenovadas - Auditoría Express macOS Collector 1.0.3 # Local-first, read-only, no sudo, no package installation, no network commands. # Generates lr-audit-express/1 JSON and an optional LR1 code in ~/Downloads. set -u COLLECTOR_VERSION="macOS-1.0.3" SCHEMA="lr-audit-express/1" PLUTIL="/usr/bin/plutil" SYSCTL="/usr/sbin/sysctl" SYSTEM_PROFILER="/usr/sbin/system_profiler" DISKUTIL="/usr/sbin/diskutil" PROFILES="/usr/bin/profiles" FDSETUP="/usr/bin/fdesetup" CSRUTIL="/usr/bin/csrutil" SW_VERS="/usr/bin/sw_vers" UNAME="/usr/bin/uname" BASE64="/usr/bin/base64" PBCOPY="/usr/bin/pbcopy" DATE="/bin/date" TR="/usr/bin/tr" SED="/usr/bin/sed" AWK="/usr/bin/awk" for required in "$PLUTIL" "$SYSCTL" "$SYSTEM_PROFILER" "$DISKUTIL" "$SW_VERS" "$UNAME" "$DATE"; do if [[ ! -x "$required" ]]; then print -u2 "Falta una utilidad nativa necesaria: $required" exit 2 fi done DOWNLOADS="$HOME/Downloads" [[ -d "$DOWNLOADS" ]] || DOWNLOADS="$HOME/Desktop" [[ -d "$DOWNLOADS" ]] || DOWNLOADS="$HOME" STAMP="$($DATE '+%Y%m%d-%H%M%S')" OUTPUT_FILE="$DOWNLOADS/LRAuditExpressMac-$STAMP.json" WORK_PLIST="${TMPDIR:-/tmp}/LRAuditExpressMac-$STAMP-$$.plist" cleanup() { rm -f "$WORK_PLIST"; } trap cleanup EXIT HUP INT TERM print "🧪 Iniciando Auditoría Express para macOS..." print " Recolección local, de solo lectura y sin sudo." # ---------- helpers ---------- trim() { printf '%s\n' "${1:-}" | "$SED" -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; } is_int() { printf '%s\n' "${1:-}" | /usr/bin/grep -Eq '^[0-9]+$'; } is_number() { printf '%s\n' "${1:-}" | /usr/bin/grep -Eq '^[0-9]+([.][0-9]+)?$'; } plist_raw() { local payload="${1:-}" path="${2:-}" [[ -n "$payload" && -n "$path" ]] || return 1 printf '%s' "$payload" | "$PLUTIL" -extract "$path" raw -o - - 2>/dev/null } first_plist_raw() { local payload="$1"; shift local key value for key in "$@"; do value="$(plist_raw "$payload" "$key" 2>/dev/null || true)" if [[ -n "$value" ]]; then printf '%s' "$value" return 0 fi done return 1 } # Structured writer helpers. The report is assembled as an XML property list # first, because property lists have deterministic nested dictionary/array semantics. # It is converted to JSON only after the complete structure has been built. json_write_error() { local path="${1:-unknown}" print -u2 "No se pudo construir el campo estructurado: $path" rm -f "$OUTPUT_FILE" exit 3 } json_dict() { local p="$1"; "$PLUTIL" -insert "$p" -dictionary "$WORK_PLIST" >/dev/null 2>&1 || json_write_error "$p"; } json_array() { local p="$1"; "$PLUTIL" -insert "$p" -array "$WORK_PLIST" >/dev/null 2>&1 || json_write_error "$p"; } json_string() { local p="$1" v="${2:-}"; "$PLUTIL" -insert "$p" -string "$v" "$WORK_PLIST" >/dev/null 2>&1 || json_write_error "$p"; } json_int() { local p="$1" v="${2:-}"; "$PLUTIL" -insert "$p" -integer "$v" "$WORK_PLIST" >/dev/null 2>&1 || json_write_error "$p"; } json_float() { local p="$1" v="${2:-}"; "$PLUTIL" -insert "$p" -float "$v" "$WORK_PLIST" >/dev/null 2>&1 || json_write_error "$p"; } json_bool() { local p="$1" v="${2:-false}"; "$PLUTIL" -insert "$p" -bool "$v" "$WORK_PLIST" >/dev/null 2>&1 || json_write_error "$p"; } normalize_yes_no() { local value="${1:-}" lower lower="$(printf '%s' "$value" | /usr/bin/tr '[:upper:]' '[:lower:]')" case "$lower" in yes*|true|1) printf '%s' "true" ;; no|false|0) printf '%s' "false" ;; *) printf '%s' "null" ;; esac } normalize_activation_lock() { local value="${1:-}" lower lower="$(printf '%s' "$value" | /usr/bin/tr '[:upper:]' '[:lower:]')" case "$lower" in *disabled*) printf '%s' "disabled" ;; *enabled*) printf '%s' "enabled" ;; *) printf '%s' "unknown" ;; esac } nvme_low_if_high_zero() { local payload="$1" base="$2" low high low="$(plist_raw "$payload" "SMARTDeviceSpecificKeysMayVaryNotGuaranteed.${base}_0" 2>/dev/null || true)" high="$(plist_raw "$payload" "SMARTDeviceSpecificKeysMayVaryNotGuaranteed.${base}_1" 2>/dev/null || true)" if is_int "$low" && { [[ -z "$high" ]] || [[ "$high" == "0" ]]; }; then printf '%s' "$low" return 0 fi return 1 } # ---------- baseline ---------- ARCH="$($UNAME -m 2>/dev/null || true)" MODEL_ID="$($SYSCTL -n hw.model 2>/dev/null || true)" MEM_BYTES="$($SYSCTL -n hw.memsize 2>/dev/null || true)" PHYSICAL_CORES="$($SYSCTL -n hw.physicalcpu 2>/dev/null || true)" LOGICAL_CORES="$($SYSCTL -n hw.logicalcpu 2>/dev/null || true)" OS_VERSION="$($SW_VERS -productVersion 2>/dev/null || true)" OS_BUILD="$($SW_VERS -buildVersion 2>/dev/null || true)" COLLECTED_AT="$($DATE -u '+%Y-%m-%dT%H:%M:%SZ')" # system_profiler JSON is requested at mini detail level. We retain only selected # values and never write serial number, UUID or Apple Account data to the report. HW_JSON="$($SYSTEM_PROFILER -json -detailLevel mini SPHardwareDataType 2>/dev/null || true)" CHIP_NAME="$(first_plist_raw "$HW_JSON" 'SPHardwareDataType.0.chip_type' 'SPHardwareDataType.0.cpu_type' 2>/dev/null || true)" MACHINE_NAME="$(first_plist_raw "$HW_JSON" 'SPHardwareDataType.0.machine_name' 2>/dev/null || true)" ACTIVATION_RAW="$(first_plist_raw "$HW_JSON" 'SPHardwareDataType.0.activation_lock_status' 2>/dev/null || true)" ACTIVATION_STATUS="$(normalize_activation_lock "$ACTIVATION_RAW")" unset HW_JSON [[ -n "$CHIP_NAME" ]] || { if [[ "$ARCH" == "arm64" ]]; then CHIP_NAME="Apple Silicon"; else CHIP_NAME="$($SYSCTL -n machdep.cpu.brand_string 2>/dev/null || true)"; fi } CPU_MANUFACTURER="" if [[ "$ARCH" == "arm64" ]]; then CPU_MANUFACTURER="Apple"; elif printf '%s' "$CHIP_NAME" | /usr/bin/grep -qi intel; then CPU_MANUFACTURER="Intel"; fi # ---------- battery ---------- POWER_JSON="$($SYSTEM_PROFILER -json -detailLevel mini SPPowerDataType 2>/dev/null || true)" BAT_CYCLES="$(first_plist_raw "$POWER_JSON" \ 'SPPowerDataType.0.sppower_battery_health_info.sppower_battery_cycle_count' \ 'SPPowerDataType.0._items.0.sppower_battery_health_info.sppower_battery_cycle_count' 2>/dev/null || true)" BAT_HEALTH_TEXT="$(first_plist_raw "$POWER_JSON" \ 'SPPowerDataType.0.sppower_battery_health_info.sppower_battery_health' \ 'SPPowerDataType.0._items.0.sppower_battery_health_info.sppower_battery_health' 2>/dev/null || true)" BAT_MAX_CAP_RAW="$(first_plist_raw "$POWER_JSON" \ 'SPPowerDataType.0.sppower_battery_health_info.sppower_battery_health_maximum_capacity' \ 'SPPowerDataType.0._items.0.sppower_battery_health_info.sppower_battery_health_maximum_capacity' 2>/dev/null || true)" BAT_CHARGE="$(first_plist_raw "$POWER_JSON" \ 'SPPowerDataType.0.sppower_battery_charge_info.sppower_battery_state_of_charge' \ 'SPPowerDataType.0._items.0.sppower_battery_charge_info.sppower_battery_state_of_charge' 2>/dev/null || true)" unset POWER_JSON BAT_HEALTH_PERCENT="$(printf '%s\n' "$BAT_MAX_CAP_RAW" | "$SED" -nE 's/[^0-9]*([0-9]+([.][0-9]+)?).*/\1/p' | head -1)" BATTERY_PRESENT=false if is_int "$BAT_CYCLES" || is_number "$BAT_HEALTH_PERCENT" || [[ -n "$BAT_HEALTH_TEXT" ]]; then BATTERY_PRESENT=true; fi # ---------- storage ---------- # `/` normally points at an APFS volume/snapshot backed by a synthesized APFS # container. The container has no storage of its own, so presenting its # `MediaName` (often simply "Container") as the SSD identity is misleading. # Resolve the APFS container to its imported physical store, then climb from # the store partition (for example disk0s2) to the physical whole disk (disk0). ROOT_INFO="$($DISKUTIL info -plist / 2>/dev/null || true)" CONTAINER_ID="$(first_plist_raw "$ROOT_INFO" 'APFSContainerReference' 'ParentWholeDisk' 2>/dev/null || true)" STORE_ID="$(first_plist_raw "$ROOT_INFO" \ 'APFSPhysicalStores.0.APFSPhysicalStore' \ 'APFSPhysicalStores.0.DeviceIdentifier' \ 'APFSPhysicalStore' 2>/dev/null || true)" APFS_LIST="" CONTAINER_SIZE="" if [[ -n "$CONTAINER_ID" ]]; then APFS_LIST="$($DISKUTIL apfs list -plist "$CONTAINER_ID" 2>/dev/null || true)" if [[ -n "$APFS_LIST" ]]; then [[ -n "$STORE_ID" ]] || STORE_ID="$(first_plist_raw "$APFS_LIST" \ 'Containers.0.DesignatedPhysicalStore' \ 'Containers.0.PhysicalStores.0.DeviceIdentifier' 2>/dev/null || true)" CONTAINER_SIZE="$(first_plist_raw "$APFS_LIST" 'Containers.0.CapacityCeiling' 2>/dev/null || true)" fi fi STORE_INFO="" WHOLE_ID="" DISK_INFO="" PHYSICAL_RESOLVED=false if [[ -n "$STORE_ID" ]]; then STORE_INFO="$($DISKUTIL info -plist "$STORE_ID" 2>/dev/null || true)" WHOLE_ID="$(first_plist_raw "$STORE_INFO" 'ParentWholeDisk' 2>/dev/null || true)" [[ -n "$WHOLE_ID" ]] || WHOLE_ID="$STORE_ID" DISK_INFO="$($DISKUTIL info -plist "$WHOLE_ID" 2>/dev/null || true)" [[ -n "$DISK_INFO" ]] || DISK_INFO="$STORE_INFO" [[ -n "$DISK_INFO" ]] && PHYSICAL_RESOLVED=true fi [[ -n "$DISK_INFO" ]] || DISK_INFO="$ROOT_INFO" DISK_MODEL="$(first_plist_raw "$DISK_INFO" 'MediaName' 'IORegistryEntryName' 2>/dev/null || true)" DISK_BUS="$(plist_raw "$DISK_INFO" 'BusProtocol' 2>/dev/null || true)" DISK_MEDIA="$(plist_raw "$DISK_INFO" 'MediaType' 2>/dev/null || true)" DISK_SIZE="$(first_plist_raw "$DISK_INFO" 'TotalSize' 'Size' 2>/dev/null || true)" DISK_SMART="$(plist_raw "$DISK_INFO" 'SMARTStatus' 2>/dev/null || true)" DISK_SOLID="$(plist_raw "$DISK_INFO" 'SolidState' 2>/dev/null || true)" NVME_WEAR="$(plist_raw "$DISK_INFO" 'SMARTDeviceSpecificKeysMayVaryNotGuaranteed.PERCENTAGE_USED' 2>/dev/null || true)" NVME_TEMP_RAW="$(plist_raw "$DISK_INFO" 'SMARTDeviceSpecificKeysMayVaryNotGuaranteed.TEMPERATURE' 2>/dev/null || true)" NVME_HOURS="$(nvme_low_if_high_zero "$DISK_INFO" 'POWER_ON_HOURS' 2>/dev/null || true)" NVME_MEDIA_ERRORS="$(nvme_low_if_high_zero "$DISK_INFO" 'MEDIA_ERRORS' 2>/dev/null || true)" NVME_DATA_WRITTEN="$(nvme_low_if_high_zero "$DISK_INFO" 'DATA_UNITS_WRITTEN' 2>/dev/null || true)" NVME_TEMP_C="" if is_number "$NVME_TEMP_RAW"; then NVME_TEMP_C="$($AWK -v t="$NVME_TEMP_RAW" 'BEGIN { if (t > 200 && t < 500) printf "%.1f", t-273.15; else if (t >= 0 && t < 150) printf "%.1f", t; }')" fi DETAIL_AVAILABLE=false if is_number "$NVME_WEAR" || is_number "$NVME_TEMP_C" || is_int "$NVME_HOURS" || is_int "$NVME_MEDIA_ERRORS" || is_int "$NVME_DATA_WRITTEN"; then DETAIL_AVAILABLE=true; fi unset ROOT_INFO APFS_LIST STORE_INFO # ---------- management (local status only) ---------- # We deliberately use `profiles status -type enrollment`. We do NOT use # `profiles show -type enrollment`, `renew`, curl or any server lookup. MDM_TEXT="" if [[ -x "$PROFILES" ]]; then MDM_TEXT="$($PROFILES status -type enrollment 2>/dev/null || true)"; fi MDM_RAW="$(printf '%s\n' "$MDM_TEXT" | "$SED" -nE 's/^[[:space:]]*MDM enrollment:[[:space:]]*(.*)$/\1/p' | head -1)" ADE_RAW="$(printf '%s\n' "$MDM_TEXT" | "$SED" -nE 's/^[[:space:]]*Enrolled via DEP:[[:space:]]*(.*)$/\1/p' | head -1)" MDM_STATE="$(normalize_yes_no "$MDM_RAW")" ADE_STATE="$(normalize_yes_no "$ADE_RAW")" MDM_USER_APPROVED=null if printf '%s' "$MDM_RAW" | /usr/bin/grep -qi "user approved"; then MDM_USER_APPROVED=true; elif [[ "$MDM_STATE" == "false" ]]; then MDM_USER_APPROVED=false; fi unset MDM_TEXT # ---------- security ---------- FILEVAULT_STATUS="unknown" if [[ -x "$FDSETUP" ]]; then # `isactive` has explicit exit semantics in fdesetup: 0=enabled, 1=off. # We preserve any other return code as unknown/busy instead of guessing. "$FDSETUP" isactive >/dev/null 2>&1 FV_RC=$? case "$FV_RC" in 0) FILEVAULT_STATUS="enabled" ;; 1) FILEVAULT_STATUS="disabled" ;; 2) FILEVAULT_STATUS="busy" ;; *) FILEVAULT_STATUS="unknown" ;; esac fi SIP_STATUS="unknown" if [[ -x "$CSRUTIL" ]]; then SIP_TEXT="$($CSRUTIL status 2>/dev/null || true)" SIP_LOWER="$(printf '%s' "$SIP_TEXT" | /usr/bin/tr '[:upper:]' '[:lower:]')" case "$SIP_LOWER" in *enabled*) SIP_STATUS="enabled" ;; *disabled*) SIP_STATUS="disabled" ;; esac unset SIP_TEXT SIP_LOWER fi APPLE_SILICON=false [[ "$ARCH" == "arm64" ]] && APPLE_SILICON=true # ---------- structured report ---------- # Build a complete nested property-list tree first. We never attempt to create a # deep leaf before its parent container exists. Optional evidence is omitted, # rather than represented as 0/false when it is unknown. if ! "$PLUTIL" -create xml1 "$WORK_PLIST" >/dev/null 2>&1; then print -u2 "No se pudo iniciar el documento estructurado de Auditoría Express." rm -f "$OUTPUT_FILE" exit 3 fi for d in execution privacy system bios operatingSystem memory storage battery security management collection; do json_dict "$d" done json_array cpu json_dict cpu.0 json_array gpu json_array memory.modules json_array storage.disks json_dict storage.legacySmart json_array battery.batteries # Contract + privacy. json_string schema "$SCHEMA" json_string platform "macos" json_string collectorVersion "$COLLECTOR_VERSION" json_string collectedAt "$COLLECTED_AT" json_bool execution.isAdministrator false json_bool privacy.networkRequests false json_bool privacy.userIdentityCollected false json_bool privacy.serialNumbersCollected false json_bool privacy.rawDsregOutputCollected false json_bool privacy.accountIdentifiersCollected false json_bool privacy.rawEnrollmentOutputCollected false # System / OS / CPU / memory. json_string system.manufacturer "Apple" [[ -n "$MODEL_ID" ]] && json_string system.model "$MODEL_ID" [[ -n "$MACHINE_NAME" ]] && json_string system.systemType "$MACHINE_NAME" is_int "$MEM_BYTES" && json_int system.totalMemoryBytes "$MEM_BYTES" json_string operatingSystem.caption "macOS" [[ -n "$OS_VERSION" ]] && json_string operatingSystem.version "$OS_VERSION" [[ -n "$OS_BUILD" ]] && json_string operatingSystem.buildNumber "$OS_BUILD" [[ -n "$ARCH" ]] && json_string operatingSystem.architecture "$ARCH" [[ -n "$CHIP_NAME" ]] && json_string cpu.0.name "$CHIP_NAME" [[ -n "$CPU_MANUFACTURER" ]] && json_string cpu.0.manufacturer "$CPU_MANUFACTURER" is_int "$PHYSICAL_CORES" && json_int cpu.0.cores "$PHYSICAL_CORES" is_int "$LOGICAL_CORES" && json_int cpu.0.logicalProcessors "$LOGICAL_CORES" is_int "$MEM_BYTES" && json_int memory.totalBytes "$MEM_BYTES" # Storage. `legacySmart` remains explicit because the shared Windows schema also # has that branch; macOS detailed NVMe evidence is stored per disk when exposed. json_string storage.source "diskutil-info-plist" json_bool storage.legacySmart.available false json_string storage.legacySmart.queryStatus "not-applicable-macos" if [[ -n "$DISK_INFO" ]]; then json_dict storage.disks.0 json_dict storage.disks.0.reliability [[ -n "$DISK_MODEL" ]] && json_string storage.disks.0.model "$DISK_MODEL" DISK_MEDIA_OUT="$DISK_MEDIA" [[ "$DISK_SOLID" == "true" ]] && DISK_MEDIA_OUT="SSD" [[ -n "$DISK_MEDIA_OUT" ]] && json_string storage.disks.0.mediaType "$DISK_MEDIA_OUT" [[ -n "$DISK_BUS" ]] && json_string storage.disks.0.busType "$DISK_BUS" [[ -n "$DISK_SMART" ]] && json_string storage.disks.0.healthStatus "$DISK_SMART" is_int "$DISK_SIZE" && json_int storage.disks.0.sizeBytes "$DISK_SIZE" is_int "$CONTAINER_SIZE" && json_int storage.disks.0.apfsContainerSizeBytes "$CONTAINER_SIZE" json_bool storage.disks.0.physicalStoreResolved "$PHYSICAL_RESOLVED" json_string storage.disks.0.scope "$([[ "$PHYSICAL_RESOLVED" == true ]] && printf 'physical-device' || printf 'logical-fallback')" is_number "$NVME_WEAR" && json_float storage.disks.0.reliability.wearPercent "$NVME_WEAR" is_number "$NVME_TEMP_C" && json_float storage.disks.0.reliability.temperatureC "$NVME_TEMP_C" is_int "$NVME_HOURS" && json_int storage.disks.0.reliability.powerOnHours "$NVME_HOURS" is_int "$NVME_MEDIA_ERRORS" && json_int storage.disks.0.reliability.mediaErrors "$NVME_MEDIA_ERRORS" is_int "$NVME_DATA_WRITTEN" && json_int storage.disks.0.reliability.dataUnitsWritten "$NVME_DATA_WRITTEN" json_bool storage.disks.0.reliability.detailAvailable "$DETAIL_AVAILABLE" json_string storage.disks.0.reliability.source "diskutil.SMARTDeviceSpecificKeysMayVaryNotGuaranteed" fi unset DISK_INFO # Battery. Missing capacities/cycles are omitted, never converted to zero. json_string battery.batteryReportStatus "not-applicable-macos" if [[ "$BATTERY_PRESENT" == true ]]; then json_dict battery.batteries.0 json_bool battery.batteries.0.cycleCounterSupported false if is_int "$BAT_CYCLES"; then json_int battery.batteries.0.cycleCount "$BAT_CYCLES" # This field already exists; replacing this one known boolean is deliberate. "$PLUTIL" -replace battery.batteries.0.cycleCounterSupported -bool true "$WORK_PLIST" >/dev/null 2>&1 || json_write_error battery.batteries.0.cycleCounterSupported fi is_number "$BAT_HEALTH_PERCENT" && { json_float battery.batteries.0.healthPercent "$BAT_HEALTH_PERCENT"; json_string battery.batteries.0.capacityUnit "percent"; } is_number "$BAT_CHARGE" && json_float battery.batteries.0.estimatedChargeRemainingPercent "$BAT_CHARGE" [[ -n "$BAT_HEALTH_TEXT" ]] && json_string battery.batteries.0.status "$BAT_HEALTH_TEXT" json_string battery.batteries.0.source "system_profiler.SPPowerDataType" fi # Security + local management state. json_string security.activationLockStatus "$ACTIVATION_STATUS" json_string security.fileVaultStatus "$FILEVAULT_STATUS" json_string security.sipStatus "$SIP_STATUS" json_bool security.appleSilicon "$APPLE_SILICON" [[ "$MDM_STATE" == "true" ]] && json_bool management.mdmEnrolled true [[ "$MDM_STATE" == "false" ]] && json_bool management.mdmEnrolled false [[ "$ADE_STATE" == "true" ]] && json_bool management.automatedDeviceEnrollment true [[ "$ADE_STATE" == "false" ]] && json_bool management.automatedDeviceEnrollment false [[ "$MDM_USER_APPROVED" == "true" ]] && json_bool management.mdmUserApproved true [[ "$MDM_USER_APPROVED" == "false" ]] && json_bool management.mdmUserApproved false if [[ "$MDM_STATE" == "null" && "$ADE_STATE" == "null" ]]; then json_string management.enrollmentQueryStatus "unknown" else json_string management.enrollmentQueryStatus "available" fi json_string collection.hardware "available" json_string collection.battery "$([[ "$BATTERY_PRESENT" == true ]] && printf 'available' || printf 'not-reported')" json_string collection.storage "$([[ "$PHYSICAL_RESOLVED" == true ]] && printf 'available' || printf 'partial')" json_string collection.management "$([[ "$MDM_STATE" != "null" || "$ADE_STATE" != "null" ]] && printf 'available' || printf 'unknown')" json_string collection.security "available" # Validate the property-list tree before conversion. if ! "$PLUTIL" -lint "$WORK_PLIST" >/dev/null 2>&1; then print -u2 "No se pudo validar la estructura interna del reporte." rm -f "$OUTPUT_FILE" exit 3 fi # Convert once, at the end, to the JSON contract consumed by Auditoría Express. if ! "$PLUTIL" -convert json -r -o "$OUTPUT_FILE" "$WORK_PLIST" >/dev/null 2>&1; then print -u2 "No se pudo convertir el reporte estructurado a JSON." rm -f "$OUTPUT_FILE" exit 3 fi # The JSON is emitted by plutil itself. Parsing the three mandatory contract # fields below doubles as the final structural read-back check. # Contract guard: read the identifiers directly from the generated file. FINAL_SCHEMA="$("$PLUTIL" -extract schema raw -o - "$OUTPUT_FILE" 2>/dev/null || true)" FINAL_PLATFORM="$("$PLUTIL" -extract platform raw -o - "$OUTPUT_FILE" 2>/dev/null || true)" FINAL_COLLECTOR="$("$PLUTIL" -extract collectorVersion raw -o - "$OUTPUT_FILE" 2>/dev/null || true)" if [[ "$FINAL_SCHEMA" != "$SCHEMA" || "$FINAL_PLATFORM" != "macos" || "$FINAL_COLLECTOR" != "$COLLECTOR_VERSION" ]]; then print -u2 "El reporte no cumple el contrato lr-audit-express/1. No se entregará un archivo incompatible." rm -f "$OUTPUT_FILE" exit 4 fi unset FINAL_SCHEMA FINAL_PLATFORM FINAL_COLLECTOR COPIED=false if [[ -x "$BASE64" && -x "$PBCOPY" ]]; then PORTABLE="LR1:$($BASE64 < "$OUTPUT_FILE" | "$TR" -d '\r\n')" if printf '%s' "$PORTABLE" | "$PBCOPY" 2>/dev/null; then COPIED=true; fi unset PORTABLE fi print "" print "✓ Auditoría Express macOS completada." print "📦 Archivo: $OUTPUT_FILE" if [[ "$COPIED" == true ]]; then print "✓ Código LR1 copiado al portapapeles."; else print "ℹ Importa el archivo JSON en Auditoría Express."; fi print "🔒 El reporte no contiene usuario, nombre del equipo, seriales, UUID, IP, MAC, correo ni Apple Account." print "ℹ MDM/ADE usa solo el estado local de 'profiles status'; no se ejecuta 'profiles show', 'renew' ni ninguna consulta web."