Skip to content

Commit

Permalink
feature(inventory): Add windows store inventory
Browse files Browse the repository at this point in the history
Add UWP/APPX/Windows Store software inventory
Add API to convert multibytes string to and from widechar strings
Add API to load indirect strings

Closes fusioninventory#299
  • Loading branch information
g-bougard committed Aug 31, 2018
1 parent 110bd2a commit 5946a0c
Show file tree
Hide file tree
Showing 4 changed files with 407 additions and 0 deletions.
1 change: 1 addition & 0 deletions Changes
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Revision history for FusionInventory agent

inventory:
* Fix physical memory error correction detection via WMI under win32
* Fix #299: Added UWP/APPX/Windows Store software inventory

deploy:
* Bump Deploy task version to 2.7
Expand Down
253 changes: 253 additions & 0 deletions lib/FusionInventory/Agent/Task/Inventory/Win32/Softwares.pm
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ use parent 'FusionInventory::Agent::Task::Inventory::Module';

use English qw(-no_match_vars);
use File::Basename;
use File::Temp;
use UNIVERSAL::require;
use Encode qw(decode);

use FusionInventory::Agent::Tools;
use FusionInventory::Agent::Tools::Win32;
use FusionInventory::Agent::Tools::Win32::Constants;
use FusionInventory::Agent::Tools::Win32::LoadIndirectString;

my $seen = {};

Expand All @@ -31,6 +35,7 @@ sub doInventory {

my $inventory = $params{inventory};
my $logger = $params{logger};
my $remotewmi = $inventory->getRemote();

my $is64bit = is64bit();

Expand Down Expand Up @@ -86,6 +91,14 @@ sub doInventory {
_addSoftware(inventory => $inventory, entry => $hotfix);
}

# Lookup for UWP/Windows Store packages (not supported by WMI task)
unless ($remotewmi) {
my $packages = _getAppxPackages( logger => $logger ) || [];
foreach my $package (@{$packages}) {
_addSoftware(inventory => $inventory, entry => $package);
}
}

# Reset seen hash so we can see softwares in later same run inventory
$seen = {};
}
Expand Down Expand Up @@ -404,4 +417,244 @@ sub _getSqlInstancesVersions {
return $sqlinstanceVersions->{'/Edition'};
}

sub _getAppxPackages {
my (%params) = @_;

return unless canRun('powershell');

XML::TreePP->require();

my $logger = $params{logger};

my @lines;
{
# Temp file will be deleted out of this scope
my $fh = File::Temp->new(
TEMPLATE => 'appx-getpackages-XXXXXX',
SUFFIX => '.ps1'
);
print $fh <DATA>;
close($fh);
my $file = $fh->filename;

return unless ($file && -f $file);

@lines = getAllLines(
command => "powershell -NonInteractive -ExecutionPolicy ByPass -File $file",
%params
);
}

my ($list, $package);
my %manifest_mapping = qw(
DisplayName DISPLAYNAME
Description COMMENTS
PublisherDisplayName PUBLISHERDISPLAYNAME
);

foreach my $line (@lines) {
chomp($line);

# Add package on empty line
if (!$line && $package && $package->{NAME}) {
push @{$list}, $package;
undef $package;
next;
}

my ($key, $value) = $line =~ /^([A-Z_]+):\s*(.*)\s*$/;
next unless ($key && defined($value));
$package->{$key} = decode('UTF-8', $value);

# Read manifest
if ($key eq 'FOLDER' && $value && -d $value) {
my $xml = $value . '/appxmanifest.xml';
if (-f $xml) {
my $tpp = XML::TreePP->new()
or next;
my $tree = $tpp->parsefile($xml);
foreach my $property (keys(%manifest_mapping)) {
my $key = $manifest_mapping{$property};
my $value = $tree->{Package}->{Properties}->{$property}
or next;
$package->{$key} = decode('UTF-8', $value);
}
}
}
}

# Add last package if still not added
push @{$list}, $package if ($package && $package->{NAME});

# Extract publishers
my $publishers = _parsePackagePublishers($list);

# Cleanup list and fix localized strings
foreach my $package (@{$list}) {
my $name = $package->{NAME};
my $pubid = delete $package->{PUBLISHERID};
$package->{PUBLISHER} = $publishers->{$pubid}
if ($pubid && $publishers->{$pubid});

if (!$package->{PUBLISHER} && $name =~ /^Microsoft/i) {
$package->{PUBLISHER} = "Microsoft Corp.";
} elsif (!$package->{PUBLISHER}) {
$logger->debug2("no publisher found for $name package") if $logger;
}

my $pkgname = delete $package->{PACKAGE};

my $installdate = delete $package->{INSTALLDATE};
if ($installdate) {
my ($date) = $installdate =~ m|^([0-9/]+)|;
$installdate = _dateFormat($date);
$package->{INSTALLDATE} = $installdate if $installdate;
}

my $dn = delete $package->{DISPLAYNAME};
if ($dn && $dn =~ /^ms-resource:/) {
my $res = SHLoadIndirectString(_canonicalResourceURI(
$pkgname, $package->{FOLDER}, $dn
));
$logger->debug2("$name package name" . ($res ?
"resolved to '$res'" : "can't be resolved from '$dn'"))
if $logger;
$dn = $res;
}
if (!$dn) {
$dn = _canonicalPackageName($package->{NAME});
}
$package->{NAME} = $dn if $dn;

my $comments = delete $package->{COMMENTS};
if ($comments && $comments =~ /^ms-resource:/) {
my $res = SHLoadIndirectString(_canonicalResourceURI(
$pkgname, $package->{FOLDER}, $comments
));
$logger->debug2("$name package comments" . ($res ?
"resolved to '$res'" : "can't be resolved from '$comments'"))
if $logger;
$comments = $res;
}
$package->{COMMENTS} = $comments if $comments;

$package->{FROM} = 'uwp';
}

return $list;
}

sub _canonicalPackageName {
my ($name) = @_;
# Fix up name for well-know cases if the case display name is missing
if ($name =~ /^Microsoft\.NET\./i) {
$name =~ s/\./ /g;
$name =~ s/Microsoft NET/Microsoft .Net/;
} elsif ($name =~ /^(Microsoft|windows)\./i) {
$name =~ s/\./ /g;
}
return $name;
}

sub _canonicalResourceURI {
my ($package, $folder, $resource) = @_;
my $file = $folder.'\resources.pri';
my $base = -f $file ? $file : $package;
my ($prefix, $respath) = $resource =~ /^(ms-resource:)(.*)$/
or return;
if ($respath =~ m|^//|) {
# Keep resource as is
} elsif ($respath =~ m|^/|) {
$resource = $prefix.'//'.$respath;
} else {
$resource = $prefix.'///'.($respath =~ /resources/i ? '':'resources/').$respath;
}
return '@{'.$base.'?'.$resource.'}';
}

sub _parsePackagePublishers {
my $list = shift(@_);

my %publishers = qw(
tf1gferkr813w AutoDesk
);

my @localized_publisher_packages = ();

foreach my $package (@{$list}) {
my $publisher = delete $package->{PUBLISHERDISPLAYNAME};
my $pubid = $package->{PUBLISHERID}
or next;
next unless $publisher;
next if ($publishers{$pubid} && $publishers{$pubid} !~ /^ms-resource:/);
if ($publisher =~ /^ms-resource:/) {
push @localized_publisher_packages, $package;
}
$publishers{$pubid} = $publisher;
}

# Fix publishers with ms-resource:
foreach my $package (@localized_publisher_packages) {
my $pubid = $package->{PUBLISHERID}
or next;
next if ($publishers{$pubid} && $publishers{$pubid} !~ /^ms-resource:/);
my $string = SHLoadIndirectString(_canonicalResourceURI(
$package->{PACKAGE}, $package->{FOLDER}, $publishers{$pubid}
));
if ($string) {
$publishers{$pubid} = $string;
} else {
delete $publishers{$pubid};
}
}

return \%publishers;
}

1;

__DATA__
# Script PowerShell
[Windows.Management.Deployment.PackageManager,Windows.Management.Deployment,ContentType=WindowsRuntime] >$null
$packages = New-Object Windows.Management.Deployment.PackageManager
foreach ( $package in $packages.FindPackages() )
{
# Check install state for each user and break if an installation is found
$state = "Installed"
foreach ( $user in $packages.FindUsers($package.Id.FullName) )
{
$state = $user.InstallState
if ($user.InstallState -Like "Installed") {
break
$p = $packages.FindPackageForUser($user.UserSecurityId, $package.Id.FullName)
if ($p.InstalledLocation.DateCreated -NotLike "") {
$installedDate = $p.InstalledLocation.DateCreated
break
}
}
}
if ($state -NotLike "Installed") { continue }
# Use installeddate if found otherwise use installation folder creation date
$installedDate = ""
if ($package.InstalledDate -NotLike "") {
$installedDate = $package.InstalledDate
} elseif ($package.InstalledLocation.DateCreated -NotLike "") {
$installedDate = $package.InstalledLocation.DateCreated
}
Write-host "NAME: $($package.Id.Name)"
Write-host "PACKAGE: $($package.Id.FullName)"
Write-host "ARCH: $($package.Id.Architecture.ToString().ToLowerInvariant())"
Write-host "VERSION: $($package.Id.Version.Major).$($package.Id.Version.Minor).$($package.Id.Version.Build).$($package.Id.Version.Revision)"
Write-host "FOLDER: $($package.InstalledLocation.Path)"
if ($installedDate -NotLike "") {
Write-host "INSTALLDATE: $($installedDate)"
}
Write-host "PUBLISHER: $($package.Id.Publisher)"
Write-host "PUBLISHERID: $($package.Id.PublisherId)"
Write-host "SYSTEM_CATEGORY: $($package.SignatureKind.ToString().ToLowerInvariant())"
Write-Host
}
59 changes: 59 additions & 0 deletions lib/FusionInventory/Agent/Tools/Win32/LoadIndirectString.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package FusionInventory::Agent::Tools::Win32::LoadIndirectString;

use warnings;
use strict;

use parent 'Exporter';

use FusionInventory::Agent::Tools::Win32::WideChar;

our @EXPORT = qw(
SHLoadIndirectString
);

my $apiSHLoadIndirectString;

sub SHLoadIndirectString {
my ($string) = @_;

return unless $string;

my $wstring = MultiByteToWideChar($string)
or return;

# Load Win32::API as late as possible
Win32::API->require() or return;

unless ($apiSHLoadIndirectString) {
eval {
$apiSHLoadIndirectString = Win32::API->new(
'shlwapi',
'SHLoadIndirectString',
[ 'P', 'P', 'I', 'I' ],
'N'
);
};
}

return unless $apiSHLoadIndirectString;

# Buffer size should be sufficient for our purpose
my $buffer = '\0' x 4096;
my $ret = $apiSHLoadIndirectString->Call(
$wstring,
$buffer,
4096,
0
);

return if ($ret || !$buffer);

$buffer = WideCharToMultiByte($buffer);

# api returns the same string in buffer is no indirect string was found
return unless ($buffer && $buffer ne $string);

return $buffer ;
}

1;
Loading

0 comments on commit 5946a0c

Please sign in to comment.