diff --git a/Data/Agent/Roles/Device_Audit.yml b/Data/Agent/Roles/Device_Audit.yml deleted file mode 100644 index 777c152..0000000 --- a/Data/Agent/Roles/Device_Audit.yml +++ /dev/null @@ -1,204 +0,0 @@ - - hosts: all - gather_facts: false - tasks: - - name: Collect summary via PowerShell (hostname, OS, uptime, last reboot) - ansible.builtin.shell: | - $ErrorActionPreference = 'Stop' - $hostname = $env:COMPUTERNAME - $username = $env:USERNAME - $domain = $env:USERDOMAIN - $w = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' - $product = [string]$w.ProductName - $display = [string]$w.DisplayVersion - $build = [string]$w.CurrentBuildNumber - $os = ($product + ' ' + $display).Trim() - $boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime - $uptime = [int]((Get-Date) - $boot).TotalSeconds - # Produce Last Reboot as UTC string to match UI expectations (YYYY-MM-DD HH:MM:SS) - $bootUtc = (Get-Date $boot).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') - $out = [pscustomobject]@{ - hostname=$hostname; - operating_system=$os; - username=$username; - domain=$domain; - uptime_sec=$uptime; - last_reboot=$bootUtc - } - $out | ConvertTo-Json -Depth 4 - register: summary_raw - changed_when: false - - - name: Parse summary JSON - ansible.builtin.set_fact: - _summary: "{{ summary_raw.stdout | from_json | default({}, true) }}" - - - name: Collect installed software - ansible.builtin.shell: | - $ErrorActionPreference = 'SilentlyContinue' - $paths = @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*') - $items = foreach ($p in $paths) { - if (Test-Path $p) { - Get-ItemProperty -Path $p | Where-Object { $_.DisplayName } | ForEach-Object { - [pscustomobject]@{ name=[string]$_.DisplayName; version=[string]$_.DisplayVersion } - } - } - } - $items | Sort-Object name -Unique | ConvertTo-Json -Depth 4 - register: software_raw - changed_when: false - - - name: Parse software JSON - ansible.builtin.set_fact: - _software: "{{ software_raw.stdout | default('[]') | from_json | default([], true) }}" - - - name: Collect memory modules - ansible.builtin.shell: | - $ErrorActionPreference = 'SilentlyContinue' - Get-CimInstance Win32_PhysicalMemory | ForEach-Object { - [pscustomobject]@{ slot=$_.BankLabel; speed=[string]$_.Speed; serial=[string]$_.SerialNumber; capacity=[string]$_.Capacity } - } | ConvertTo-Json -Depth 4 - register: memory_raw - changed_when: false - - - name: Parse memory JSON - ansible.builtin.set_fact: - _memory: "{{ memory_raw.stdout | default('[]') | from_json | default([], true) }}" - - - name: Collect storage volumes - ansible.builtin.shell: | - $ErrorActionPreference = 'SilentlyContinue' - Get-CimInstance Win32_LogicalDisk | ForEach-Object { - $total = [double]$_.Size; $free = [double]$_.FreeSpace; $used = $total - $free; - $usage = if ($total -gt 0) { [math]::Round(($used / $total) * 100, 2) } else { 0 } - $type = switch ($_.DriveType) { 2 {'Removable'} 3 {'Fixed Disk'} default {'Unknown'} } - [pscustomobject]@{ drive=$_.DeviceID; disk_type=$type; usage=$usage; total=$total; free=$free; used=$used } - } | ConvertTo-Json -Depth 4 - register: storage_raw - changed_when: false - - - name: Parse storage JSON - ansible.builtin.set_fact: - _storage: "{{ storage_raw.stdout | default('[]') | from_json | default([], true) }}" - - - name: Collect network adapters - ansible.builtin.shell: | - $ErrorActionPreference = 'SilentlyContinue' - try { - $ip = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -and $_.IPAddress -notmatch '^169\.254\.' -and $_.IPAddress -ne '127.0.0.1' } - $ad = Get-NetAdapter | ForEach-Object { $_ | Select-Object -Property Name, InterfaceAlias, MacAddress } - $map = @{}; foreach($a in $ad){ $map[$a.InterfaceAlias] = $a.MacAddress } - $tmp = @{} - foreach($e in $ip){ - $alias = if ($e.InterfaceAlias) { $e.InterfaceAlias } else { 'unknown' } - $item = $tmp[$alias] - if (-not $item) { $item = [pscustomobject]@{ adapter=$alias; ips=@(); mac='' }; $tmp[$alias] = $item } - $item.mac = $map[$alias] - if ($e.IPAddress -and $item.ips -notcontains $e.IPAddress) { $item.ips += $e.IPAddress } - } - $out = $tmp.GetEnumerator() | ForEach-Object { $_.Value } - } catch { $out = @() } - $out | ConvertTo-Json -Depth 4 - register: network_raw - changed_when: false - - - name: Parse network JSON - ansible.builtin.set_fact: - _network: "{{ network_raw.stdout | default('[]') | from_json | default([], true) }}" - - - name: Compose device details structure - ansible.builtin.set_fact: - device_details: - summary: "{{ _summary }}" - software: "{{ _software }}" - memory: "{{ _memory }}" - storage: "{{ _storage }}" - network: "{{ _network }}" - - - name: Derive internal IP from adapter list - ansible.builtin.set_fact: - device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'internal_ip': (_network | map(attribute='ips') | list | flatten | select('match','^(10\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|192\\.168\\.)') | list | first | default('')) })) }) }}" - - - name: Detect device type (Server/Workstation/Virtual Machine) - ansible.builtin.shell: | - $ErrorActionPreference = 'SilentlyContinue' - function _getCim($cls){ try { return Get-CimInstance $cls -ErrorAction Stop } catch { try { return Get-WmiObject -Class $cls -ErrorAction Stop } catch { return $null } } } - $os = _getCim 'Win32_OperatingSystem' - $cs = _getCim 'Win32_ComputerSystem' - $caption = ""; if ($os) { $caption = [string]$os.Caption } - $model = ""; if ($cs) { $model = [string]$cs.Model } - $manu = ""; if ($cs) { $manu = [string]$cs.Manufacturer } - $virt = $false - if ($model -match 'Virtual' -or $manu -match 'Microsoft Corporation' -and $model -match 'Virtual Machine' -or $manu -match 'VMware' -or $manu -match 'innotek' -or $manu -match 'VirtualBox' -or $manu -match 'QEMU' -or $manu -match 'Xen' -or $manu -match 'Parallels') { $virt = $true } - if ($virt) { 'Virtual Machine' } - elseif ($caption -match 'Server') { 'Server' } - else { 'Workstation' } - register: device_type_raw - changed_when: false - - - name: Attach device type to summary - ansible.builtin.set_fact: - device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'device_type': (device_type_raw.stdout | default('') | trim) })) }) }}" - - - name: Collect external IP (best-effort) - ansible.builtin.shell: | - $ErrorActionPreference = 'SilentlyContinue' - $ip = '' - try { $ip = (Invoke-RestMethod -Uri 'https://api.ipify.org?format=json' -TimeoutSec 3).ip } catch {} - if (-not $ip) { try { $ip = (Invoke-WebRequest -Uri 'https://checkip.amazonaws.com' -TimeoutSec 3).Content.Trim() } catch {} } - if (-not $ip) { try { $ip = (Invoke-WebRequest -Uri 'https://ifconfig.me/ip' -TimeoutSec 3).Content.Trim() } catch {} } - $ip - register: external_ip_raw - changed_when: false - - - name: Attach external IP to summary - ansible.builtin.set_fact: - device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'external_ip': (external_ip_raw.stdout | default('') | trim) })) }) }}" - - - name: Collect last logged-on user from registry (SAM/UPN) - ansible.builtin.shell: | - $ErrorActionPreference = 'SilentlyContinue' - function Normalize-Sam([string]$s) { - if ([string]::IsNullOrWhiteSpace($s)) { return '' } - if ($s -match '\$$') { return '' } # exclude machine accounts - if ($s -like 'DWM-*' -or $s -like 'UMFD-*') { return '' } - if ($s -eq 'SYSTEM' -or $s -eq 'LOCAL SERVICE' -or $s -eq 'NETWORK SERVICE' -or $s -eq 'ANONYMOUS LOGON') { return '' } - return $s - } - - $regPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' - $sam = '' - $upn = '' - try { $sam = (Get-ItemProperty -Path $regPath -Name 'LastLoggedOnSAMUser' -ErrorAction Stop).LastLoggedOnSAMUser } catch {} - try { $upn = (Get-ItemProperty -Path $regPath -Name 'LastLoggedOnUser' -ErrorAction Stop).LastLoggedOnUser } catch {} - - $user = Normalize-Sam $sam - if (-not $user) { - $user = Normalize-Sam $upn - if ($user -and $user -like '*@*') { - # Convert UPN to DOMAIN\user using machine domain (best effort) - $domDns = (Get-WmiObject Win32_ComputerSystem).Domain - $domShort = '' - if ($domDns) { $domShort = ($domDns -split '\.')[0].ToUpper() } - $parts = $user -split '@' - if ($parts.Length -ge 1) { - $u = $parts[0] - if ($domShort) { $user = "$domShort\$u" } - } - } - } - - if ($user) { $user } else { 'No Users Logged In' } - register: last_user_raw - changed_when: false - - - name: Attach last_user string to summary - ansible.builtin.set_fact: - device_details: "{{ device_details | combine({'summary': (device_details.summary | combine({'last_user': (last_user_raw.stdout | default('') | trim) })) }) }}" - - - name: Write device details JSON - ansible.builtin.copy: - content: "{{ device_details | to_nice_json }}" - dest: "{{ output_file }}" - run_once: true diff --git a/Data/Agent/Roles/role_DeviceAudit.py b/Data/Agent/Roles/role_DeviceAudit.py index 230fe07..9a1acfa 100644 --- a/Data/Agent/Roles/role_DeviceAudit.py +++ b/Data/Agent/Roles/role_DeviceAudit.py @@ -134,69 +134,7 @@ def _project_root(): return os.getcwd() -def _run_ansible_audit(ctx) -> dict: - try: - exe_dir = os.path.dirname(sys.executable) - candidate = os.path.join(exe_dir, 'ansible-playbook.exe' if IS_WINDOWS else 'ansible-playbook') - ansible_playbook = candidate if os.path.isfile(candidate) else 'ansible-playbook' - - base = os.path.join(_project_root(), 'Logs', 'Agent', 'ansible') - os.makedirs(base, exist_ok=True) - out_path = os.path.join(base, 'audit.json') - - # Require an external playbook; look next to this role first, then source tree as fallback - roles_dir = os.path.dirname(__file__) - pb_candidates = [ - os.path.join(roles_dir, 'Device_Audit.yml'), - os.path.join(_project_root(), 'Data', 'Agent', 'Roles', 'Device_Audit.yml'), - ] - pb_path = next((p for p in pb_candidates if os.path.isfile(p)), None) - if not pb_path: - # Log helpful error and return empty - try: - with open(os.path.join(base, 'ansible.err.log'), 'w', encoding='utf-8', newline='\n') as ef: - ef.write('Device_Audit.yml not found in roles directory.\n') - ef.write('Searched:\n - ' + '\n - '.join(pb_candidates)) - except Exception: - pass - return {} - - out_posix = Path(out_path).as_posix() - py_interp = Path(sys.executable).as_posix() - - env = os.environ.copy() - env.setdefault('PYTHONIOENCODING', 'utf-8') - env.setdefault('ANSIBLE_FORCE_COLOR', '0') - - cmd = [ - ansible_playbook, - '-i', 'localhost,', - '-c', 'local', - pb_path, - '-e', f'ansible_python_interpreter={py_interp}', - '-e', 'ansible_shell_type=powershell' if IS_WINDOWS else 'ansible_shell_type=sh', - '-e', 'ansible_shell_executable=powershell.exe' if IS_WINDOWS else 'ansible_shell_executable=/bin/sh', - '-e', f'output_file={out_posix}', - ] - - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env) - if proc.returncode != 0: - try: - with open(os.path.join(base, 'ansible.err.log'), 'w', encoding='utf-8', newline='\n') as ef: - ef.write(proc.stdout or '') - ef.write('\n--- STDERR ---\n') - ef.write(proc.stderr or '') - except Exception: - pass - return {} - try: - with open(out_path, 'r', encoding='utf-8') as jf: - details = json.load(jf) - return details if isinstance(details, dict) else {} - except Exception: - return {} - except Exception: - return {} +# Removed Ansible-based audit path; Python collectors provide details directly. def _ps_json(cmd: str, timeout: int = 60): @@ -722,8 +660,7 @@ class Role: self.ctx = ctx self._ext_ip = None self._ext_ip_ts = 0 - self._ansible_cache = None - self._ansible_ts = 0 + self._refresh_ts = 0 self._last_details = None try: # Set OS string once @@ -756,12 +693,10 @@ class Role: refresh_sec = max(300, refresh_min * 60) now = time.time() - need_refresh = (not self._last_details) or ((now - self._ansible_ts) > refresh_sec) + need_refresh = (not self._last_details) or ((now - self._refresh_ts) > refresh_sec) if need_refresh: - details = _run_ansible_audit(self.ctx) - if not details: - # Fallback collector when Ansible is unavailable - details = _build_details_fallback() + # Always collect via built-in Python collectors + details = _build_details_fallback() # Best-effort fill of missing/renamed fields so UI is happy try: details = self._normalize_details(details) @@ -769,7 +704,7 @@ class Role: pass if details: self._last_details = details - self._ansible_ts = now + self._refresh_ts = now # Always post the latest available details (possibly cached) details_to_send = self._last_details or {'summary': collect_summary(self.ctx.config)}