diff --git a/content/exchange/artifacts/Generic.Client.ADStatus.yaml b/content/exchange/artifacts/Generic.Client.ADStatus.yaml new file mode 100644 index 00000000000..0d35b0ce91e --- /dev/null +++ b/content/exchange/artifacts/Generic.Client.ADStatus.yaml @@ -0,0 +1,114 @@ +name: Generic.Client.ADStatus +author: Andreas Misje – @misje +description: | + Get Active Directory join status from a computer. The information returned + depends on the operating system, but DomainJoined (boolean) and Domain is + returned for all. DomainJoined is always set. Domain is always uppercase. + + On Linux, realm/sssd is used to query AD status/configuration. The following + columns are returned: + + - _RealmType (e.g. "kerberos") + - _RealmName (e.g. "AD.EXAMPLE.ORG") + - Domain (e.g. "AD.EXAMPLE.ORG) + - AllowedUsers (e.g. ["bob"]) + - AllowedGroups (e.g. ["it"]) + - DomainJoined (boolean) + + On Windows, `dsregcmd` is used to return AD status/configuration. Only a small + part of its output is returned: + + - AzureAdJoined (boolean) + - EnterpriseJoined (boolean) + - DomainJoined (boolean) + - NetBIOS (e.g. "EXAMPLE") + - Domain (e.g. "AD.EXAMPLE.ORG) + - DeviceName (e.g. "DESKTOP-FOO") + + On macOS, `dsconfigad` is used to return AD status/configuration: + + - Domain (e.g. "AD.EXAMPLE.ORG") + - DeviceName (e.g. "DESKTOP-FOO") + - AdminGroups (e.g. ["it"]) + - DomainJoined (boolean) + +type: CLIENT + +sources: + - query: | + LET Info <= SELECT OS + FROM info() + + LET SplitString(String) = filter(regex='.+', + list=split(string=String, sep=', ?')) + + LET LinuxStatus = SELECT + parse_string_with_regex( + string=Stdout, + regex=(''' +type: +(?P<_RealmType>.*)''', ''' +realm-name: +(?P<_RealmName>.*)''', ''' +domain-name: +(?P.*)''', ''' +permitted-logins: +(?P.*)''', ''' +permitted-groups: +(?P.*)''', )) AS ADStatus + FROM execve( + argv=('realm', 'list')) + + LET WindowsStatus = SELECT + parse_string_with_regex( + string=Stdout, + regex=('''(?s)Device State.+AzureAdJoined *: *(?P\S+)''', '''(?s)Device State.+EnterpriseJoined *: *(?P\S+)''', '''(?s)Device State.+DomainJoined *: *(?P\S+)''', '''(?s)Device State.+DomainName *: *(?P\S+)''', '''(?s)Device State.+Device Name *: *(?P\S+)''', )) AS ADStatus + FROM execve( + argv=('dsregcmd', '/status')) + + LET DarwinStatus = SELECT + parse_string_with_regex( + string=Stdout, + regex=('''Active Directory Domain += +(?P\S+)''', '''Computer Account += +(?P\S+)\$''', ''' +Allowed admin groups += +(?P.+)''', )) AS ADStatus + FROM execve( + argv=('dsconfigad', '-show')) + + LET ADDict = SELECT + ADStatus + dict(Domain=upcase(string=ADStatus.Domain)) AS ADStatus + FROM switch( + linux={ + SELECT * + FROM if( + condition=Info[0].OS = 'linux', + then={ + SELECT ADStatus + dict( + DomainJoined=ADStatus != dict(), + AllowedUsers=SplitString(String=ADStatus.AllowedUsers), + AllowedGroups=SplitString(String=ADStatus.AllowedGroups)) AS ADStatus + FROM LinuxStatus + }) + }, + windows={ + SELECT + * + FROM if( + condition=Info[0].OS = 'windows', + then={ + SELECT + ADStatus + dict( + AzureAdJoined=ADStatus.AzureAdJoined = 'YES', + EnterpriseJoined=ADStatus.EnterpriseJoined = 'YES', + DomainJoined=ADStatus.DomainJoined = 'YES') + + parse_string_with_regex( + string=ADStatus.DeviceName, + regex='''^(?P[^.]+)\.(?P\S+)$''') AS ADStatus + FROM WindowsStatus + }) + }, + darwin={ + SELECT + * + FROM if( + condition=Info[0].OS = 'darwin', + then={ + SELECT + ADStatus + dict( + DomainJoined=ADStatus != dict(), + AdminGroups=SplitString( + String=ADStatus.AdminGroups)) AS ADStatus + FROM DarwinStatus + }) + }) + + SELECT * + FROM foreach(row=ADDict, column='ADStatus') diff --git a/content/exchange/artifacts/Generic.Client.Defender.Health.yaml b/content/exchange/artifacts/Generic.Client.Defender.Health.yaml new file mode 100644 index 00000000000..c27cb7f75d9 --- /dev/null +++ b/content/exchange/artifacts/Generic.Client.Defender.Health.yaml @@ -0,0 +1,150 @@ +name: Generic.Client.Defender.Health +author: Andreas Misje – @misje +description: | + Get MDATP health + + Microsoft Defender for Endpoint Advanced Threat Protection is an EDR solution + for Windows, Darwin and Linux. This artifact retrieves all available information + about the agents's status and configuration. The artifact will fail if MDATP is + not installed on the endpoint. + + The output from the MDATP status commands vary significantly between Windows + and Linux/Darwin. The output on Linux and Darwin are almost identical, with a + few exceptions: + + Linux has these additional fields: + + - behaviorMonitoring + - supplementaryEventsSubsystem + + Darwin has these additional fields: + + - deviceControlEnforcementLevel + - ecsConfigurationIds + - fullDiskAccessEnabled + - networkEventsSubsystem + - tamperProtection + - troubleshootingMode + + The notebook suggestion "Common MDATP health information" provides a nice + summary of information of fields present in at least Windows and one of Linux + and Darwin. + +type: CLIENT + +implied_permissions: + - EXECVE + +sources: + - query: | + LET Info <= SELECT OS + FROM info() + + SELECT * + FROM if( + condition=Info[0].OS = 'linux', + then={ + SELECT parse_json(data=Stdout) AS MDATPHealth + FROM execve(argv=['/usr/bin/mdatp', 'health', '--output', 'json']) + }, + else=if( + condition=Info[0].OS = 'darwin', + then={ + SELECT parse_json(data=Stdout) AS MDATPHealth + FROM execve(argv=['/usr/local/bin/mdatp', 'health', '--output', 'json']) + }, + else=if( + condition=Info[0].OS = 'windows', + then={ + SELECT + to_dict(item={ + SELECT _key, + _value + FROM parse_records_with_regex( + file=Stdout, + accessor='data', + regex='''^\s*(?P<_key>\S+)\s+:\s+(?P<_value>[^\r\n]+)''') + }) + dict( + edrMachineId=read_file( + accessor='reg', + filename='''HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows Advanced Threat Protection\senseId''')) AS MDATPHealth + FROM Artifact.Windows.System.PowerShell( + Command='Get-MpComputerStatus') + }))) + + notebook: + - name: Common MDATP health information + type: vql_suggestion + template: | + /* + # Common MDATP health information + */ + LET ColumnTypes <= dict(`ClientId`='client') + + LET S = scope() + + LET Result <= SELECT + ClientId, + S.Fqdn || client_info(client_id=ClientId).os_info.fqdn AS Fqdn, + S.MDATPHealth || dict() AS D + FROM source() + + SELECT * + FROM foreach( + row=Result, + query={ + SELECT * + FROM if( + condition='AMEngineVersion' IN D, + then={ + SELECT + ClientId, + Fqdn, + D.edrMachineId AS edrMachineId, + D.AMProductVersion AS appVersion, + D.AMEngineVersion AS engineVersion, + D.AntivirusSignatureVersion AS definitionsVersion, + timestamp(string=D.AntivirusSignatureLastUpdated) AS definitionsUpdated, + if(condition=D.DefenderSignaturesOutOfDate = NULL, + then=NULL, + else=D.DefenderSignaturesOutOfDate != 'False') AS definitionsUpToDate, + if(condition=D.BehaviorMonitorEnabled = NULL, + then=NULL, + else=D.BehaviorMonitorEnabled = 'True') AS behaviorMonitoringEnabled, + if(condition=D.RealTimeProtectionEnabled = NULL, + then=NULL, + else=D.RealTimeProtectionEnabled = 'True') AS realTimeProtectionEnabled, + if( + condition=D.IsTamperProtected = NULL, + then=NULL, + else=D.IsTamperProtected = 'True') AS tamperProtectionEnabled, + if( + condition=D.TroubleShootingMode = NULL, + then=NULL, + else=D.TroubleShootingMode != 'Disabled') AS troubleshootingModeEnabled + FROM scope() + }, + else={ + SELECT + ClientId, + Fqdn, + D.edrMachineId AS edrMachineId, + D.appVersion AS appVersion, + D.engineVersion AS engineVersion, + D.definitionsVersion AS definitionsVersion, + timestamp( + epoch=D.definitionsUpdated) AS definitionsUpdated, + if( + condition=D.definitionsStatus.`$type` = NULL, + then=NULL, + else=D.definitionsStatus.`$type` = 'upToDate') AS definitionsUpToDate, + // Not available in Darwin: + D.behaviorMonitoring.displayValue AS behaviorMonitoringEnabled, + D.realTimeProtectionEnabled.value AS realTimeProtectionEnabled, + // Not available in Linux: + D.tamperProtection.displayValue AS tamperProtectionEnabled, + // Not available in Linux: + D.troubleshootingMode AS troubleshootingModeEnabled + FROM scope() + }) + }) \ No newline at end of file diff --git a/content/exchange/artifacts/Generic.Client.HW.Identification.yaml b/content/exchange/artifacts/Generic.Client.HW.Identification.yaml new file mode 100644 index 00000000000..213dd13bcc0 --- /dev/null +++ b/content/exchange/artifacts/Generic.Client.HW.Identification.yaml @@ -0,0 +1,183 @@ +name: Generic.Client.HW.Identification +author: Andreas Misje – @misje +description: | + Extract product serial and other identification strings from the system. + + One column is returned, SystemInfo, a dict with all available information. + On MacOS, only manufacturer, model and serial is available. Example values: + + Lenovo T14 Gen 2i: + + | Key | Example value | + | --- | ------------- | + | product_family | Thinkpad T14 Gen 2i | + | product_name | 20W00126MX | + | product_sku | LENOVO_MT_20W0_BU_Think_FM_ThinkPad T14 Gen 2i | + | product_serial | xxxxxxxx | + | product_uuid | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | + | product_manufacturer | LENOVO | + | product_version | Thinkpad T14 Gen 2i | + | board_name | 20W00126MX | + | board_serial | xxxxxxxxxxx | + | board_manufacturer | LENOVO | + | board_version | SDK0T76538 WIN | + | chassis_asset_tag | No Asset Information | + | chassis_serial | xxxxxxxx | + | chassis_manufacturer | LENOVO | + | chassis_version | None | + + Dell Latitude 7420: + + | Key | Example value | + | --- | ------------- | + | product_family | Latitude | + | product_name | Latitude 7420 | + | product_sku | 0A36 | + | product_serial | xxxxxxx | + | product_uuid | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx | + | product_manufacturer | | + | product_version | | + | board_name | 07MHG4 | + | board_serial | xxxxxxxx/xxxxxxxxxxxxxxx | + | board_manufacturer | Dell Inc. | + | board_version | A02 | + | chassis_asset_tag | 57182 | + | chassis_serial | xxxxxxx | + | chassis_manufacturer | Dell Inc. | + | chassis_version | | + + Raspberry Pi: + + | Key | Example value | + | --- | ------------- | + | firmware_version | Raspberry Pi 4 Model B Rev 1.2 | + | firmware_serial | xxxxxxxxxxxxxxxx | + + MacBook Pro: + + | Key | Example value | + | --- | ------------- | + | product_name | Mac15,6 | + | product_serial | xxxxxxxxxx | + | product_manufacturer | Apple Inc. | + +type: CLIENT + +implied_permissions: + - EXECVE + +sources: + - query: | + LET Info <= SELECT OS + FROM info() + + LET IsWin <= Info[0].OS = 'windows' + + LET IsLin <= Info[0].OS = 'linux' + + LET IsMac <= Info[0].OS = 'darwin' + + LET WinInfo = SELECT { + SELECT dict(product_family=SystemFamily, + product_name=Model, + product_sku=SystemSKUNumber) AS Info + FROM wmi(query='select * from Win32_ComputerSystem') + } AS ComputerSystem, + { + SELECT dict(product_serial=IdentifyingNumber, + product_uuid=UUID, + product_manufacturer=Vendor, + product_version=Version) AS Info + FROM wmi(query='select * from Win32_ComputerSystemProduct') + } AS ComputerSystemProduct, + { + SELECT dict(board_name=Product, + board_serial=SerialNumber, + board_manufacturer=Manufacturer, + board_version=Version) AS Info + FROM wmi(query='select * from Win32_BaseBoard') + } AS BaseBoard, + { + SELECT dict(chassis_asset_tag=SMBIOSAssetTag, + chassis_serial=SerialNumber, + chassis_manufacturer=Manufacturer, + chassis_version=Version) AS Info + FROM wmi(query='SELECT * FROM Win32_SystemEnclosure') + } AS SystemEnclosure + FROM scope() + + LET SysInfoDict = SELECT Info AS SystemInfo + FROM if( + condition=IsLin, + then={ + SELECT + dict( + product_family=read_file(filename='/sys/class/dmi/id/product_family')[:-1], + product_name=read_file( + filename='/sys/class/dmi/id/product_name')[:-1], + product_serial=read_file( + filename='/sys/class/dmi/id/product_serial')[:-1], + product_sku=read_file( + filename='/sys/class/dmi/id/product_sku')[:-1], + product_uuid=read_file( + filename='/sys/class/dmi/id/product_uuid')[:-1], + product_version=read_file( + filename='/sys/class/dmi/id/product_version')[:-1], + board_name=read_file( + filename='/sys/class/dmi/id/board_name')[:-1], + board_serial=read_file( + filename='/sys/class/dmi/id/board_serial')[:-1], + board_manufacturer=read_file( + filename='/sys/class/dmi/id/board_vendor')[:-1], + board_version=read_file( + filename='/sys/class/dmi/id/board_version')[:-1], + chassis_asset_tag=read_file( + filename='/sys/class/dmi/id/chassis_asset_tag')[:-1], + chassis_serial=read_file( + filename='/sys/class/dmi/id/chassis_serial')[:-1], + chassis_manufacturer=read_file( + filename='/sys/class/dmi/id/chassis_vendor')[:-1], + chassis_version=read_file( + filename='/sys/class/dmi/id/chassis_version')[:-1], + // Useful on devices like Raspberry Pi, where no other information is available: + firmware_version=read_file( + filename='/sys/firmware/devicetree/base/model')[:-1], + // Useful on devices like Raspberry Pi, where no other information is available: + firmware_serial=read_file( + filename='/sys/firmware/devicetree/base/serial-number')[:-1]) AS Info + FROM scope() + }, + else=if( + condition=IsMac, + then={ + SELECT + parse_string_with_regex( + string=Stdout, + regex=('"manufacturer" = <"(?P[^"]+)">', '"model" = <"(?P[^"]+)">', '"IOPlatformSerialNumber" = "(?P[^"]+)"')) AS Info + FROM execve( + argv=['ioreg', '-c', 'IOPlatformExpertDevice', '-d', 2]) + }, + else=if( + condition=IsWin, + then={ + SELECT + ComputerSystem + ComputerSystemProduct + BaseBoard + + SystemEnclosure AS Info + FROM WinInfo + }))) + + // This is a very simple attempt to detect whether the computer is virtual. + // Detecting this is really hard, sometimes impossible, but this may still + // be useful: + SELECT + *, product_name =~ '''(?i)\b(virtualbox|vmware|kvm|qemu|hyper[- ]?v|xen|parallels|bhyve|openstack|cloudstack|bochs|amazon ec2|azure|google compute engine|nutanix|proxmox|virtio|vultr|digitalocean|oracle cloud|microsoft virtual machine|virtual machine|standard pc)\b''' AS virtual + FROM foreach( + row=SysInfoDict, + column='SystemInfo') + + notebook: + - name: As dict + type: vql_suggestion + template: | + SELECT _value AS SystemInfo + FROM items(item={ SELECT * FROM source() }) \ No newline at end of file diff --git a/content/exchange/artifacts/Server.Monitor.StoreClientHWInfo.yaml b/content/exchange/artifacts/Server.Monitor.StoreClientHWInfo.yaml new file mode 100755 index 00000000000..43fdfe07cc0 --- /dev/null +++ b/content/exchange/artifacts/Server.Monitor.StoreClientHWInfo.yaml @@ -0,0 +1,142 @@ +name: Server.Monitor.StoreClientHWInfo +author: Andreas Misje – @misje +description: | + Save HW identification data from client interrogation as client metadata. + + This artifact listens to flow completions, typically Custom.Generic.Client.Info, + your own override of the interrogation artifact, and extracts hardware information + gathered in the interrogation. The artifact used to provide this information + is expected to be Generic.Client.HW.Identification, provided as a source by + the customisable name HWIdentificationSource. In other words, you have to add + a new source to your Custom.Generic.Client.Info artifact and call + Generic.Client.HW.Identification for this server event artifact to be useful. + However, since this artifact is highly customisable, you may use it to set + any kind of metadata provided by a interrogation artifact source. Simply + adjust the parameters to your needs, ignore the serial settings, and adjust + HWInfoMetadata as needed. + + The primary use case for this artifact is to store the serial number of the + client as client metadata. This allows you to quickly look up clients by their + serial number if you add "serial" as an indexed field. Additional hardware info, + like the manufacturer, product name or model, may also be added as metadata. + Only unique, relevant metadata is useful for indexed searching, but additional + hardware identification could be handy anyway. + + See also Server.Monitor.StoreClientInfo for a similar artifact for storing + any metadata from any source in the interrogation artifact. + +type: SERVER_EVENT + +parameters: + - name: InterrogationArtifact + type: regex + description: | + Name of the client artifact to watch + default: Custom.Generic.Client.Info + + - name: HWIdentificationSource + description: | + Source in InterrogationArtifact that contains hardware information + default: HWIdentification + + - name: SerialColumn + description: | + Name of column containing the serial number used to find the device in + Snipe-IT. The default behaviour is to pick the first non-empty value in + product_serial, board_serial, and lastly, firmware_serial. The order of + choices matter. + type: multichoice + choices: + - product_serial + - board_serial + - chassis_serial + - firmware_serial + default: '["product_serial", "board_serial", "firmware_serial"]' + + - name: SerialMetadataField + description: | + Name of the metadata field that will store the serial number (from + SerialColumn). + default: serial + + - name: SerialIgnore + description: | + Serial values to ignore, typically defaults set on motherboards. + type: regex + default: '^(System Serial Number|System Version|Default string|0|None|)$' + + - name: HWInfoMetadata + description: | + Additional columns from InterrogationArtifact/HWIdentificationSource that will be + set as client metadata (SerialColumn/SerialMetadataField is always set). + Specify the column from the HWIdentification source in "Field", and an + optional alias, used as metadata name, in "Alias". + type: csv + default: | + Field,Alias + board_manufacturer,manufacturer + product_name, + + - name: KeepEmptyValues + description: | + If true, an empty value will be stored as metadata. If false, the metadata + will not be set at all. Note that if a metadata value was previously empty, + this will not remove that value. Empty/null serials are ignored. + type: bool + default: false + +sources: + - query: | + LET HWInfo = SELECT * + FROM foreach(row={ + SELECT * + FROM watch_monitoring(artifact='System.Flow.Completion') + WHERE Flow.artifacts_with_results =~ InterrogationArtifact + }, + query={ + SELECT ClientId, + * + FROM source(client_id=ClientId, + flow_id=Flow.session_id, + artifact=InterrogationArtifact, + source=HWIdentificationSource) + }) + + // With HW info as dict: + LET HWInfoDict = SELECT _value.ClientId AS ClientId, + _value - dict(ClientId=NULL, _Source=NULL) AS Data + FROM items(item={ SELECT * FROM HWInfo }) + + LET SerialValue(Data) = SELECT _value + FROM items(item=Data) + WHERE _key IN SerialColumn + AND NOT _value =~ SerialIgnore + + LET NullOrEmpty(Value) = Value = NULL OR Value = "" + + LET SelectedMetadata = SELECT * + FROM foreach(row=HWInfoDict, + query={ + SELECT ClientId, + to_dict(item={ + SELECT * + FROM foreach(row=HWInfoMetadata, + query={ + SELECT Alias || Field AS _key, + get(item=Data, field=Field) AS _value + FROM scope() + WHERE KeepEmptyValues OR (_value != NULL + AND len(list=str(str=_value))) + }) + }) + if(condition=NOT NullOrEmpty(Value=SerialValue(Data=Data)[0]._value), + then=set(item=dict(), + field=SerialMetadataField, + value=SerialValue(Data=Data)[0]._value), + else=dict()) AS Metadata + FROM scope() + }) + + // Set the metadata and return the dict of data, as well as the the + // client_set_metadata() result: + SELECT *, client_set_metadata(client_id=ClientId, metadata=Metadata) AS Updated + FROM SelectedMetadata \ No newline at end of file diff --git a/content/exchange/artifacts/Server.Monitor.StoreClientInfo.yaml b/content/exchange/artifacts/Server.Monitor.StoreClientInfo.yaml new file mode 100644 index 00000000000..9300a2b211c --- /dev/null +++ b/content/exchange/artifacts/Server.Monitor.StoreClientInfo.yaml @@ -0,0 +1,111 @@ +name: Server.Monitor.StoreClientInfo +author: Andreas Misje – @misje +description: | + Save data from client interrogation as client metadata. + + This artifact listens for flow completions, typically Custom.Generic.Client.Info, + your own override of the interrogation artifact, and extracts any information + gathered in the interrogation. + + The primary use case for this artifact is to any useful information about the + client as metadata so that it can be indexed and search for, e.g. "OS: windows", + "arch: amd64", "dual_boot: true", "ad_joined: false" etc. + + NOTE: The sources referred to in InfoMetadata should only return a single line. + If more lines are returned, only the first line is used. + + See also Server.Monitor.StoreClientHWInfo. + +type: SERVER_EVENT + +parameters: + - name: InterrogationArtifact + type: regex + description: | + Name of the client artifact to watch + default: Custom.Generic.Client.Info + + - name: InfoMetadata + description: | + The artifact source (e.g. BasicInformation), the field to save, and an + optional alias to give the field as a metadata name + type: csv + default: | + Source,Field,Alias + BasicInformation,OS,os + BasicInformation,Architecture,arch + + - name: KeepEmptyValues + description: | + If true, an empty value will be stored as metadata. If false, the metadata + will not be set at all. Note that if a metadata value was previously empty, + this will not remove that value. + type: bool + default: false + +sources: + - query: | + LET ExtractSource(Artifact) = regex_replace( + re='[^/]+(?:/(?P.+))?', + replace='$1', + source=Artifact) + + LET Interrogation = SELECT * + FROM foreach(row={ + SELECT * + FROM watch_monitoring(artifact='System.Flow.Completion') + WHERE Flow.artifacts_with_results =~ InterrogationArtifact + }, + query={ + SELECT * + FROM foreach(row=Flow.artifacts_with_results, + query={ + SELECT ExtractSource(Artifact=_value) AS _Source, + ClientId, + * + FROM source(client_id=ClientId, + flow_id=Flow.session_id, + artifact=InterrogationArtifact, + source=ExtractSource(Artifact=_value)) + WHERE ExtractSource(Artifact=_value) IN InfoMetadata.Source + GROUP BY _Source + }) + }) + + // With info as dict: + LET InfoDict = SELECT _value.ClientId AS ClientId, + _value._Source AS _Source, + _value AS Data + FROM items(item={ SELECT * FROM Interrogation }) + + LET SelectedMetadata = SELECT * + FROM foreach(row=InfoDict, + query={ + SELECT ClientId, + to_dict(item={ + SELECT * + FROM foreach(row=InfoMetadata, + query={ + SELECT Alias || Field AS _key, + get(item=Data, member=Field) AS _value + FROM scope() + WHERE _Source = Source + AND (KeepEmptyValues OR (_value != NULL + AND len(list=str(str=_value)))) + }) + }) - dict(ClientId=NULL, _Source=NULL) AS Metadata + FROM scope() + }) + + // Set the metadata and return the dict of data, as well as the the + // client_set_metadata() result: + // Do not store an empty dict. This has proved to cause issues: + LET SetMetadata = SELECT *, if(condition=Metadata, + then=client_set_metadata( + client_id=ClientId, + metadata=Metadata), + else=false) AS Updated + FROM SelectedMetadata + + SELECT * + FROM SetMetadata \ No newline at end of file