Загрузка данных


--[[
    CLIENT EXPOSURE DEBUGGER v4.0
    Defensive client-surface auditor for your own Roblox place.

    Design goals:
      * Read-only: never fires remotes, prompts, click detectors, or mutates game state.
      * No executor-specific APIs (no getgc/getsenv/debug registry/hooks/decompiler/etc.).
      * Scans the place-relevant client-visible DataModel.
      * Categorizes findings instead of dumping one huge text blob.
      * Virtualized result list: only a small fixed pool of GUI rows exists at once.
      * Live-watch for newly replicated/streamed-in descendants.
      * Redacts values that look like secrets instead of printing them in full.
      * Close button uses Activated + MouseButton1Click fallback and full cleanup.

    IMPORTANT THREAT-MODEL NOTE:
      Presence of a finding is not proof of an exploit. Severity is REVIEW PRIORITY.
      Anything replicated to a compromised client must be treated as readable/controllable
      according to Roblox's client/server security model.
]]

local VERSION = "4.0"

local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local CollectionService = game:GetService("CollectionService")

local localPlayer = Players.LocalPlayer
if not localPlayer then
    warn("[Client Exposure Debugger] LocalPlayer is unavailable. Run this on the client.")
    return
end

local playerGui = localPlayer:WaitForChild("PlayerGui")

local CONFIG = {
    GUI_NAME = "client_exposure_debugger_v4",
    MAX_INSTANCES = 150000,
    SCAN_YIELD_EVERY = 300,
    ROW_HEIGHT = 30,
    ROW_POOL_SIZE = 48,
    PRINT_LIMIT = 5000,
    LIVE_REFRESH_INTERVAL = 0.35,
}

local COLORS = {
    bg = Color3.fromRGB(15, 16, 20),
    panel = Color3.fromRGB(22, 23, 29),
    panel2 = Color3.fromRGB(29, 30, 37),
    panel3 = Color3.fromRGB(36, 37, 45),
    stroke = Color3.fromRGB(62, 64, 76),
    text = Color3.fromRGB(235, 236, 242),
    dim = Color3.fromRGB(157, 161, 176),
    accent = Color3.fromRGB(104, 136, 255),
    info = Color3.fromRGB(123, 180, 255),
    low = Color3.fromRGB(155, 190, 130),
    medium = Color3.fromRGB(239, 188, 95),
    high = Color3.fromRGB(243, 126, 93),
    critical = Color3.fromRGB(255, 84, 108),
    selected = Color3.fromRGB(50, 56, 74),
}

local SEVERITY_RANK = {
    INFO = 0,
    LOW = 1,
    MEDIUM = 2,
    HIGH = 3,
    CRITICAL = 4,
}

local SEVERITY_COLOR = {
    INFO = COLORS.info,
    LOW = COLORS.low,
    MEDIUM = COLORS.medium,
    HIGH = COLORS.high,
    CRITICAL = COLORS.critical,
}

local CATEGORY_ORDER = {
    "ALL",
    "RISKS",
    "REMOTES",
    "CODE",
    "STATE",
    "INTERACTIONS",
    "PHYSICS",
    "UI",
    "ASSETS",
    "SYSTEM",
}

local CATEGORY_LABEL = {
    ALL = "All findings",
    RISKS = "Review queue",
    REMOTES = "Remotes / IPC",
    CODE = "Client code",
    STATE = "State / variables",
    INTERACTIONS = "Interactions",
    PHYSICS = "Physics",
    UI = "UI exposure",
    ASSETS = "Assets",
    SYSTEM = "System notes",
}

local SECRET_WORDS = {
    "secret", "token", "password", "passwd", "apikey", "api_key",
    "privatekey", "private_key", "webhook", "authorization", "authkey",
    "bearer", "credential",
}

local SENSITIVE_WORDS = {
    "admin", "owner", "moderator", "ban", "kick", "grant", "give",
    "reward", "purchase", "buy", "sell", "money", "cash", "coin",
    "currency", "inventory", "item", "weapon", "damage", "cooldown",
    "teleport", "speed", "level", "xp", "experience", "save", "load",
    "data", "profile", "anticheat", "anti_cheat", "security", "permission",
}

local ASSET_PROPERTIES = {
    Sound = {"SoundId"},
    Animation = {"AnimationId"},
    MeshPart = {"MeshId", "TextureID"},
    SpecialMesh = {"MeshId", "TextureId"},
    ImageLabel = {"Image"},
    ImageButton = {"Image"},
    Decal = {"Texture"},
    Texture = {"Texture"},
    ParticleEmitter = {"Texture"},
    Beam = {"Texture"},
    Trail = {"Texture"},
    VideoFrame = {"Video"},
    Shirt = {"ShirtTemplate"},
    Pants = {"PantsTemplate"},
    SurfaceAppearance = {"ColorMap", "NormalMap", "MetalnessMap", "RoughnessMap"},
}

local PHYSICS_CLASS_NAMES = {
    LinearVelocity = true,
    AngularVelocity = true,
    VectorForce = true,
    LineForce = true,
    Torque = true,
    AlignPosition = true,
    AlignOrientation = true,
    BodyVelocity = true,
    BodyAngularVelocity = true,
    BodyPosition = true,
    BodyGyro = true,
    RocketPropulsion = true,
}

-- Cleanup previous copy.
local oldGui = playerGui:FindFirstChild(CONFIG.GUI_NAME)
if oldGui then
    oldGui:Destroy()
end

local connections = {}
local closed = false
local scanGeneration = 0
local scanning = false
local liveDirty = false
local lastLiveRefresh = 0

local function trackConnection(connection)
    table.insert(connections, connection)
    return connection
end

local function safeDisconnect(connection)
    pcall(function()
        connection:Disconnect()
    end)
end

local function new(className, props, parent)
    local object = Instance.new(className)
    if props then
        for key, value in pairs(props) do
            object[key] = value
        end
    end
    if parent then
        object.Parent = parent
    end
    return object
end

local function addCorner(parent, radius)
    return new("UICorner", {CornerRadius = UDim.new(0, radius or 8)}, parent)
end

local function addStroke(parent, color, thickness, transparency)
    return new("UIStroke", {
        Color = color or COLORS.stroke,
        Thickness = thickness or 1,
        Transparency = transparency or 0,
    }, parent)
end

local function safeGet(object, property)
    local ok, value = pcall(function()
        return object[property]
    end)
    if ok then
        return value
    end
    return nil
end

local function safeFullName(object)
    local ok, value = pcall(function()
        return object:GetFullName()
    end)
    if ok then
        return value
    end
    return object.Name
end

local function lower(value)
    return string.lower(tostring(value or ""))
end

local function containsAny(text, words)
    text = lower(text)
    for _, word in ipairs(words) do
        if string.find(text, word, 1, true) then
            return true, word
        end
    end
    return false, nil
end

local function isSecretContext(text)
    local found = containsAny(text, SECRET_WORDS)
    return found
end

local function isSensitiveContext(text)
    local found = containsAny(text, SENSITIVE_WORDS)
    return found
end

local function looksLikeSecretValue(value)
    if typeof(value) ~= "string" then
        return false
    end
    local text = lower(value)
    if string.find(text, "discord.com/api/webhooks/", 1, true)
        or string.find(text, "discordapp.com/api/webhooks/", 1, true)
        or string.find(text, "authorization: bearer", 1, true)
        or string.find(text, "private key", 1, true)
    then
        return true
    end
    return false
end

local function valueToString(value, context)
    local valueType = typeof(value)

    if isSecretContext(context) or looksLikeSecretValue(value) then
        local length = valueType == "string" and #value or 0
        if length > 0 then
            return string.format("<REDACTED client-visible value; length=%d>", length)
        end
        return "<REDACTED client-visible value>"
    end

    if valueType == "Instance" then
        return "<Instance> " .. safeFullName(value)
    end

    if valueType == "string" then
        local text = value
        if #text > 500 then
            text = string.sub(text, 1, 500) .. "…"
        end
        return text
    end

    local ok, result = pcall(function()
        return tostring(value)
    end)
    if ok then
        return result
    end

    return "<unprintable " .. valueType .. ">"
end

-- ============================================================
-- GUI
-- ============================================================

local gui = new("ScreenGui", {
    Name = CONFIG.GUI_NAME,
    ResetOnSpawn = false,
    IgnoreGuiInset = true,
    DisplayOrder = 100000,
    ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
}, playerGui)

local main = new("Frame", {
    Name = "window",
    AnchorPoint = Vector2.new(0.5, 0.5),
    Position = UDim2.fromScale(0.5, 0.5),
    Size = UDim2.new(0.94, 0, 0.88, 0),
    BackgroundColor3 = COLORS.bg,
    BorderSizePixel = 0,
    ClipsDescendants = true,
    ZIndex = 10,
}, gui)
addCorner(main, 12)
addStroke(main, COLORS.stroke, 1, 0)

local header = new("Frame", {
    Name = "header",
    Size = UDim2.new(1, 0, 0, 48),
    BackgroundColor3 = COLORS.panel,
    BorderSizePixel = 0,
    Active = true,
    ZIndex = 100,
}, main)

local title = new("TextLabel", {
    Position = UDim2.fromOffset(16, 0),
    Size = UDim2.new(1, -330, 1, 0),
    BackgroundTransparency = 1,
    Text = "CLIENT EXPOSURE DEBUGGER  v" .. VERSION,
    TextColor3 = COLORS.text,
    Font = Enum.Font.Code,
    TextSize = 18,
    TextXAlignment = Enum.TextXAlignment.Left,
    ZIndex = 110,
}, header)

local headerStatus = new("TextLabel", {
    AnchorPoint = Vector2.new(1, 0),
    Position = UDim2.new(1, -58, 0, 0),
    Size = UDim2.fromOffset(250, 48),
    BackgroundTransparency = 1,
    Text = "initializing…",
    TextColor3 = COLORS.dim,
    Font = Enum.Font.Code,
    TextSize = 13,
    TextXAlignment = Enum.TextXAlignment.Right,
    ZIndex = 110,
}, header)

local closeButton = new("TextButton", {
    Name = "close",
    AnchorPoint = Vector2.new(1, 0),
    Position = UDim2.new(1, -8, 0, 7),
    Size = UDim2.fromOffset(36, 34),
    BackgroundColor3 = COLORS.panel3,
    BorderSizePixel = 0,
    Text = "×",
    TextColor3 = COLORS.text,
    Font = Enum.Font.GothamBold,
    TextSize = 24,
    AutoButtonColor = true,
    Active = true,
    ZIndex = 200,
}, header)
addCorner(closeButton, 8)

local sidebar = new("Frame", {
    Position = UDim2.fromOffset(0, 48),
    Size = UDim2.new(0, 202, 1, -48),
    BackgroundColor3 = COLORS.panel,
    BorderSizePixel = 0,
    ZIndex = 20,
}, main)

local summaryLabel = new("TextLabel", {
    Position = UDim2.fromOffset(12, 12),
    Size = UDim2.new(1, -24, 0, 128),
    BackgroundColor3 = COLORS.panel2,
    BorderSizePixel = 0,
    Text = "Waiting for scan…",
    TextColor3 = COLORS.dim,
    Font = Enum.Font.Code,
    TextSize = 12,
    TextWrapped = true,
    TextXAlignment = Enum.TextXAlignment.Left,
    TextYAlignment = Enum.TextYAlignment.Top,
    ZIndex = 25,
}, sidebar)
addCorner(summaryLabel, 8)

local tabsContainer = new("Frame", {
    Position = UDim2.fromOffset(8, 150),
    Size = UDim2.new(1, -16, 1, -282),
    BackgroundTransparency = 1,
    BorderSizePixel = 0,
    ZIndex = 25,
}, sidebar)

local tabLayout = new("UIListLayout", {
    FillDirection = Enum.FillDirection.Vertical,
    Padding = UDim.new(0, 5),
    SortOrder = Enum.SortOrder.LayoutOrder,
}, tabsContainer)

local rescanButton = new("TextButton", {
    AnchorPoint = Vector2.new(0, 1),
    Position = UDim2.new(0, 10, 1, -92),
    Size = UDim2.new(1, -20, 0, 36),
    BackgroundColor3 = COLORS.accent,
    BorderSizePixel = 0,
    Text = "RESCAN",
    TextColor3 = Color3.new(1, 1, 1),
    Font = Enum.Font.GothamBold,
    TextSize = 13,
    ZIndex = 30,
}, sidebar)
addCorner(rescanButton, 8)

local printButton = new("TextButton", {
    AnchorPoint = Vector2.new(0, 1),
    Position = UDim2.new(0, 10, 1, -50),
    Size = UDim2.new(1, -20, 0, 34),
    BackgroundColor3 = COLORS.panel3,
    BorderSizePixel = 0,
    Text = "PRINT FILTERED",
    TextColor3 = COLORS.text,
    Font = Enum.Font.GothamBold,
    TextSize = 12,
    ZIndex = 30,
}, sidebar)
addCorner(printButton, 8)

local hintLabel = new("TextLabel", {
    AnchorPoint = Vector2.new(0, 1),
    Position = UDim2.new(0, 12, 1, -8),
    Size = UDim2.new(1, -24, 0, 34),
    BackgroundTransparency = 1,
    Text = "RightShift = hide/show\nEnd = close",
    TextColor3 = COLORS.dim,
    Font = Enum.Font.Code,
    TextSize = 10,
    TextXAlignment = Enum.TextXAlignment.Left,
    TextYAlignment = Enum.TextYAlignment.Top,
    ZIndex = 25,
}, sidebar)

local detailsWidth = 350
local center = new("Frame", {
    Position = UDim2.fromOffset(202, 48),
    Size = UDim2.new(1, -(202 + detailsWidth), 1, -48),
    BackgroundColor3 = COLORS.bg,
    BorderSizePixel = 0,
    ZIndex = 20,
}, main)

local toolsBar = new("Frame", {
    Position = UDim2.fromOffset(10, 10),
    Size = UDim2.new(1, -20, 0, 38),
    BackgroundTransparency = 1,
    BorderSizePixel = 0,
    ZIndex = 30,
}, center)

local searchBox = new("TextBox", {
    Position = UDim2.fromOffset(0, 0),
    Size = UDim2.new(1, -178, 1, 0),
    BackgroundColor3 = COLORS.panel2,
    BorderSizePixel = 0,
    PlaceholderText = "Search path / name / value / note…",
    PlaceholderColor3 = COLORS.dim,
    Text = "",
    TextColor3 = COLORS.text,
    Font = Enum.Font.Code,
    TextSize = 13,
    ClearTextOnFocus = false,
    TextXAlignment = Enum.TextXAlignment.Left,
    ZIndex = 35,
}, toolsBar)
addCorner(searchBox, 8)
new("UIPadding", {
    PaddingLeft = UDim.new(0, 10),
    PaddingRight = UDim.new(0, 10),
}, searchBox)

local severityButton = new("TextButton", {
    AnchorPoint = Vector2.new(1, 0),
    Position = UDim2.new(1, 0, 0, 0),
    Size = UDim2.fromOffset(168, 38),
    BackgroundColor3 = COLORS.panel2,
    BorderSizePixel = 0,
    Text = "Severity: ALL",
    TextColor3 = COLORS.text,
    Font = Enum.Font.Code,
    TextSize = 12,
    ZIndex = 35,
}, toolsBar)
addCorner(severityButton, 8)

local listFrame = new("ScrollingFrame", {
    Position = UDim2.fromOffset(10, 56),
    Size = UDim2.new(1, -20, 1, -90),
    BackgroundColor3 = COLORS.panel,
    BorderSizePixel = 0,
    ScrollBarThickness = 6,
    ScrollBarImageColor3 = COLORS.stroke,
    CanvasSize = UDim2.fromOffset(0, 0),
    AutomaticCanvasSize = Enum.AutomaticSize.None,
    ScrollingDirection = Enum.ScrollingDirection.Y,
    ElasticBehavior = Enum.ElasticBehavior.WhenScrollable,
    ZIndex = 25,
}, center)
addCorner(listFrame, 8)

local emptyLabel = new("TextLabel", {
    AnchorPoint = Vector2.new(0.5, 0.5),
    Position = UDim2.fromScale(0.5, 0.5),
    Size = UDim2.new(1, -30, 0, 70),
    BackgroundTransparency = 1,
    Text = "No findings in this view.",
    TextColor3 = COLORS.dim,
    Font = Enum.Font.Code,
    TextSize = 14,
    TextWrapped = true,
    ZIndex = 26,
}, listFrame)

local listFooter = new("TextLabel", {
    AnchorPoint = Vector2.new(0, 1),
    Position = UDim2.new(0, 10, 1, -6),
    Size = UDim2.new(1, -20, 0, 22),
    BackgroundTransparency = 1,
    Text = "0 results",
    TextColor3 = COLORS.dim,
    Font = Enum.Font.Code,
    TextSize = 11,
    TextXAlignment = Enum.TextXAlignment.Left,
    ZIndex = 30,
}, center)

local details = new("Frame", {
    AnchorPoint = Vector2.new(1, 0),
    Position = UDim2.new(1, 0, 0, 48),
    Size = UDim2.new(0, detailsWidth, 1, -48),
    BackgroundColor3 = COLORS.panel,
    BorderSizePixel = 0,
    ZIndex = 20,
}, main)

local detailsTitle = new("TextLabel", {
    Position = UDim2.fromOffset(14, 12),
    Size = UDim2.new(1, -28, 0, 28),
    BackgroundTransparency = 1,
    Text = "DETAILS",
    TextColor3 = COLORS.text,
    Font = Enum.Font.GothamBold,
    TextSize = 14,
    TextXAlignment = Enum.TextXAlignment.Left,
    ZIndex = 25,
}, details)

local detailsBody = new("ScrollingFrame", {
    Position = UDim2.fromOffset(12, 48),
    Size = UDim2.new(1, -24, 1, -100),
    BackgroundColor3 = COLORS.panel2,
    BorderSizePixel = 0,
    ScrollBarThickness = 5,
    ScrollBarImageColor3 = COLORS.stroke,
    AutomaticCanvasSize = Enum.AutomaticSize.Y,
    CanvasSize = UDim2.fromOffset(0, 0),
    ScrollingDirection = Enum.ScrollingDirection.Y,
    ZIndex = 25,
}, details)
addCorner(detailsBody, 8)

local detailsText = new("TextLabel", {
    Position = UDim2.fromOffset(10, 10),
    Size = UDim2.new(1, -20, 0, 0),
    AutomaticSize = Enum.AutomaticSize.Y,
    BackgroundTransparency = 1,
    Text = "Select a finding to inspect it.\n\nSeverity is review priority, not proof of an exploit.",
    TextColor3 = COLORS.text,
    Font = Enum.Font.Code,
    TextSize = 12,
    TextWrapped = true,
    TextXAlignment = Enum.TextXAlignment.Left,
    TextYAlignment = Enum.TextYAlignment.Top,
    ZIndex = 26,
}, detailsBody)

local selectedPrintButton = new("TextButton", {
    AnchorPoint = Vector2.new(0, 1),
    Position = UDim2.new(0, 12, 1, -10),
    Size = UDim2.new(1, -24, 0, 34),
    BackgroundColor3 = COLORS.panel3,
    BorderSizePixel = 0,
    Text = "PRINT SELECTED",
    TextColor3 = COLORS.text,
    Font = Enum.Font.GothamBold,
    TextSize = 12,
    ZIndex = 25,
}, details)
addCorner(selectedPrintButton, 8)

-- ============================================================
-- Window close / hide / drag
-- ============================================================

local function shutdown()
    if closed then
        return
    end
    closed = true
    scanGeneration = scanGeneration + 1
    for _, connection in ipairs(connections) do
        safeDisconnect(connection)
    end
    connections = {}
    if gui and gui.Parent then
        gui:Destroy()
    end
end

-- Activated is the primary path. MouseButton1Click is a fallback for clients/executors
-- that have odd input routing. The guard in shutdown() makes double-fire harmless.
trackConnection(closeButton.Activated:Connect(shutdown))
trackConnection(closeButton.MouseButton1Click:Connect(shutdown))

local dragging = false
local dragStart = nil
local startPosition = nil

trackConnection(header.InputBegan:Connect(function(input)
    if input.UserInputType == Enum.UserInputType.MouseButton1
        or input.UserInputType == Enum.UserInputType.Touch
    then
        dragging = true
        dragStart = input.Position
        startPosition = main.Position
    end
end))

trackConnection(UserInputService.InputChanged:Connect(function(input)
    if not dragging or not dragStart or not startPosition then
        return
    end
    if input.UserInputType == Enum.UserInputType.MouseMovement
        or input.UserInputType == Enum.UserInputType.Touch
    then
        local delta = input.Position - dragStart
        main.Position = UDim2.new(
            startPosition.X.Scale,
            startPosition.X.Offset + delta.X,
            startPosition.Y.Scale,
            startPosition.Y.Offset + delta.Y
        )
    end
end))

trackConnection(UserInputService.InputEnded:Connect(function(input)
    if input.UserInputType == Enum.UserInputType.MouseButton1
        or input.UserInputType == Enum.UserInputType.Touch
    then
        dragging = false
        dragStart = nil
        startPosition = nil
    end
end))

trackConnection(UserInputService.InputBegan:Connect(function(input, processed)
    if processed then
        return
    end
    if input.KeyCode == Enum.KeyCode.RightShift then
        main.Visible = not main.Visible
    elseif input.KeyCode == Enum.KeyCode.End then
        shutdown()
    end
end))

-- ============================================================
-- Data model / findings
-- ============================================================

local findings = {}
local filteredFindings = {}
local findingKeys = {}
local seenAssemblies = {}
local seenAssets = {}
local currentTab = "ALL"
local selectedFinding = nil
local severityModeIndex = 1

local SEVERITY_MODES = {
    {label = "ALL", minRank = 0},
    {label = "MEDIUM+", minRank = 2},
    {label = "HIGH+", minRank = 3},
    {label = "CRITICAL", minRank = 4},
}

local stats = {}

local function resetStats()
    stats = {
        instances = 0,
        attributes = 0,
        tags = 0,
        values = 0,
        remotes = 0,
        code = 0,
        interactions = 0,
        assemblies = 0,
        ui = 0,
        assets = 0,
        risks = 0,
        high = 0,
        critical = 0,
        truncated = false,
    }
end
resetStats()

local function makeFindingKey(category, path, title, key)
    return table.concat({category or "", path or "", title or "", key or ""}, "\31")
end

local function addFinding(category, severity, titleText, object, key, value, note, risk)
    if closed then
        return
    end

    local path = object and safeFullName(object) or "<system>"
    local uniqueKey = makeFindingKey(category, path, titleText, key)
    if findingKeys[uniqueKey] then
        return
    end
    findingKeys[uniqueKey] = true

    local context = table.concat({path, titleText or "", key or ""}, " ")
    local displayValue = value == nil and "" or valueToString(value, context)

    local finding = {
        category = category,
        severity = severity,
        rank = SEVERITY_RANK[severity] or 0,
        title = titleText or "Finding",
        path = path,
        key = key or "",
        value = displayValue,
        note = note or "",
        risk = risk == true,
        className = object and object.ClassName or "System",
        object = object,
    }

    table.insert(findings, finding)

    if finding.risk then
        stats.risks = stats.risks + 1
    end
    if finding.severity == "HIGH" then
        stats.high = stats.high + 1
    elseif finding.severity == "CRITICAL" then
        stats.critical = stats.critical + 1
    end

    liveDirty = true
end

local function severityForContext(context, baseSeverity)
    if isSecretContext(context) then
        return "CRITICAL", true
    end
    if isSensitiveContext(context) then
        local base = SEVERITY_RANK[baseSeverity] or 0
        if base < SEVERITY_RANK.HIGH then
            return "HIGH", true
        end
    end
    return baseSeverity, (SEVERITY_RANK[baseSeverity] or 0) >= SEVERITY_RANK.LOW
end

local function safeGetTags(object)
    local ok, tags = pcall(function()
        return CollectionService:GetTags(object)
    end)
    if ok then
        return tags
    end
    return nil
end

local function getPlayerFromDescendant(object)
    local model = object:FindFirstAncestorOfClass("Model")
    if model then
        local ok, player = pcall(function()
            return Players:GetPlayerFromCharacter(model)
        end)
        if ok then
            return player
        end
    end
    return nil
end

local function inspectAssetProperties(object)
    for className, properties in pairs(ASSET_PROPERTIES) do
        if object:IsA(className) then
            for _, property in ipairs(properties) do
                local value = safeGet(object, property)
                if value ~= nil then
                    local text = tostring(value)
                    if text ~= "" then
                        local dedupeKey = className .. "|" .. property .. "|" .. text
                        if not seenAssets[dedupeKey] then
                            seenAssets[dedupeKey] = true
                            stats.assets = stats.assets + 1
                            addFinding(
                                "ASSETS",
                                "INFO",
                                className .. "." .. property,
                                object,
                                property,
                                value,
                                "Client-visible asset/content reference.",
                                false
                            )
                        end
                    end
                end
            end
        end
    end
end

local function inspectAttributes(object)
    local ok, attributes = pcall(function()
        return object:GetAttributes()
    end)
    if not ok then
        return
    end

    for name, value in pairs(attributes) do
        stats.attributes = stats.attributes + 1
        local context = safeFullName(object) .. " " .. name
        local severity, risk = severityForContext(context, "INFO")
        local note = "Attribute is replicated with this instance and readable by the client."
        if severity == "CRITICAL" then
            note = "Potential secret-like client-visible attribute. Move secrets/server authority out of replicated state. Value is redacted here."
        elseif severity == "HIGH" then
            note = "Sensitive gameplay/state attribute. Treat it as untrusted client-visible information; server must remain authoritative."
        end
        addFinding("STATE", severity, "Attribute", object, name, value, note, risk)
    end
end

local function inspectTags(object)
    local tags = safeGetTags(object)
    if not tags then
        return
    end
    for _, tag in ipairs(tags) do
        stats.tags = stats.tags + 1
        local severity, risk = severityForContext(safeFullName(object) .. " " .. tag, "INFO")
        addFinding(
            "STATE",
            severity,
            "CollectionService tag",
            object,
            "tag",
            tag,
            "Tag names are visible to the client and can reveal gameplay classification/architecture.",
            risk
        )
    end
end

local function inspectValueObject(object)
    if not object:IsA("ValueBase") then
        return
    end

    stats.values = stats.values + 1
    local value = safeGet(object, "Value")
    local context = safeFullName(object) .. " " .. object.Name
    local severity, risk = severityForContext(context, "INFO")
    local note = "ValueBase content is client-visible when the instance is replicated."
    if severity == "CRITICAL" then
        note = "Potential secret-like ValueBase. Never keep secrets in replicated instances. Value is redacted here."
    elseif severity == "HIGH" then
        note = "Sensitive gameplay/economy/config value is visible. Server must not trust client copies of this state."
    end
    addFinding("STATE", severity, object.ClassName, object, "Value", value, note, risk)
end

local function inspectRemote(object)
    if object:IsA("RemoteEvent")
        or object:IsA("RemoteFunction")
        or object:IsA("UnreliableRemoteEvent")
    then
        stats.remotes = stats.remotes + 1
        local context = safeFullName(object)
        local severity, risk = severityForContext(context, "LOW")
        local note = "Server boundary surface. Validate permission/context, types, ranges and rate limits server-side. Presence alone is not a vulnerability."

        if object:IsA("RemoteFunction") then
            local callback = safeGet(object, "OnClientInvoke")
            if typeof(callback) == "function" then
                note = note .. " A client OnClientInvoke callback is currently bound."
            end
        end

        addFinding("REMOTES", severity, object.ClassName, object, "name", object.Name, note, risk)
        return
    end

    if object:IsA("BindableEvent") or object:IsA("BindableFunction") then
        addFinding(
            "REMOTES",
            "INFO",
            object.ClassName .. " (local IPC)",
            object,
            "name",
            object.Name,
            "Bindable objects are client-local IPC when present on the client; they do not cross the client/server boundary by themselves.",
            false
        )
    end
end

local function inspectCode(object)
    local isClientCode = false
    local codeType = nil

    if object:IsA("LocalScript") then
        isClientCode = true
        codeType = "LocalScript"
    elseif object:IsA("ModuleScript") then
        isClientCode = true
        codeType = "ModuleScript"
    elseif object:IsA("Script") then
        local runContext = safeGet(object, "RunContext")
        if runContext == Enum.RunContext.Client then
            isClientCode = true
            codeType = "Script (RunContext=Client)"
        end
    end

    if not isClientCode then
        return
    end

    stats.code = stats.code + 1
    local context = safeFullName(object)
    local severity, risk = severityForContext(context, "LOW")
    local note = "Replicated client code should be assumed readable/decompilable by a compromised client. This auditor intentionally does not read Source or decompile anything."
    if severity == "CRITICAL" or severity == "HIGH" then
        note = note .. " Sensitive/security-themed naming raises review priority: secrets and authoritative checks must remain server-only."
    end

    addFinding("CODE", severity, codeType, object, "Enabled", safeGet(object, "Enabled"), note, risk)
end

local function inspectInteraction(object)
    if object:IsA("ProximityPrompt") then
        stats.interactions = stats.interactions + 1
        local summary = string.format(
            "Action=%s | Object=%s | Hold=%.2f | MaxDistance=%.2f | Enabled=%s",
            tostring(safeGet(object, "ActionText") or ""),
            tostring(safeGet(object, "ObjectText") or ""),
            tonumber(safeGet(object, "HoldDuration") or 0) or 0,
            tonumber(safeGet(object, "MaxActivationDistance") or 0) or 0,
            tostring(safeGet(object, "Enabled"))
        )
        addFinding(
            "INTERACTIONS",
            "MEDIUM",
            "ProximityPrompt",
            object,
            "settings",
            summary,
            "Client-triggered interaction surface. Server must validate distance, state, permissions and rate independently of prompt settings.",
            true
        )
        return
    end

    if object:IsA("ClickDetector") then
        stats.interactions = stats.interactions + 1
        addFinding(
            "INTERACTIONS",
            "MEDIUM",
            "ClickDetector",
            object,
            "MaxActivationDistance",
            safeGet(object, "MaxActivationDistance"),
            "Client-triggered interaction surface. Do not rely on client-side distance or click legitimacy alone.",
            true
        )
        return
    end

    if object:IsA("DragDetector") then
        stats.interactions = stats.interactions + 1
        addFinding(
            "INTERACTIONS",
            "MEDIUM",
            "DragDetector",
            object,
            "Enabled",
            safeGet(object, "Enabled"),
            "Client interaction surface. Server-side state/permission validation is still required for consequential actions.",
            true
        )
    end
end

local function inspectPhysics(object)
    if object:IsA("BasePart") then
        local anchored = safeGet(object, "Anchored")
        if anchored == false then
            local root = safeGet(object, "AssemblyRootPart") or object
            if root and not seenAssemblies[root] then
                seenAssemblies[root] = true
                stats.assemblies = stats.assemblies + 1

                local ownerPlayer = getPlayerFromDescendant(root)
                local severity = "LOW"
                local note = "Unanchored assembly is a network-ownership candidate. Roblox may assign client simulation authority; validate consequential physics/touch outcomes on the server."
                if ownerPlayer == localPlayer then
                    severity = "MEDIUM"
                    note = "Local player's character assembly is client-simulated. Movement/physics anti-cheat must use server validation rather than trusting client state."
                elseif ownerPlayer then
                    note = "Another player's character assembly is replicated client-side. Treat observed client state as non-authoritative."
                end

                local value = string.format(
                    "Root=%s | Mass=%s | CanCollide=%s | CanTouch=%s | CanQuery=%s",
                    safeFullName(root),
                    tostring(safeGet(root, "AssemblyMass")),
                    tostring(safeGet(root, "CanCollide")),
                    tostring(safeGet(root, "CanTouch")),
                    tostring(safeGet(root, "CanQuery"))
                )

                addFinding("PHYSICS", severity, "Unanchored assembly", root, "physics", value, note, true)
            end
        end
        return
    end

    if PHYSICS_CLASS_NAMES[object.ClassName] then
        addFinding(
            "PHYSICS",
            "LOW",
            "Physics controller: " .. object.ClassName,
            object,
            "Enabled",
            safeGet(object, "Enabled"),
            "Client-visible physics controller/constraint. Review ownership and ensure server validates consequential motion/state.",
            true
        )
        return
    end

    if object.ClassName == "TouchTransmitter" then
        addFinding(
            "PHYSICS",
            "MEDIUM",
            "TouchTransmitter",
            object,
            "type",
            object.ClassName,
            "Touch-based gameplay should be server-validated because client-owned physics can influence touch reports.",
            true
        )
    end
end

local function inspectUI(object)
    if object:IsA("ScreenGui") or object:IsA("SurfaceGui") or object:IsA("BillboardGui") then
        stats.ui = stats.ui + 1
        local value = string.format(
            "Enabled=%s | DisplayOrder=%s | ResetOnSpawn=%s",
            tostring(safeGet(object, "Enabled")),
            tostring(safeGet(object, "DisplayOrder")),
            tostring(safeGet(object, "ResetOnSpawn"))
        )
        addFinding(
            "UI",
            "INFO",
            object.ClassName,
            object,
            "settings",
            value,
            "Client-visible GUI root. UI visibility/state is not an authority boundary.",
            false
        )
        return
    end

    if object:IsA("TextButton") or object:IsA("TextLabel") or object:IsA("TextBox") then
        local text = safeGet(object, "Text")
        if text ~= nil and tostring(text) ~= "" then
            stats.ui = stats.ui + 1
            local context = safeFullName(object) .. " text"
            local severity, risk = severityForContext(context, "INFO")
            addFinding(
                "UI",
                severity,
                object.ClassName .. " text",
                object,
                "Text",
                text,
                "Rendered/client-held text can reveal runtime state, labels, internal naming or values. Do not treat hidden UI as confidential.",
                risk
            )
        end
        return
    end

    if object:IsA("ImageButton") then
        stats.ui = stats.ui + 1
        addFinding(
            "UI",
            "INFO",
            "ImageButton",
            object,
            "Visible",
            safeGet(object, "Visible"),
            "Client-visible clickable UI surface.",
            false
        )
    end
end

local function inspectRuntimeState(object)
    if object:IsA("Humanoid") then
        local value = string.format(
            "Health=%s/%s | WalkSpeed=%s | JumpPower=%s | HipHeight=%s",
            tostring(safeGet(object, "Health")),
            tostring(safeGet(object, "MaxHealth")),
            tostring(safeGet(object, "WalkSpeed")),
            tostring(safeGet(object, "JumpPower")),
            tostring(safeGet(object, "HipHeight"))
        )
        addFinding(
            "STATE",
            "LOW",
            "Humanoid runtime state",
            object,
            "state",
            value,
            "Humanoid properties are client-visible and some can be locally modified; server anti-cheat should validate outcomes rather than trust the local copy.",
            true
        )
    elseif object:IsA("Tool") then
        addFinding(
            "STATE",
            "INFO",
            "Tool",
            object,
            "Enabled",
            safeGet(object, "Enabled"),
            "Client-visible inventory/tool structure can reveal available actions and metadata.",
            false
        )
    elseif object:IsA("Configuration") then
        addFinding(
            "STATE",
            "INFO",
            "Configuration container",
            object,
            "name",
            object.Name,
            "Replicated Configuration structure is client-visible; inspect its attributes/children for sensitive state.",
            false
        )
    end
end

local function isDebuggerObject(object)
    if object == gui then
        return true
    end
    local ok, result = pcall(function()
        return object:IsDescendantOf(gui)
    end)
    return ok and result or false
end

local function inspectInstance(object)
    if closed or not object or isDebuggerObject(object) then
        return
    end

    stats.instances = stats.instances + 1

    inspectAttributes(object)
    inspectTags(object)
    inspectValueObject(object)
    inspectRemote(object)
    inspectCode(object)
    inspectInteraction(object)
    inspectPhysics(object)
    inspectUI(object)
    inspectAssetProperties(object)
    inspectRuntimeState(object)
end

-- ============================================================
-- Scope
-- ============================================================

local function tryGetService(name)
    local ok, service = pcall(function()
        return game:GetService(name)
    end)
    if ok then
        return service
    end
    return nil
end

local scanRoots = {}
local scanRootSet = {}

local function addScanRoot(name)
    local service = tryGetService(name)
    if service and not scanRootSet[service] then
        scanRootSet[service] = true
        table.insert(scanRoots, service)
    end
end

-- Place-relevant roots. Deliberately excludes Roblox engine-internal CoreGui/CorePackages noise.
addScanRoot("ReplicatedFirst")
addScanRoot("ReplicatedStorage")
addScanRoot("Workspace")
addScanRoot("Players")
addScanRoot("Lighting")
addScanRoot("SoundService")
addScanRoot("StarterGui")
addScanRoot("StarterPlayer")
addScanRoot("StarterPack")
addScanRoot("Teams")
addScanRoot("TextChatService")
addScanRoot("LocalizationService")
addScanRoot("MaterialService")

local function isInScope(object)
    if isDebuggerObject(object) then
        return false
    end
    for _, root in ipairs(scanRoots) do
        if object == root then
            return true
        end
        local ok, result = pcall(function()
            return object:IsDescendantOf(root)
        end)
        if ok and result then
            return true
        end
    end
    return false
end

-- ============================================================
-- Filtering / virtualized list
-- ============================================================

local tabButtons = {}
local rowPool = {}
local rowIndexMap = {}

local function findingMatchesSearch(finding, query)
    if query == "" then
        return true
    end
    local haystack = lower(table.concat({
        finding.category or "",
        finding.severity or "",
        finding.title or "",
        finding.path or "",
        finding.key or "",
        finding.value or "",
        finding.note or "",
        finding.className or "",
    }, " "))
    return string.find(haystack, query, 1, true) ~= nil
end

local function findingMatchesTab(finding)
    if currentTab == "ALL" then
        return true
    elseif currentTab == "RISKS" then
        return finding.risk == true
    end
    return finding.category == currentTab
end

local function updateTabTexts()
    local counts = {}
    for _, category in ipairs(CATEGORY_ORDER) do
        counts[category] = 0
    end

    counts.ALL = #findings
    for _, finding in ipairs(findings) do
        if finding.risk then
            counts.RISKS = counts.RISKS + 1
        end
        if counts[finding.category] ~= nil then
            counts[finding.category] = counts[finding.category] + 1
        end
    end

    for category, button in pairs(tabButtons) do
        button.Text = string.format("%-15s %d", CATEGORY_LABEL[category] or category, counts[category] or 0)
        if category == currentTab then
            button.BackgroundColor3 = COLORS.selected
            button.TextColor3 = COLORS.text
        else
            button.BackgroundColor3 = COLORS.panel2
            button.TextColor3 = COLORS.dim
        end
    end
end

local function updateSummary()
    summaryLabel.Text = string.format(
        "Scanned: %d\nRemotes: %d   Code: %d\nState: %d attrs / %d values\nInteractions: %d\nPhysics assemblies: %d\nAssets: %d\nReview: %d   High: %d   Critical: %d%s",
        stats.instances,
        stats.remotes,
        stats.code,
        stats.attributes,
        stats.values,
        stats.interactions,
        stats.assemblies,
        stats.assets,
        stats.risks,
        stats.high,
        stats.critical,
        stats.truncated and "\n⚠ Scan truncated by limit" or ""
    )
end

local function findingDetails(finding)
    if not finding then
        return "Select a finding to inspect it.\n\nSeverity is review priority, not proof of an exploit."
    end

    local lines = {
        "SEVERITY: " .. finding.severity,
        "CATEGORY: " .. finding.category,
        "TYPE: " .. finding.className,
        "TITLE: " .. finding.title,
        "",
        "PATH:",
        finding.path,
    }

    if finding.key ~= "" then
        table.insert(lines, "")
        table.insert(lines, "KEY:")
        table.insert(lines, finding.key)
    end

    if finding.value ~= "" then
        table.insert(lines, "")
        table.insert(lines, "VALUE:")
        table.insert(lines, finding.value)
    end

    if finding.note ~= "" then
        table.insert(lines, "")
        table.insert(lines, "WHY IT MATTERS:")
        table.insert(lines, finding.note)
    end

    table.insert(lines, "")
    table.insert(lines, "RISK FLAG: " .. tostring(finding.risk))

    return table.concat(lines, "\n")
end

local function selectFinding(finding)
    selectedFinding = finding
    detailsText.Text = findingDetails(finding)
    detailsText.TextColor3 = finding and (SEVERITY_COLOR[finding.severity] or COLORS.text) or COLORS.text
    detailsBody.CanvasPosition = Vector2.new(0, 0)
end

local function renderVirtualRows()
    if closed then
        return
    end

    local count = #filteredFindings
    emptyLabel.Visible = count == 0
    listFrame.CanvasSize = UDim2.fromOffset(0, count * CONFIG.ROW_HEIGHT)

    local firstIndex = math.floor(listFrame.CanvasPosition.Y / CONFIG.ROW_HEIGHT) + 1

    for poolIndex, row in ipairs(rowPool) do
        local dataIndex = firstIndex + poolIndex - 1
        local finding = filteredFindings[dataIndex]

        if finding then
            row.Visible = true
            row.Position = UDim2.fromOffset(0, (dataIndex - 1) * CONFIG.ROW_HEIGHT)
            row.Size = UDim2.new(1, -6, 0, CONFIG.ROW_HEIGHT - 1)
            rowIndexMap[row] = dataIndex

            local prefix = string.format("[%s] [%s] ", finding.severity, finding.category)
            local keyValue = finding.key ~= "" and (" :: " .. finding.key) or ""
            local value = finding.value ~= "" and (" = " .. finding.value) or ""
            row.Text = prefix .. finding.path .. keyValue .. value
            row.TextColor3 = SEVERITY_COLOR[finding.severity] or COLORS.text
            row.BackgroundColor3 = (finding == selectedFinding) and COLORS.selected or COLORS.panel
        else
            row.Visible = false
            rowIndexMap[row] = nil
        end
    end
end

local function applyFilters(resetScroll)
    if closed then
        return
    end

    local query = lower(searchBox.Text)
    local severityMode = SEVERITY_MODES[severityModeIndex]
    local output = {}

    for _, finding in ipairs(findings) do
        if findingMatchesTab(finding)
            and finding.rank >= severityMode.minRank
            and findingMatchesSearch(finding, query)
        then
            table.insert(output, finding)
        end
    end

    filteredFindings = output

    if resetScroll then
        listFrame.CanvasPosition = Vector2.new(0, 0)
    end

    listFooter.Text = string.format(
        "%d results  |  %s  |  %s%s",
        #filteredFindings,
        CATEGORY_LABEL[currentTab] or currentTab,
        severityMode.label,
        scanning and "  |  SCANNING" or ""
    )

    updateTabTexts()
    updateSummary()
    renderVirtualRows()
end

for order, category in ipairs(CATEGORY_ORDER) do
    local button = new("TextButton", {
        Name = "tab_" .. string.lower(category),
        Size = UDim2.new(1, 0, 0, 31),
        BackgroundColor3 = COLORS.panel2,
        BorderSizePixel = 0,
        Text = CATEGORY_LABEL[category] or category,
        TextColor3 = COLORS.dim,
        Font = Enum.Font.Code,
        TextSize = 11,
        TextXAlignment = Enum.TextXAlignment.Left,
        AutoButtonColor = false,
        LayoutOrder = order,
        ZIndex = 30,
    }, tabsContainer)
    addCorner(button, 7)
    new("UIPadding", {PaddingLeft = UDim.new(0, 9)}, button)
    tabButtons[category] = button

    trackConnection(button.Activated:Connect(function()
        currentTab = category
        applyFilters(true)
    end))
end

for i = 1, CONFIG.ROW_POOL_SIZE do
    local row = new("TextButton", {
        Name = "row_" .. tostring(i),
        Position = UDim2.fromOffset(0, 0),
        Size = UDim2.new(1, -6, 0, CONFIG.ROW_HEIGHT - 1),
        BackgroundColor3 = COLORS.panel,
        BorderSizePixel = 0,
        Text = "",
        TextColor3 = COLORS.text,
        Font = Enum.Font.Code,
        TextSize = 12,
        TextXAlignment = Enum.TextXAlignment.Left,
        TextTruncate = Enum.TextTruncate.AtEnd,
        AutoButtonColor = false,
        Visible = false,
        ZIndex = 27,
    }, listFrame)
    new("UIPadding", {PaddingLeft = UDim.new(0, 8), PaddingRight = UDim.new(0, 8)}, row)
    table.insert(rowPool, row)

    trackConnection(row.Activated:Connect(function()
        local index = rowIndexMap[row]
        if index then
            selectFinding(filteredFindings[index])
            renderVirtualRows()
        end
    end))
end

trackConnection(listFrame:GetPropertyChangedSignal("CanvasPosition"):Connect(renderVirtualRows))
trackConnection(searchBox:GetPropertyChangedSignal("Text"):Connect(function()
    applyFilters(true)
end))

trackConnection(severityButton.Activated:Connect(function()
    severityModeIndex = severityModeIndex + 1
    if severityModeIndex > #SEVERITY_MODES then
        severityModeIndex = 1
    end
    severityButton.Text = "Severity: " .. SEVERITY_MODES[severityModeIndex].label
    applyFilters(true)
end))

-- ============================================================
-- Scan
-- ============================================================

local function clearResults()
    findings = {}
    filteredFindings = {}
    findingKeys = {}
    seenAssemblies = {}
    seenAssets = {}
    selectedFinding = nil
    resetStats()
    selectFinding(nil)
    liveDirty = false
end

local function addSystemNotes()
    local streaming = safeGet(workspace, "StreamingEnabled")
    local placeInfo = string.format(
        "PlaceId=%s | GameId=%s | JobId=%s | StreamingEnabled=%s",
        tostring(game.PlaceId),
        tostring(game.GameId),
        tostring(game.JobId),
        tostring(streaming)
    )
    addFinding(
        "SYSTEM",
        "INFO",
        "Session",
        nil,
        "runtime",
        placeInfo,
        "Audit is read-only and only covers client-visible runtime state.",
        false
    )

    if streaming == true then
        addFinding(
            "SYSTEM",
            "MEDIUM",
            "Workspace streaming is enabled",
            nil,
            "StreamingEnabled",
            true,
            "Initial Workspace scan only sees content currently streamed to this client. Live watch records new descendants as they stream in; move through the map or rescan from different regions for broader coverage.",
            true
        )
    end

    addFinding(
        "SYSTEM",
        "INFO",
        "Threat model",
        nil,
        "assumption",
        "Compromised client",
        "Treat replicated code/data as readable and client-originated state/events as untrusted. This tool does not require or invoke ModuleScripts because require() could execute side effects.",
        false
    )
end

local function collectQueue(generation)
    local queue = {}
    local seen = {}

    for _, root in ipairs(scanRoots) do
        if closed or generation ~= scanGeneration then
            return nil
        end

        if not seen[root] then
            seen[root] = true
            table.insert(queue, root)
        end

        local ok, descendants = pcall(function()
            return root:GetDescendants()
        end)

        if ok then
            for _, object in ipairs(descendants) do
                if not seen[object] then
                    seen[object] = true
                    table.insert(queue, object)
                    if #queue >= CONFIG.MAX_INSTANCES then
                        stats.truncated = true
                        return queue
                    end
                end
            end
        end

        task.wait()
    end

    return queue
end

local function sortFindings()
    table.sort(findings, function(a, b)
        if a.rank ~= b.rank then
            return a.rank > b.rank
        end
        if a.category ~= b.category then
            return a.category < b.category
        end
        if a.path ~= b.path then
            return a.path < b.path
        end
        return a.title < b.title
    end)
end

local function startScan()
    if closed then
        return
    end

    scanGeneration = scanGeneration + 1
    local generation = scanGeneration
    scanning = true
    rescanButton.Text = "SCANNING…"
    headerStatus.Text = "building scan queue…"

    clearResults()
    addSystemNotes()
    applyFilters(true)

    task.spawn(function()
        local queue = collectQueue(generation)
        if not queue or closed or generation ~= scanGeneration then
            return
        end

        local total = #queue
        headerStatus.Text = string.format("0 / %d", total)

        for index, object in ipairs(queue) do
            if closed or generation ~= scanGeneration then
                return
            end

            inspectInstance(object)

            if index % CONFIG.SCAN_YIELD_EVERY == 0 then
                headerStatus.Text = string.format("%d / %d", index, total)
                updateSummary()
                listFooter.Text = string.format("Scanning %d / %d…", index, total)
                task.wait()
            end
        end

        if closed or generation ~= scanGeneration then
            return
        end

        sortFindings()
        scanning = false
        rescanButton.Text = "RESCAN"
        headerStatus.Text = string.format("complete | %d findings", #findings)
        applyFilters(true)
    end)
end

trackConnection(rescanButton.Activated:Connect(startScan))

-- Live-watch catches newly replicated or newly streamed-in instances after the snapshot.
trackConnection(game.DescendantAdded:Connect(function(object)
    if closed or scanning then
        return
    end
    if isInScope(object) then
        inspectInstance(object)
        liveDirty = true
    end
end))

trackConnection(RunService.Heartbeat:Connect(function()
    if closed or scanning or not liveDirty then
        return
    end
    local now = os.clock()
    if now - lastLiveRefresh >= CONFIG.LIVE_REFRESH_INTERVAL then
        lastLiveRefresh = now
        liveDirty = false
        applyFilters(false)
    end
end))

-- ============================================================
-- Print/export helpers (normal Roblox output only)
-- ============================================================

local function findingToLine(finding)
    local parts = {
        "[" .. finding.severity .. "]",
        "[" .. finding.category .. "]",
        finding.path,
    }
    if finding.key ~= "" then
        table.insert(parts, ":: " .. finding.key)
    end
    if finding.value ~= "" then
        table.insert(parts, "= " .. finding.value)
    end
    return table.concat(parts, " ")
end

trackConnection(printButton.Activated:Connect(function()
    print(string.format("=== CLIENT EXPOSURE DEBUGGER v%s | FILTERED EXPORT ===", VERSION))
    print(string.format("Results: %d | Tab: %s | Severity: %s", #filteredFindings, currentTab, SEVERITY_MODES[severityModeIndex].label))

    local limit = math.min(#filteredFindings, CONFIG.PRINT_LIMIT)
    for i = 1, limit do
        print(findingToLine(filteredFindings[i]))
    end

    if #filteredFindings > limit then
        warn(string.format("Export truncated: printed %d of %d results.", limit, #filteredFindings))
    end
end))

trackConnection(selectedPrintButton.Activated:Connect(function()
    if selectedFinding then
        print("=== CLIENT EXPOSURE DEBUGGER | SELECTED ===")
        print(findingDetails(selectedFinding))
    else
        warn("[Client Exposure Debugger] No finding selected.")
    end
end))

-- Kick off initial scan after GUI is fully parented/renderable.
task.defer(startScan)