mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
merge main into feat-mdm-wipe-host
This commit is contained in:
commit
40892c9adb
20 changed files with 343 additions and 288 deletions
19
CODEOWNERS
19
CODEOWNERS
|
|
@ -84,15 +84,16 @@ go.mod @fleetdm/go
|
|||
#
|
||||
# (see website/config/custom.js for DRIs of other paths not listed here)
|
||||
##############################################################################################
|
||||
/handbook/company @mikermcneil
|
||||
/handbook/README.md @mikermcneil
|
||||
/handbook/business-operations @sampfluger88
|
||||
/handbook/digital-experience @sampfluger88
|
||||
/handbook/customer-success @sampfluger88
|
||||
/handbook/demand @sampfluger88
|
||||
/handbook/engineering @sampfluger88
|
||||
/handbook/sales @sampfluger88
|
||||
/handbook/product-design @sampfluger88
|
||||
/handbook/company @mikermcneil
|
||||
/handbook/README.md @mikermcneil
|
||||
/handbook/business-operations @sampfluger88
|
||||
/handbook/digital-experience @sampfluger88
|
||||
/handbook/customer-success @sampfluger88
|
||||
/handbook/demand @sampfluger88
|
||||
/handbook/engineering @sampfluger88 @lukeheath
|
||||
/handbook/sales @sampfluger88
|
||||
/handbook/product-design @sampfluger88
|
||||
/handbook/company/product-groups @sampfluger88 @lukeheath
|
||||
|
||||
##############################################################################################
|
||||
# 🦿 GitHub issue templates
|
||||
|
|
|
|||
1
changes/16593-disk-encryption-verifying
Normal file
1
changes/16593-disk-encryption-verifying
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Display disk encryption status in macOS as "verifying" while Fleet verifies if the escrowed key can be decrypted.
|
||||
1
changes/16608-search-target-icon
Normal file
1
changes/16608-search-target-icon
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Fix position of live query/poilcy host search icon
|
||||
|
|
@ -7,7 +7,7 @@ import { HOSTS_SEARCH_BOX_PLACEHOLDER } from "utilities/constants";
|
|||
|
||||
import DataError from "components/DataError";
|
||||
// @ts-ignore
|
||||
import Input from "components/forms/fields/InputFieldWithIcon";
|
||||
import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon/InputFieldWithIcon";
|
||||
import TableContainer from "components/TableContainer";
|
||||
import { generateTableHeaders } from "./TargetsInputHostsTableConfig";
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ const TargetsInput = ({
|
|||
return (
|
||||
<div>
|
||||
<div className={baseClass}>
|
||||
<Input
|
||||
<InputFieldWithIcon
|
||||
autofocus
|
||||
type="search"
|
||||
iconSvg="search"
|
||||
|
|
|
|||
|
|
@ -94,7 +94,4 @@
|
|||
overflow: auto;
|
||||
}
|
||||
}
|
||||
.input-icon-field__icon {
|
||||
top: 34px; // Override styling to include label header
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
.filter-cell {
|
||||
.input-icon-field__input-wrapper {
|
||||
margin-top: $pad-xsmall;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
|
|
@ -8,7 +12,6 @@
|
|||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
padding-left: 32px;
|
||||
margin-top: $pad-xsmall;
|
||||
}
|
||||
|
||||
.search-field__input-wrapper {
|
||||
|
|
@ -18,9 +21,6 @@
|
|||
}
|
||||
|
||||
.icon {
|
||||
left: 10px;
|
||||
top: 17px;
|
||||
|
||||
path {
|
||||
fill: $ui-fleet-black-33; // Override input icon color
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,25 +114,27 @@ class InputFieldWithIcon extends InputField {
|
|||
return (
|
||||
<div className={wrapperClasses}>
|
||||
{this.props.label && this.renderHeading()}
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
onChange={onInputChange}
|
||||
onClick={onClick}
|
||||
className={inputClasses}
|
||||
placeholder={placeholder}
|
||||
ref={(r) => {
|
||||
this.input = r;
|
||||
}}
|
||||
tabIndex={tabIndex}
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
{...inputOptions}
|
||||
data-1p-ignore={ignore1Password}
|
||||
/>
|
||||
{iconSvg && <Icon name={iconSvg} className={iconClasses} />}
|
||||
{iconName && <FleetIcon name={iconName} className={iconClasses} />}
|
||||
<div className={`${baseClass}__input-wrapper`}>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
onChange={onInputChange}
|
||||
onClick={onClick}
|
||||
className={inputClasses}
|
||||
placeholder={placeholder}
|
||||
ref={(r) => {
|
||||
this.input = r;
|
||||
}}
|
||||
tabIndex={tabIndex}
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
{...inputOptions}
|
||||
data-1p-ignore={ignore1Password}
|
||||
/>
|
||||
{iconSvg && <Icon name={iconSvg} className={iconClasses} />}
|
||||
{iconName && <FleetIcon name={iconName} className={iconClasses} />}
|
||||
</div>
|
||||
{renderHelpText()}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Relative input wrapper with absolute icon corrects icon alignment on all browsers
|
||||
&__input-wrapper {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Refactor to include svg icons
|
||||
&--icon-start {
|
||||
margin-top: 0;
|
||||
|
|
@ -25,10 +33,11 @@
|
|||
.input-icon-field__icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 13px;
|
||||
top: 0;
|
||||
height: 40px;
|
||||
width: 16px;
|
||||
font-size: $x-small;
|
||||
color: $core-fleet-blue;
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -51,20 +60,6 @@
|
|||
color: $core-fleet-blue;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border: 1px solid $core-vibrant-blue;
|
||||
|
||||
// Icon color matches border color on focus and on hover
|
||||
+ .input-icon-field__icon {
|
||||
svg {
|
||||
path {
|
||||
fill: $core-vibrant-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
|
@ -82,6 +77,33 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__input-wrapper:hover {
|
||||
.input-icon-field__input {
|
||||
border: 1px solid $core-vibrant-blue;
|
||||
}
|
||||
|
||||
// Icon color matches border color on focus and on hover
|
||||
.input-icon-field__icon {
|
||||
svg {
|
||||
path {
|
||||
fill: $core-vibrant-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-icon-field__input:focus {
|
||||
border: 1px solid $core-vibrant-blue;
|
||||
|
||||
// Icon color matches border color on focus and on hover
|
||||
+ .input-icon-field__icon {
|
||||
svg {
|
||||
path {
|
||||
fill: $core-vibrant-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&__label {
|
||||
display: block;
|
||||
font-size: $x-small;
|
||||
|
|
|
|||
|
|
@ -1965,186 +1965,179 @@ func (ds *Datastore) UpdateOrDeleteHostMDMAppleProfile(ctx context.Context, prof
|
|||
return err
|
||||
}
|
||||
|
||||
func subqueryHostsMacOSSettingsStatusFailed() (string, []interface{}) {
|
||||
sql := `
|
||||
SELECT
|
||||
1 FROM host_mdm_apple_profiles hmap
|
||||
WHERE
|
||||
h.uuid = hmap.host_uuid
|
||||
AND hmap.status = ?`
|
||||
args := []interface{}{fleet.MDMDeliveryFailed}
|
||||
const (
|
||||
appleMDMFailedProfilesStmt = `
|
||||
h.uuid = hmap.host_uuid AND
|
||||
hmap.status = :failed`
|
||||
|
||||
return sql, args
|
||||
}
|
||||
appleMDMPendingProfilesStmt = `
|
||||
h.uuid = hmap.host_uuid AND
|
||||
(
|
||||
hmap.status IS NULL OR
|
||||
hmap.status = :pending OR
|
||||
-- special case for filevault, it's pending if the profile is
|
||||
-- pending OR the profile is verified or verifying but we still
|
||||
-- don't have an encryption key.
|
||||
(
|
||||
hmap.profile_identifier = :filevault AND
|
||||
hmap.status IN (:verifying, :verified) AND
|
||||
hmap.operation_type = :install AND
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM host_disk_encryption_keys hdek
|
||||
WHERE h.id = hdek.host_id AND
|
||||
(hdek.decryptable = 1 OR hdek.decryptable IS NULL)
|
||||
)
|
||||
)
|
||||
)`
|
||||
|
||||
func subqueryHostsMacOSSettingsStatusPending() (string, []interface{}) {
|
||||
sql := `
|
||||
SELECT
|
||||
1 FROM host_mdm_apple_profiles hmap
|
||||
WHERE
|
||||
h.uuid = hmap.host_uuid
|
||||
AND (hmap.status IS NULL
|
||||
OR hmap.status = ?
|
||||
OR(hmap.profile_identifier = ?
|
||||
AND hmap.status IN (?, ?)
|
||||
AND hmap.operation_type = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT
|
||||
1 FROM host_disk_encryption_keys hdek
|
||||
WHERE
|
||||
h.id = hdek.host_id
|
||||
AND hdek.decryptable = 1)))
|
||||
AND NOT EXISTS (
|
||||
SELECT
|
||||
1 FROM host_mdm_apple_profiles hmap2
|
||||
WHERE
|
||||
h.uuid = hmap2.host_uuid
|
||||
AND hmap2.status = ?)`
|
||||
args := []interface{}{
|
||||
fleet.MDMDeliveryPending,
|
||||
mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
fleet.MDMDeliveryVerified,
|
||||
fleet.MDMOperationTypeInstall,
|
||||
fleet.MDMDeliveryFailed,
|
||||
appleMDMVerifyingProfilesStmt = `
|
||||
h.uuid = hmap.host_uuid AND
|
||||
hmap.operation_type = :install AND
|
||||
(
|
||||
-- all profiles except filevault that are 'verifying'
|
||||
(
|
||||
hmap.profile_identifier != :filevault AND
|
||||
hmap.status = :verifying
|
||||
)
|
||||
OR
|
||||
-- special cases for filevault
|
||||
(
|
||||
hmap.profile_identifier = :filevault AND
|
||||
(
|
||||
-- filevault profile is verified, but we didn't verify the encryption key
|
||||
(
|
||||
hmap.status = :verified AND
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM host_disk_encryption_keys AS hdek
|
||||
WHERE h.id = hdek.host_id AND
|
||||
hdek.decryptable IS NULL
|
||||
)
|
||||
)
|
||||
OR
|
||||
-- filevault profile is verifying, and we already have an encryption key, in any state
|
||||
(
|
||||
hmap.status = :verifying AND
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM host_disk_encryption_keys AS hdek
|
||||
WHERE h.id = hdek.host_id AND
|
||||
hdek.decryptable = 1 OR hdek.decryptable IS NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)`
|
||||
|
||||
appleVerifiedProfilesStmt = `
|
||||
h.uuid = hmap.host_uuid AND
|
||||
hmap.operation_type = :install AND
|
||||
hmap.status = :verified AND
|
||||
(
|
||||
hmap.profile_identifier != :filevault OR
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM host_disk_encryption_keys hdek
|
||||
WHERE h.id = hdek.host_id AND
|
||||
hdek.decryptable = 1
|
||||
)
|
||||
)`
|
||||
)
|
||||
|
||||
// subqueryAppleProfileStatus builds the right subquery that can be used to
|
||||
// filter hosts based on their profile status.
|
||||
//
|
||||
// The subquery mechanism works by finding profiles for hosts that:
|
||||
// - match with the provided status
|
||||
// - match any status that supercedes the provided status (eg: failed supercedes verifying)
|
||||
//
|
||||
// Hosts will be considered to be in the given status only if the profiles
|
||||
// match the given status and zero profiles match any superceding status.
|
||||
func subqueryAppleProfileStatus(status fleet.MDMDeliveryStatus) (string, []any, error) {
|
||||
var condition string
|
||||
var excludeConditions string
|
||||
switch status {
|
||||
case fleet.MDMDeliveryFailed:
|
||||
condition = appleMDMFailedProfilesStmt
|
||||
excludeConditions = "FALSE"
|
||||
case fleet.MDMDeliveryPending:
|
||||
condition = appleMDMPendingProfilesStmt
|
||||
excludeConditions = appleMDMFailedProfilesStmt
|
||||
case fleet.MDMDeliveryVerifying:
|
||||
condition = appleMDMVerifyingProfilesStmt
|
||||
excludeConditions = fmt.Sprintf("(%s) OR (%s)", appleMDMPendingProfilesStmt, appleMDMFailedProfilesStmt)
|
||||
case fleet.MDMDeliveryVerified:
|
||||
condition = appleVerifiedProfilesStmt
|
||||
excludeConditions = fmt.Sprintf("(%s) OR (%s) OR (%s)", appleMDMPendingProfilesStmt, appleMDMFailedProfilesStmt, appleMDMVerifyingProfilesStmt)
|
||||
default:
|
||||
return "", nil, fmt.Errorf("invalid status: %s", status)
|
||||
}
|
||||
return sql, args
|
||||
}
|
||||
|
||||
func subqueryHostsMacOSSetttingsStatusVerifying() (string, []interface{}) {
|
||||
sql := `
|
||||
SELECT
|
||||
1 FROM host_mdm_apple_profiles hmap
|
||||
WHERE
|
||||
h.uuid = hmap.host_uuid
|
||||
AND hmap.operation_type = ?
|
||||
AND hmap.status = ?
|
||||
AND(hmap.profile_identifier != ?
|
||||
OR EXISTS (
|
||||
SELECT
|
||||
1 FROM host_disk_encryption_keys hdek
|
||||
WHERE
|
||||
h.id = hdek.host_id
|
||||
AND hdek.decryptable = 1))
|
||||
AND NOT EXISTS (
|
||||
SELECT
|
||||
1 FROM host_mdm_apple_profiles hmap2
|
||||
WHERE (h.uuid = hmap2.host_uuid
|
||||
AND hmap2.operation_type = ?
|
||||
AND(hmap2.status IS NULL
|
||||
OR hmap2.status NOT IN(?, ?)
|
||||
OR(hmap2.profile_identifier = ?
|
||||
AND hmap2.status IN(?, ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT
|
||||
1 FROM host_disk_encryption_keys hdek
|
||||
WHERE
|
||||
h.id = hdek.host_id
|
||||
AND hdek.decryptable = 1))))
|
||||
OR(h.uuid = hmap2.host_uuid
|
||||
AND hmap2.operation_type = ?
|
||||
AND(hmap2.status IS NULL
|
||||
OR hmap2.status NOT IN(?, ?))))`
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT 1
|
||||
FROM host_mdm_apple_profiles hmap
|
||||
WHERE %s AND
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM host_mdm_apple_profiles hmap
|
||||
WHERE %s
|
||||
)`, condition, excludeConditions)
|
||||
|
||||
args := []interface{}{
|
||||
fleet.MDMOperationTypeInstall,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
fleet.MDMOperationTypeInstall,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
fleet.MDMDeliveryVerified,
|
||||
mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
fleet.MDMDeliveryVerified,
|
||||
fleet.MDMOperationTypeRemove,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
fleet.MDMDeliveryVerified,
|
||||
arg := map[string]any{
|
||||
"install": fleet.MDMOperationTypeInstall,
|
||||
"remove": fleet.MDMOperationTypeRemove,
|
||||
"verifying": fleet.MDMDeliveryVerifying,
|
||||
"failed": fleet.MDMDeliveryFailed,
|
||||
"verified": fleet.MDMDeliveryVerified,
|
||||
"pending": fleet.MDMDeliveryPending,
|
||||
"filevault": mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
}
|
||||
return sql, args
|
||||
}
|
||||
|
||||
func subqueryHostsMacOSSetttingsStatusVerified() (string, []interface{}) {
|
||||
sql := `
|
||||
SELECT
|
||||
1 FROM host_mdm_apple_profiles hmap
|
||||
WHERE
|
||||
h.uuid = hmap.host_uuid
|
||||
AND hmap.operation_type = ?
|
||||
AND hmap.status = ?
|
||||
AND(hmap.profile_identifier != ?
|
||||
OR EXISTS (
|
||||
SELECT
|
||||
1 FROM host_disk_encryption_keys hdek
|
||||
WHERE
|
||||
h.id = hdek.host_id
|
||||
AND hdek.decryptable = 1))
|
||||
AND NOT EXISTS (
|
||||
SELECT
|
||||
1 FROM host_mdm_apple_profiles hmap2
|
||||
WHERE (h.uuid = hmap2.host_uuid
|
||||
AND hmap2.operation_type = ?
|
||||
AND (hmap2.status IS NULL
|
||||
OR hmap2.status != ?
|
||||
OR(hmap2.profile_identifier = ?
|
||||
AND hmap2.status = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT
|
||||
1 FROM host_disk_encryption_keys hdek
|
||||
WHERE
|
||||
h.id = hdek.host_id
|
||||
AND hdek.decryptable = 1))))
|
||||
OR(h.uuid = hmap2.host_uuid
|
||||
AND hmap2.operation_type = ?
|
||||
AND (hmap2.status IS NULL
|
||||
OR hmap2.status NOT IN(?, ?))))`
|
||||
args := []interface{}{
|
||||
fleet.MDMOperationTypeInstall,
|
||||
fleet.MDMDeliveryVerified,
|
||||
mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
fleet.MDMOperationTypeInstall,
|
||||
fleet.MDMDeliveryVerified,
|
||||
mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
fleet.MDMDeliveryVerified,
|
||||
fleet.MDMOperationTypeRemove,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
fleet.MDMDeliveryVerified,
|
||||
query, args, err := sqlx.Named(sql, arg)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("subqueryAppleProfileStatus %s: %w", status, err)
|
||||
}
|
||||
return sql, args
|
||||
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetMDMAppleProfilesSummary(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) {
|
||||
var args []interface{}
|
||||
subqueryFailed, subqueryFailedArgs := subqueryHostsMacOSSettingsStatusFailed()
|
||||
|
||||
subqueryFailed, subqueryFailedArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryFailed)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building failed subquery")
|
||||
}
|
||||
args = append(args, subqueryFailedArgs...)
|
||||
subqueryPending, subqueryPendingArgs := subqueryHostsMacOSSettingsStatusPending()
|
||||
|
||||
subqueryPending, subqueryPendingArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryPending)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building pending subquery")
|
||||
}
|
||||
args = append(args, subqueryPendingArgs...)
|
||||
subqueryVerifying, subqueryVeryingingArgs := subqueryHostsMacOSSetttingsStatusVerifying()
|
||||
args = append(args, subqueryVeryingingArgs...)
|
||||
subqueryVerified, subqueryVerifiedArgs := subqueryHostsMacOSSetttingsStatusVerified()
|
||||
|
||||
subqueryVerifying, subqueryVerifyingArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryVerifying)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building verifying subquery")
|
||||
}
|
||||
args = append(args, subqueryVerifyingArgs...)
|
||||
|
||||
subqueryVerified, subqueryVerifiedArgs, err := subqueryAppleProfileStatus(fleet.MDMDeliveryVerified)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building verified subquery")
|
||||
}
|
||||
args = append(args, subqueryVerifiedArgs...)
|
||||
|
||||
sqlFmt := `
|
||||
SELECT
|
||||
COUNT(
|
||||
CASE WHEN EXISTS (%s)
|
||||
THEN 1
|
||||
END) AS failed,
|
||||
COUNT(
|
||||
CASE WHEN EXISTS (%s)
|
||||
THEN 1
|
||||
END) AS pending,
|
||||
COUNT(
|
||||
CASE WHEN EXISTS (%s)
|
||||
THEN 1
|
||||
END) AS verifying,
|
||||
COUNT(
|
||||
CASE WHEN EXISTS (%s)
|
||||
THEN 1
|
||||
END) AS verified
|
||||
FROM
|
||||
hosts h
|
||||
WHERE
|
||||
h.platform = 'darwin' AND %s`
|
||||
SELECT
|
||||
COUNT(CASE WHEN EXISTS (%s) THEN 1 END) AS failed,
|
||||
COUNT(CASE WHEN EXISTS (%s) THEN 1 END) AS pending,
|
||||
COUNT(CASE WHEN EXISTS (%s) THEN 1 END) AS verifying,
|
||||
COUNT(CASE WHEN EXISTS (%s) THEN 1 END) AS verified
|
||||
FROM
|
||||
hosts h
|
||||
WHERE
|
||||
h.platform = 'darwin' AND %s`
|
||||
|
||||
teamFilter := "h.team_id IS NULL"
|
||||
if teamID != nil && *teamID > 0 {
|
||||
|
|
@ -2153,9 +2146,8 @@ WHERE
|
|||
}
|
||||
|
||||
stmt := fmt.Sprintf(sqlFmt, subqueryFailed, subqueryPending, subqueryVerifying, subqueryVerified, teamFilter)
|
||||
|
||||
var res fleet.MDMProfilesSummary
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...)
|
||||
err = sqlx.GetContext(ctx, ds.reader(ctx), &res, stmt, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -2209,14 +2201,18 @@ func subqueryFileVaultVerifying() (string, []interface{}) {
|
|||
1 FROM host_mdm_apple_profiles hmap
|
||||
WHERE
|
||||
h.uuid = hmap.host_uuid
|
||||
AND hdek.decryptable = 1
|
||||
AND hmap.profile_identifier = ?
|
||||
AND hmap.status = ?
|
||||
AND hmap.operation_type = ?`
|
||||
AND hmap.operation_type = ?
|
||||
AND (
|
||||
(hmap.status = ? AND hdek.decryptable IS NULL)
|
||||
OR
|
||||
(hmap.status = ? AND hdek.decryptable = 1)
|
||||
)`
|
||||
args := []interface{}{
|
||||
mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
fleet.MDMOperationTypeInstall,
|
||||
fleet.MDMDeliveryVerified,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
}
|
||||
return sql, args
|
||||
}
|
||||
|
|
@ -2268,23 +2264,11 @@ func subqueryFileVaultEnforcing() (string, []interface{}) {
|
|||
AND hmap.profile_identifier = ?
|
||||
AND (hmap.status IS NULL OR hmap.status = ?)
|
||||
AND hmap.operation_type = ?
|
||||
UNION SELECT
|
||||
1 FROM host_mdm_apple_profiles hmap
|
||||
WHERE
|
||||
h.uuid = hmap.host_uuid
|
||||
AND hmap.profile_identifier = ?
|
||||
AND (hmap.status IS NOT NULL AND (hmap.status = ? OR hmap.status = ?))
|
||||
AND hmap.operation_type = ?
|
||||
AND hdek.decryptable IS NULL
|
||||
AND hdek.host_id IS NOT NULL`
|
||||
`
|
||||
args := []interface{}{
|
||||
mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
fleet.MDMDeliveryPending,
|
||||
fleet.MDMOperationTypeInstall,
|
||||
mobileconfig.FleetFileVaultPayloadIdentifier,
|
||||
fleet.MDMDeliveryVerifying,
|
||||
fleet.MDMDeliveryVerified,
|
||||
fleet.MDMOperationTypeInstall,
|
||||
}
|
||||
return sql, args
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1769,9 +1769,11 @@ func testAggregateMacOSSettingsStatusWithFileVault(t *testing.T, ds *Datastore)
|
|||
res, err = ds.GetMDMAppleProfilesSummary(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Equal(t, uint(len(hosts)), res.Pending) // still pending because disk encryption key decryptable is not set
|
||||
// hosts still pending because disk encryption key decryptable is not set
|
||||
require.Equal(t, uint(len(hosts)-1), res.Pending)
|
||||
require.Equal(t, uint(0), res.Failed)
|
||||
require.Equal(t, uint(0), res.Verifying)
|
||||
// one host is verifying because the disk is encrypted and we're verifying the key
|
||||
require.Equal(t, uint(1), res.Verifying)
|
||||
require.Equal(t, uint(0), res.Verified)
|
||||
|
||||
err = ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{hosts[0].ID}, false, time.Now().Add(1*time.Hour))
|
||||
|
|
@ -2434,7 +2436,13 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
|
|||
|
||||
// verifying status
|
||||
verifyingHost := hosts[0]
|
||||
upsertHostCPs([]*fleet.Host{verifyingHost}, []*fleet.MDMAppleConfigProfile{noTeamFVProfile}, fleet.MDMOperationTypeInstall, &fleet.MDMDeliveryVerifying, ctx, ds, t)
|
||||
upsertHostCPs(
|
||||
[]*fleet.Host{verifyingHost},
|
||||
[]*fleet.MDMAppleConfigProfile{noTeamFVProfile},
|
||||
fleet.MDMOperationTypeInstall,
|
||||
&fleet.MDMDeliveryVerifying,
|
||||
ctx, ds, t,
|
||||
)
|
||||
oneMinuteAfterThreshold := time.Now().Add(+1 * time.Minute)
|
||||
createDiskEncryptionRecord(ctx, ds, t, verifyingHost.ID, "key-1", true, oneMinuteAfterThreshold)
|
||||
|
||||
|
|
@ -2643,7 +2651,13 @@ func TestMDMAppleFileVaultSummary(t *testing.T) {
|
|||
require.Equal(t, uint(0), allProfilesSummary.Verified)
|
||||
|
||||
// verified status
|
||||
upsertHostCPs([]*fleet.Host{verifyingTeam1Host}, []*fleet.MDMAppleConfigProfile{team1FVProfile}, fleet.MDMOperationTypeInstall, &fleet.MDMDeliveryVerified, ctx, ds, t)
|
||||
upsertHostCPs(
|
||||
[]*fleet.Host{verifyingTeam1Host},
|
||||
[]*fleet.MDMAppleConfigProfile{team1FVProfile},
|
||||
fleet.MDMOperationTypeInstall,
|
||||
&fleet.MDMDeliveryVerified,
|
||||
ctx, ds, t,
|
||||
)
|
||||
fvProfileSummary, err = ds.GetMDMAppleFileVaultSummary(ctx, &tm.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fvProfileSummary)
|
||||
|
|
|
|||
|
|
@ -1074,7 +1074,11 @@ func (ds *Datastore) applyHostFilters(
|
|||
sqlStmt, params = filterHostsByTeam(sqlStmt, opt, params)
|
||||
sqlStmt, params = filterHostsByPolicy(sqlStmt, opt, params)
|
||||
sqlStmt, params = filterHostsByMDM(sqlStmt, opt, params)
|
||||
sqlStmt, params = filterHostsByMacOSSettingsStatus(sqlStmt, opt, params)
|
||||
var err error
|
||||
sqlStmt, params, err = filterHostsByMacOSSettingsStatus(sqlStmt, opt, params)
|
||||
if err != nil {
|
||||
return "", nil, ctxerr.Wrap(ctx, err, "building query to filter macOS settings status")
|
||||
}
|
||||
sqlStmt, params = filterHostsByMacOSDiskEncryptionStatus(sqlStmt, opt, params)
|
||||
if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
|
@ -1195,9 +1199,9 @@ func filterHostsByStatus(now time.Time, sql string, opt fleet.HostListOptions, p
|
|||
return sql, params
|
||||
}
|
||||
|
||||
func filterHostsByMacOSSettingsStatus(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) {
|
||||
func filterHostsByMacOSSettingsStatus(sql string, opt fleet.HostListOptions, params []any) (string, []any, error) {
|
||||
if !opt.MacOSSettingsFilter.IsValid() {
|
||||
return sql, params
|
||||
return sql, params, nil
|
||||
}
|
||||
|
||||
newSQL := ""
|
||||
|
|
@ -1208,22 +1212,26 @@ func filterHostsByMacOSSettingsStatus(sql string, opt fleet.HostListOptions, par
|
|||
}
|
||||
|
||||
var subquery string
|
||||
var subqueryParams []interface{}
|
||||
var subqueryParams []any
|
||||
var err error
|
||||
switch opt.MacOSSettingsFilter {
|
||||
case fleet.OSSettingsFailed:
|
||||
subquery, subqueryParams = subqueryHostsMacOSSettingsStatusFailed()
|
||||
subquery, subqueryParams, err = subqueryAppleProfileStatus(fleet.MDMDeliveryFailed)
|
||||
case fleet.OSSettingsPending:
|
||||
subquery, subqueryParams = subqueryHostsMacOSSettingsStatusPending()
|
||||
subquery, subqueryParams, err = subqueryAppleProfileStatus(fleet.MDMDeliveryPending)
|
||||
case fleet.OSSettingsVerifying:
|
||||
subquery, subqueryParams = subqueryHostsMacOSSetttingsStatusVerifying()
|
||||
subquery, subqueryParams, err = subqueryAppleProfileStatus(fleet.MDMDeliveryVerifying)
|
||||
case fleet.OSSettingsVerified:
|
||||
subquery, subqueryParams = subqueryHostsMacOSSetttingsStatusVerified()
|
||||
subquery, subqueryParams, err = subqueryAppleProfileStatus(fleet.MDMDeliveryVerified)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("building subquery for %s filter: %w", opt.MacOSSettingsFilter, err)
|
||||
}
|
||||
if subquery != "" {
|
||||
newSQL += fmt.Sprintf(` AND EXISTS (%s)`, subquery)
|
||||
}
|
||||
|
||||
return sql + newSQL, append(params, subqueryParams...)
|
||||
return sql + newSQL, append(params, subqueryParams...), nil
|
||||
}
|
||||
|
||||
func filterHostsByMacOSDiskEncryptionStatus(sql string, opt fleet.HostListOptions, params []interface{}) (string, []interface{}) {
|
||||
|
|
@ -1276,15 +1284,19 @@ func (ds *Datastore) filterHostsByOSSettingsStatus(sql string, opt fleet.HostLis
|
|||
// construct the WHERE for macOS
|
||||
var subqueryMacOS string
|
||||
var paramsMacOS []interface{}
|
||||
var err error
|
||||
switch opt.OSSettingsFilter {
|
||||
case fleet.OSSettingsFailed:
|
||||
subqueryMacOS, paramsMacOS = subqueryHostsMacOSSettingsStatusFailed()
|
||||
subqueryMacOS, paramsMacOS, err = subqueryAppleProfileStatus(fleet.MDMDeliveryFailed)
|
||||
case fleet.OSSettingsPending:
|
||||
subqueryMacOS, paramsMacOS = subqueryHostsMacOSSettingsStatusPending()
|
||||
subqueryMacOS, paramsMacOS, err = subqueryAppleProfileStatus(fleet.MDMDeliveryPending)
|
||||
case fleet.OSSettingsVerifying:
|
||||
subqueryMacOS, paramsMacOS = subqueryHostsMacOSSetttingsStatusVerifying()
|
||||
subqueryMacOS, paramsMacOS, err = subqueryAppleProfileStatus(fleet.MDMDeliveryVerifying)
|
||||
case fleet.OSSettingsVerified:
|
||||
subqueryMacOS, paramsMacOS = subqueryHostsMacOSSetttingsStatusVerified()
|
||||
subqueryMacOS, paramsMacOS, err = subqueryAppleProfileStatus(fleet.MDMDeliveryVerified)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("building subquery for %s filter: %w", opt.OSSettingsFilter, err)
|
||||
}
|
||||
if subqueryMacOS != "" {
|
||||
whereMacOS = "EXISTS (" + subqueryMacOS + ")"
|
||||
|
|
|
|||
|
|
@ -584,10 +584,14 @@ func (ds *Datastore) applyHostLabelFilters(ctx context.Context, filter fleet.Tea
|
|||
params = append(params, *opt.LowDiskSpaceFilter)
|
||||
}
|
||||
|
||||
var err error
|
||||
query, params = filterHostsByStatus(ds.clock.Now(), query, opt, params)
|
||||
query, params = filterHostsByTeam(query, opt, params)
|
||||
query, params = filterHostsByMDM(query, opt, params)
|
||||
query, params = filterHostsByMacOSSettingsStatus(query, opt, params)
|
||||
query, params, err = filterHostsByMacOSSettingsStatus(query, opt, params)
|
||||
if err != nil {
|
||||
return "", nil, ctxerr.Wrap(ctx, err, "building macOS settings status filter")
|
||||
}
|
||||
query, params = filterHostsByMacOSDiskEncryptionStatus(query, opt, params)
|
||||
query, params = filterHostsByMDMBootstrapPackageStatus(query, opt, params)
|
||||
if enableDiskEncryption, err := ds.getConfigEnableDiskEncryption(ctx, opt.TeamFilter); err != nil {
|
||||
|
|
|
|||
|
|
@ -505,10 +505,10 @@ func (d *MDMHostData) PopulateOSSettingsAndMacOSSettings(profiles []HostMDMApple
|
|||
if d.rawDecryptable != nil && *d.rawDecryptable == 1 {
|
||||
// if a FileVault profile has been successfully installed on the host
|
||||
// AND we have fetched and are able to decrypt the key
|
||||
switch {
|
||||
case *fvprof.Status == MDMDeliveryVerifying:
|
||||
switch *fvprof.Status {
|
||||
case MDMDeliveryVerifying:
|
||||
settings.DiskEncryption = DiskEncryptionVerifying.addrOf()
|
||||
case *fvprof.Status == MDMDeliveryVerified:
|
||||
case MDMDeliveryVerified:
|
||||
settings.DiskEncryption = DiskEncryptionVerified.addrOf()
|
||||
}
|
||||
} else if d.rawDecryptable != nil {
|
||||
|
|
@ -525,7 +525,12 @@ func (d *MDMHostData) PopulateOSSettingsAndMacOSSettings(profiles []HostMDMApple
|
|||
// if [a FileVault profile is pending to be installed or] the
|
||||
// matching row in host_disk_encryption_keys has a field decryptable
|
||||
// = NULL
|
||||
settings.DiskEncryption = DiskEncryptionEnforcing.addrOf()
|
||||
switch *fvprof.Status {
|
||||
case MDMDeliveryVerifying, MDMDeliveryVerified:
|
||||
settings.DiskEncryption = DiskEncryptionVerifying.addrOf()
|
||||
case MDMDeliveryPending:
|
||||
settings.DiskEncryption = DiskEncryptionEnforcing.addrOf()
|
||||
}
|
||||
}
|
||||
|
||||
case fvprof.Status != nil && *fvprof.Status == MDMDeliveryFailed:
|
||||
|
|
|
|||
|
|
@ -156,17 +156,17 @@ func (svc *Service) NewDistributedQueryCampaign(ctx context.Context, queryString
|
|||
}
|
||||
}
|
||||
|
||||
err = svc.liveQueryStore.RunQuery(strconv.Itoa(int(campaign.ID)), queryString, hostIDs)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "run query")
|
||||
}
|
||||
|
||||
// Metrics are used for total hosts targeted for the activity feed.
|
||||
campaign.Metrics, err = svc.ds.CountHostsInTargets(ctx, filter, targets, time.Now())
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "counting hosts")
|
||||
}
|
||||
|
||||
err = svc.liveQueryStore.RunQuery(strconv.Itoa(int(campaign.ID)), queryString, hostIDs)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "run query")
|
||||
}
|
||||
|
||||
return campaign, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -144,9 +144,9 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) {
|
|||
Status: &fleet.MDMDeliveryVerifying,
|
||||
OperationType: fleet.MDMOperationTypeInstall,
|
||||
},
|
||||
fleet.DiskEncryptionEnforcing,
|
||||
fleet.DiskEncryptionVerifying,
|
||||
"",
|
||||
&fleet.MDMDeliveryPending,
|
||||
&fleet.MDMDeliveryVerifying,
|
||||
},
|
||||
{
|
||||
"installed profile, not decryptable",
|
||||
|
|
|
|||
|
|
@ -2963,15 +2963,15 @@ func (s *integrationMDMTestSuite) TestMDMAppleHostDiskEncryption() {
|
|||
require.NoError(t, err)
|
||||
|
||||
// get that host - it has an encryption key with unknown decryptability, so
|
||||
// it should report "enforcing" disk encryption.
|
||||
// it should report "verifying" disk encryption.
|
||||
getHostResp = getHostResponse{}
|
||||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
|
||||
require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
|
||||
require.Equal(t, fleet.DiskEncryptionEnforcing, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
|
||||
require.Equal(t, fleet.DiskEncryptionVerifying, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
|
||||
require.Nil(t, getHostResp.Host.MDM.MacOSSettings.ActionRequired)
|
||||
require.NotNil(t, getHostResp.Host.MDM.OSSettings)
|
||||
require.NotNil(t, getHostResp.Host.MDM.OSSettings.DiskEncryption.Status)
|
||||
require.Equal(t, fleet.DiskEncryptionEnforcing, *getHostResp.Host.MDM.OSSettings.DiskEncryption.Status)
|
||||
require.Equal(t, fleet.DiskEncryptionVerifying, *getHostResp.Host.MDM.OSSettings.DiskEncryption.Status)
|
||||
require.Equal(t, "", getHostResp.Host.MDM.OSSettings.DiskEncryption.Detail)
|
||||
|
||||
// request with no token
|
||||
|
|
|
|||
|
|
@ -263,27 +263,30 @@ func (svc *Service) RunLiveQueryDeadline(
|
|||
queryIDPtr = nil
|
||||
queryString = query
|
||||
}
|
||||
|
||||
campaign, err := svc.NewDistributedQueryCampaign(ctx, queryString, queryIDPtr, fleet.HostTargets{HostIDs: hostIDs})
|
||||
if err != nil {
|
||||
level.Error(svc.logger).Log(
|
||||
"msg", "new distributed query campaign",
|
||||
"queryString", queryString,
|
||||
"queryID", queryID,
|
||||
"err", err,
|
||||
)
|
||||
resultsCh <- fleet.QueryCampaignResult{QueryID: queryID, Error: ptr.String(err.Error()), Err: err}
|
||||
return
|
||||
}
|
||||
queryID = campaign.QueryID
|
||||
|
||||
readChan, cancelFunc, err := svc.GetCampaignReader(ctx, campaign)
|
||||
if err != nil {
|
||||
resultsCh <- fleet.QueryCampaignResult{QueryID: queryID, Error: ptr.String(err.Error()), Err: err}
|
||||
return
|
||||
}
|
||||
defer cancelFunc()
|
||||
|
||||
// We do not want to use the outer `ctx` directly because we want to cleanup the campaign
|
||||
// even if the outer `ctx` is canceled (e.g. a client terminating the connection).
|
||||
// Also, we make sure stats and activity DB operations don't get killed after we return results.
|
||||
ctxWithoutCancel := context.WithoutCancel(ctx)
|
||||
defer func() {
|
||||
// We do not want to use the outer `ctx` directly because we want to cleanup the campaign
|
||||
// even if the outer `ctx` is canceled (e.g. a client terminating the connection).
|
||||
ctx := context.WithoutCancel(ctx)
|
||||
err := svc.CompleteCampaign(ctx, campaign)
|
||||
err := svc.CompleteCampaign(ctxWithoutCancel, campaign)
|
||||
if err != nil {
|
||||
level.Error(svc.logger).Log("msg", "completing campaign (sync)", "query.id", campaign.QueryID, "err", err)
|
||||
level.Error(svc.logger).Log(
|
||||
"msg", "completing campaign (sync)", "query.id", campaign.QueryID, "campaign.id", campaign.ID, "err", err,
|
||||
)
|
||||
resultsCh <- fleet.QueryCampaignResult{
|
||||
QueryID: queryID,
|
||||
Error: ptr.String(err.Error()),
|
||||
|
|
@ -292,6 +295,16 @@ func (svc *Service) RunLiveQueryDeadline(
|
|||
}
|
||||
}()
|
||||
|
||||
readChan, cancelFunc, err := svc.GetCampaignReader(ctx, campaign)
|
||||
if err != nil {
|
||||
level.Error(svc.logger).Log(
|
||||
"msg", "get campaign reader", "query.id", campaign.QueryID, "campaign.id", campaign.ID, "err", err,
|
||||
)
|
||||
resultsCh <- fleet.QueryCampaignResult{QueryID: queryID, Error: ptr.String(err.Error()), Err: err}
|
||||
return
|
||||
}
|
||||
defer cancelFunc()
|
||||
|
||||
var results []fleet.QueryResult
|
||||
timeout := time.After(deadline)
|
||||
|
||||
|
|
@ -305,8 +318,6 @@ func (svc *Service) RunLiveQueryDeadline(
|
|||
level.Error(svc.logger).Log("msg", "error checking saved query", "query.id", campaign.QueryID, "err", err)
|
||||
perfStatsTracker.saveStats = false
|
||||
}
|
||||
// to make sure stats and activity DB operations don't get killed after we return results.
|
||||
ctxWithoutCancel := context.WithoutCancel(ctx)
|
||||
totalHosts := campaign.Metrics.TotalHosts
|
||||
// We update aggregated stats and activity at the end asynchronously.
|
||||
defer func() {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ module.exports = {
|
|||
MAINTAINERS_BY_PATH = sails.config.custom.confidentialGithubRepoMaintainersByPath;
|
||||
}
|
||||
|
||||
if (repo === 'fleet-mdm-gitops') {
|
||||
if (repo === 'fleet-gitops') {
|
||||
MAINTAINERS_BY_PATH = sails.config.custom.fleetMdmGitopsGithubRepoMaintainersByPath;
|
||||
}
|
||||
|
||||
|
|
|
|||
3
website/config/custom.js
vendored
3
website/config/custom.js
vendored
|
|
@ -221,9 +221,10 @@ module.exports.custom = {
|
|||
// Handbook
|
||||
'handbook/README.md': 'mikermcneil', // See https://github.com/fleetdm/fleet/pull/13195
|
||||
'handbook/company': 'mikermcneil',
|
||||
'handbook/company/product-groups': ['lukeheath', 'sampfluger88','mikermcneil'],
|
||||
'handbook/digital-experience': ['sampfluger88','mikermcneil'],
|
||||
'handbook/business-operations': ['sampfluger88','mikermcneil'],
|
||||
'handbook/engineering': ['sampfluger88','mikermcneil'],
|
||||
'handbook/engineering': ['sampfluger88','mikermcneil', 'lukeheath'],
|
||||
'handbook/product-design': ['sampfluger88','mikermcneil'],
|
||||
'handbook/sales': ['sampfluger88','mikermcneil'],
|
||||
'handbook/demand': ['sampfluger88','mikermcneil'],
|
||||
|
|
|
|||
|
|
@ -81,11 +81,11 @@
|
|||
|
||||
<div purpose="feature" class="d-flex flex-md-row flex-column justify-content-between mx-auto align-items-center">
|
||||
<div purpose="feature-image" class="right">
|
||||
<img alt="Consolidate your security stack" src="/images/vuln-management-feature-image-3-380x320@2x.png">
|
||||
<img alt="Untangle your security stack" src="/images/vuln-management-feature-image-3-380x320@2x.png">
|
||||
</div>
|
||||
<div purpose="feature-text" class="d-flex flex-column">
|
||||
<h3>Consolidate your security stack</h3>
|
||||
<p>Consolidate your point vulnerability solution with your cybersecurity asset management and log capture tools.</p>
|
||||
<h3>Untangle your security stack</h3>
|
||||
<p>Use open data and APIs to connect your point vulnerability solution with your cybersecurity asset management and log capture tools.</p>
|
||||
<div purpose="checklist" class="flex-column d-flex">
|
||||
<p>Prevent duplicated, inaccurate CMDBs to reduce tool sprawl and wasted budget</p>
|
||||
<p>Normalize asset management data and software inventories from multiple tools and operating systems</p>
|
||||
|
|
|
|||
Loading…
Reference in a new issue