Add certificates to host vitals for macOS, iOS, iPadOS (#26663)

This commit is contained in:
Sarah Gillespie 2025-02-27 12:19:02 -06:00 committed by GitHub
commit 0527b1c11f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2419 additions and 138 deletions

View file

@ -0,0 +1 @@
- Added new features to include certificates in host vitals for macOS, iOS, and iPadOS.

View file

@ -0,0 +1 @@
* Added the list host certificates (and list device's certificates) endpoints.

View file

@ -0,0 +1 @@
- add UI for viewing certificate details on the host details and my device pages

View file

@ -21,6 +21,7 @@ import (
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/vpp"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
"github.com/fleetdm/fleet/v4/server/mock"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
@ -1623,14 +1624,18 @@ software:
require.NoError(t, err)
// Dry run, global defines software, should fail.
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f",
_, err = runAppNoChecks([]string{
"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileBasic.Name(),
"--dry-run"})
"--dry-run",
})
require.Error(t, err)
assert.ErrorContains(t, err, "'software' cannot be set on global file")
// Real run, global defines software, should fail.
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileBasic.Name()})
_, err = runAppNoChecks([]string{
"gitops", "-f", globalFileWithSoftware.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileBasic.Name(),
})
require.Error(t, err)
assert.ErrorContains(t, err, "'software' cannot be set on global file")
})
@ -1653,13 +1658,17 @@ software:
require.NoError(t, err)
// Dry run, both global and no-team.yml define controls.
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithControls.Name(), "--dry-run"})
_, err = runAppNoChecks([]string{
"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithControls.Name(), "--dry-run",
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml"))
// Real run, both global and no-team.yml define controls.
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithControls.Name()})
_, err = runAppNoChecks([]string{
"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithControls.Name(),
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "'controls' cannot be set on both global config and on no-team.yml"))
})
@ -1682,13 +1691,17 @@ software:
require.NoError(t, err)
// Dry run, both global and no-team.yml defines policy with calendar events enabled.
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFilePathPoliciesCalendar.Name(), "--dry-run"})
_, err = runAppNoChecks([]string{
"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFilePathPoliciesCalendar.Name(), "--dry-run",
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on \"No team\" policies: \"Foobar\""), err.Error())
// Real run, both global and no-team.yml define controls.
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFilePathPoliciesCalendar.Name()})
_, err = runAppNoChecks([]string{
"gitops", "-f", globalFileWithControls.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFilePathPoliciesCalendar.Name(),
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "calendar events are not supported on \"No team\" policies: \"Foobar\""), err.Error())
})
@ -1707,13 +1720,17 @@ software:
require.NoError(t, err)
// Dry run, controls should be defined somewhere, either in no-team.yml or global.
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(), "--dry-run"})
_, err = runAppNoChecks([]string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(), "--dry-run",
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml"))
// Real run
_, err = runAppNoChecks([]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name()})
_, err = runAppNoChecks([]string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileWithoutControls.Name(),
})
require.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "'controls' must be set on global config or no-team.yml"))
})
@ -1725,15 +1742,19 @@ software:
// Dry run, global file without controls and software keys.
_ = runAppForTest(t,
[]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
[]string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileBasic.Name(),
"--dry-run"})
"--dry-run",
})
assert.Equal(t, fleet.AppConfig{}, *savedAppConfig, "AppConfig should be empty")
// Real run, global file without controls and software keys.
_ = runAppForTest(t,
[]string{"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileBasic.Name()})
[]string{
"gitops", "-f", globalFileWithoutControlsAndSoftwareKeys.Name(), "-f", teamFileBasic.Name(), "-f",
noTeamFileBasic.Name(),
})
assert.Equal(t, orgName, savedAppConfig.OrgInfo.OrgName)
assert.Equal(t, fleetServerURL, savedAppConfig.ServerSettings.ServerURL)
assert.Len(t, enrolledSecrets, 1)
@ -1741,7 +1762,6 @@ software:
assert.Equal(t, teamName, savedTeam.Name)
require.Len(t, enrolledTeamSecrets, 1)
assert.Equal(t, secret, enrolledTeamSecrets[0].Secret)
})
t.Run("basic global and no-team.yml", func(t *testing.T) {
@ -1907,7 +1927,7 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) {
return 0, nil
}
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(testing_utils.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
crt, key, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
@ -2017,7 +2037,6 @@ software:
assert.Equal(t, filepath.Base(cspFile.Name()), filepath.Base((*savedAppConfigPtr).MDM.WindowsSettings.CustomSettings.Value[0].Path))
assert.True(t, ds.BatchSetScriptsFuncInvoked)
})
}
func TestGitOpsTeamSofwareInstallers(t *testing.T) {

View file

@ -24,6 +24,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/urfave/cli/v2"
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
)
type withDS struct {
@ -122,7 +124,7 @@ func runServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{}, nil
}
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
require.NoError(t, err)

View file

@ -17,6 +17,23 @@ SELECT 1 FROM osquery_registry WHERE active = true AND registry = 'table' AND na
SELECT serial_number, cycle_count, designed_capacity, max_capacity FROM battery
```
## certificates_darwin
- Platforms: darwin
- Query:
```sql
SELECT
ca, common_name, subject, issuer,
key_algorithm, key_strength, key_usage, signing_algorithm,
not_valid_after, not_valid_before,
serial, sha1
FROM
certificates
WHERE
path = '/Library/Keychains/System.keychain';
```
## chromeos_profile_user_info
- Platforms: chrome

View file

@ -22,6 +22,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
"github.com/fleetdm/fleet/v4/server/mock"
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/fleetdm/fleet/v4/server/ptr"
@ -234,12 +235,13 @@ func TestGetOrCreatePreassignTeam(t *testing.T) {
) (updates fleet.MDMProfilesUpdates, err error) {
return fleet.MDMProfilesUpdates{}, nil
}
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
certPEM, keyPEM, tokenBytes, err := mysql.GenerateTestABMAssets(t)
require.NoError(t, err)
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
_ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
_ sqlx.QueryerContext,
) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetABMCert: {Name: fleet.MDMAssetABMCert, Value: certPEM},
fleet.MDMAssetABMKey: {Name: fleet.MDMAssetABMKey, Value: keyPEM},

View file

@ -0,0 +1,47 @@
import { IHostCertificate } from "interfaces/certificates";
import { IGetHostCertificatesResponse } from "services/entities/hosts";
const DEFAULT_HOST_CERTIFICATE_MOCK: IHostCertificate = {
id: 1,
not_valid_after: "2021-08-19T02:02:17Z",
not_valid_before: "2021-08-19T02:02:17Z",
certificate_authority: true,
common_name: "Test Cert",
key_algorithm: "rsaEncryption",
key_strength: 2048,
key_usage: "CRL Sign, Key Cert Sign",
serial: "123",
signing_algorithm: "sha256WithRSAEncryption",
subject: {
country: "US",
organization: "Test Inc.",
organizational_unit: "Test Inc.",
common_name: "Test Biz",
},
issuer: {
country: "US",
organization: "Test Inc.",
organizational_unit: "Test Inc.",
common_name: "Test Biz",
},
};
export const createMockHostCertificate = (
overrides?: Partial<IHostCertificate>
): IHostCertificate => {
return { ...DEFAULT_HOST_CERTIFICATE_MOCK, ...overrides };
};
const DEFAULT_HOST_CERTIFICATES_RESPONSE_MOCK: IGetHostCertificatesResponse = {
certificates: [createMockHostCertificate()],
meta: {
has_next_results: false,
has_previous_results: false,
},
};
export const createMockGetHostCertificatesResponse = (
overrides?: Partial<IGetHostCertificatesResponse>
): IGetHostCertificatesResponse => {
return { ...DEFAULT_HOST_CERTIFICATES_RESPONSE_MOCK, ...overrides };
};

View file

@ -6,7 +6,7 @@ const meta: Meta<typeof DataSet> = {
title: "Components/DataSet",
component: DataSet,
args: {
title: "Data set",
title: "Data set title",
value: "This is the value",
},
};
@ -16,3 +16,9 @@ export default meta;
type Story = StoryObj<typeof DataSet>;
export const Basic: Story = {};
export const HorizontalOrientation: Story = {
args: {
orientation: "horizontal",
},
};

View file

@ -6,15 +6,26 @@ const baseClass = "data-set";
interface IDataSetProps {
title: React.ReactNode;
value: React.ReactNode;
orientation?: "horizontal" | "vertical";
className?: string;
}
const DataSet = ({ title, value, className }: IDataSetProps) => {
const classNames = classnames(baseClass, className);
const DataSet = ({
title,
value,
orientation = "vertical",
className,
}: IDataSetProps) => {
const classNames = classnames(baseClass, className, {
[`${baseClass}__horizontal`]: orientation === "horizontal",
});
return (
<div className={classNames}>
<dt>{title}</dt>
<dt>
{title}
{orientation === "horizontal" && ":"}
</dt>
<dd>{value}</dd>
</div>
);

View file

@ -1,6 +1,11 @@
.data-set {
font-size: $x-small;
&__horizontal {
display: flex;
gap: $pad-small;
}
// ff only
@-moz-document url-prefix() {
display: flex;

View file

@ -0,0 +1,24 @@
export interface IHostCertificate {
id: number;
not_valid_after: string;
not_valid_before: string;
certificate_authority: boolean;
common_name: string;
key_algorithm: string;
key_strength: number;
key_usage: string;
serial: string;
signing_algorithm: string;
subject: {
country: string;
organization: string;
organizational_unit: string;
common_name: string;
};
issuer: {
country: string;
organization: string;
organizational_unit: string;
common_name: string;
};
}

View file

@ -128,7 +128,7 @@ export const isLinuxLike = (platform: string) => {
);
};
export const isAppleDevice = (platform: string) => {
export const isAppleDevice = (platform = "") => {
return HOST_APPLE_PLATFORMS.includes(
platform as typeof HOST_APPLE_PLATFORMS[number]
);

View file

@ -5,7 +5,11 @@ import { IDeviceUserResponse, IHostDevice } from "interfaces/host";
import createMockHost from "__mocks__/hostMock";
import mockServer from "test/mock-server";
import { createCustomRenderer } from "test/test-utils";
import { customDeviceHandler } from "test/handlers/device-handler";
import {
customDeviceHandler,
defaultDeviceCertificatesHandler,
defaultDeviceHandler,
} from "test/handlers/device-handler";
import DeviceUserPage from "./DeviceUserPage";
const mockRouter = {
@ -34,12 +38,14 @@ const mockLocation = {
describe("Device User Page", () => {
it("hides the software tab if the device has no software", async () => {
mockServer.use(defaultDeviceHandler);
mockServer.use(defaultDeviceCertificatesHandler);
const render = createCustomRenderer({
withBackendMock: true,
});
// TODO: fix return type from render
const { user } = render(
render(
<DeviceUserPage
router={mockRouter}
params={{ device_auth_token: "testToken" }}
@ -51,14 +57,61 @@ describe("Device User Page", () => {
await screen.findByText("About");
expect(screen.queryByText(/Software/)).not.toBeInTheDocument();
});
// TODO: Fix this to the new copy
// expect(screen.getByText("No software detected")).toBeInTheDocument();
it("hides the certificates card if the device has no certificates", async () => {
mockServer.use(defaultDeviceHandler);
mockServer.use(defaultDeviceCertificatesHandler);
const render = createCustomRenderer({
withBackendMock: true,
});
render(
<DeviceUserPage
router={mockRouter}
params={{ device_auth_token: "testToken" }}
location={mockLocation}
/>
);
// waiting for the device data to render
await screen.findByText("About");
expect(screen.queryByText(/Certificates/)).not.toBeInTheDocument();
});
it("hides the certificates card if the device is not an apple device (mac, iphone, ipad)", async () => {
const host = createMockHost() as IHostDevice;
host.mdm.enrollment_status = "On (manual)";
host.platform = "windows";
host.dep_assigned_to_fleet = false;
mockServer.use(customDeviceHandler({ host }));
mockServer.use(defaultDeviceCertificatesHandler);
const render = createCustomRenderer({
withBackendMock: true,
});
render(
<DeviceUserPage
router={mockRouter}
params={{ device_auth_token: "testToken" }}
location={mockLocation}
/>
);
// waiting for the device data to render
await screen.findByText("About");
expect(screen.queryByText(/Certificates/)).not.toBeInTheDocument();
});
describe("MDM enrollment", () => {
const setupTest = async (overrides: Partial<IDeviceUserResponse>) => {
mockServer.use(customDeviceHandler(overrides));
mockServer.use(defaultDeviceCertificatesHandler);
const render = createCustomRenderer({
withBackendMock: true,

View file

@ -6,7 +6,9 @@ import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
import { pick, findIndex } from "lodash";
import { NotificationContext } from "context/notification";
import deviceUserAPI from "services/entities/device_user";
import deviceUserAPI, {
IGetDeviceCertificatesResponse,
} from "services/entities/device_user";
import diskEncryptionAPI from "services/entities/disk_encryption";
import {
IDeviceMappingResponse,
@ -17,6 +19,8 @@ import {
import { IHostPolicy } from "interfaces/policy";
import { IDeviceGlobalConfig } from "interfaces/config";
import { IHostSoftware } from "interfaces/software";
import { IHostCertificate } from "interfaces/certificates";
import { isAppleDevice } from "interfaces/platform";
import DeviceUserError from "components/DeviceUserError";
// @ts-ignore
@ -31,6 +35,7 @@ import FlashMessage from "components/FlashMessage";
import { normalizeEmptyValues } from "utilities/helpers";
import PATHS from "router/paths";
import {
DEFAULT_USE_QUERY_OPTIONS,
DOCUMENT_TITLE_SUFFIX,
HOST_ABOUT_DATA,
HOST_SUMMARY_DATA,
@ -56,6 +61,8 @@ import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware";
import SelfService from "../cards/Software/SelfService";
import SoftwareDetailsModal from "../cards/Software/SoftwareDetailsModal";
import DeviceUserBanners from "./components/DeviceUserBanners";
import CertificateDetailsModal from "../modals/CertificateDetailsModal";
import CertificatesCard from "../cards/Certificates";
const baseClass = "device-user";
@ -71,6 +78,9 @@ const FREE_TAB_PATHS = [
PATHS.DEVICE_USER_DETAILS_SOFTWARE,
] as const;
const DEFAULT_CERTIFICATES_PAGE_SIZE = 10;
const DEFAULT_CERTIFICATES_PAGE = 0;
interface IDeviceUserPageProps {
location: {
pathname: string;
@ -119,6 +129,15 @@ const DeviceUserPage = ({
setSelectedSoftwareDetails,
] = useState<IHostSoftware | null>(null);
// certificates states
const [
selectedCertificate,
setSelectedCertificate,
] = useState<IHostCertificate | null>(null);
const [certificatePage, setCertificatePage] = useState(
DEFAULT_CERTIFICATES_PAGE
);
const { data: deviceMapping, refetch: refetchDeviceMapping } = useQuery(
["deviceMapping", deviceAuthToken],
() =>
@ -146,8 +165,39 @@ const DeviceUserPage = ({
}
);
const {
data: deviceCertificates,
isLoading: isLoadingDeviceCertificates,
isError: isErrorDeviceCertificates,
refetch: refetchDeviceCertificates,
} = useQuery<
IGetDeviceCertificatesResponse,
Error,
IGetDeviceCertificatesResponse,
Array<{ scope: string; token: string; page: number; perPage: number }>
>(
[
{
scope: "device-certificates",
token: deviceAuthToken,
page: certificatePage,
perPage: DEFAULT_CERTIFICATES_PAGE_SIZE,
},
],
({ queryKey: [{ token, page, perPage }] }) =>
deviceUserAPI.getDeviceCertificates(token, page, perPage),
{
...DEFAULT_USE_QUERY_OPTIONS,
// FIXME: is it worth disabling for unsupported platforms? we'd have to workaround the a
// catch-22 where we need to know the platform to know if it's supported but we also need to
// be able to include the cert refetch in the hosts query hook.
enabled: !!deviceUserAPI,
}
);
const refetchExtensions = () => {
deviceMapping !== null && refetchDeviceMapping();
deviceCertificates && refetchDeviceCertificates();
};
const isRefetching = ({
@ -242,6 +292,7 @@ const DeviceUserPage = ({
self_service: hasSelfService = false,
} = dupResponse || {};
const isPremiumTier = license?.tier === "premium";
const isAppleHost = isAppleDevice(host?.platform);
const summaryData = normalizeEmptyValues(pick(host, HOST_SUMMARY_DATA));
@ -328,6 +379,10 @@ const DeviceUserPage = ({
}
};
const onSelectCertificate = (certificate: IHostCertificate) => {
setSelectedCertificate(certificate);
};
const renderDeviceUserPage = () => {
const failingPoliciesCount = host?.issues?.failing_policies_count || 0;
@ -355,7 +410,7 @@ const DeviceUserPage = ({
return (
<div className="core-wrapper">
{!host || isLoadingHost ? (
{!host || isLoadingHost || isLoadingDeviceCertificates ? (
<Spinner />
) : (
<div className={`${baseClass} main-content`}>
@ -418,12 +473,27 @@ const DeviceUserPage = ({
</Tab>
)}
</TabList>
<TabPanel>
<TabPanel className={`${baseClass}__details-panel`}>
<AboutCard
aboutData={aboutData}
deviceMapping={deviceMapping}
munki={deviceMacAdminsData?.munki}
/>
{isAppleHost && !!deviceCertificates?.certificates.length && (
<CertificatesCard
isMyDevicePage
data={deviceCertificates}
isError={isErrorDeviceCertificates}
page={certificatePage}
pageSize={DEFAULT_CERTIFICATES_PAGE_SIZE}
hostPlatform={host.platform}
onSelectCertificate={onSelectCertificate}
onNextPage={() => setCertificatePage(certificatePage + 1)}
onPreviousPage={() =>
setCertificatePage(certificatePage - 1)
}
/>
)}
</TabPanel>
{isPremiumTier && isSoftwareEnabled && hasSelfService && (
<TabPanel>
@ -524,6 +594,12 @@ const DeviceUserPage = ({
hideInstallDetails
/>
)}
{selectedCertificate && (
<CertificateDetailsModal
certificate={selectedCertificate}
onExit={() => setSelectedCertificate(null)}
/>
)}
</div>
);
};

View file

@ -27,6 +27,12 @@
transform: scale(0.5);
}
&__details-panel {
display: grid;
grid-template-columns: 1fr;
gap: $pad-large;
}
&__error {
display: flex;
flex-direction: column;

View file

@ -15,7 +15,7 @@ import activitiesAPI, {
IHostPastActivitiesResponse,
IHostUpcomingActivitiesResponse,
} from "services/entities/activities";
import hostAPI from "services/entities/hosts";
import hostAPI, { IGetHostCertificatesResponse } from "services/entities/hosts";
import teamAPI, { ILoadTeamsResponse } from "services/entities/teams";
import {
@ -32,6 +32,7 @@ import { IQueryStats } from "interfaces/query_stats";
import { IHostSoftware } from "interfaces/software";
import { ITeam } from "interfaces/team";
import { IHostUpcomingActivity } from "interfaces/activity";
import { IHostCertificate } from "interfaces/certificates";
import { normalizeEmptyValues, wrapFleetHelper } from "utilities/helpers";
import permissions from "utilities/permissions";
@ -40,6 +41,7 @@ import {
HOST_SUMMARY_DATA,
HOST_ABOUT_DATA,
HOST_OSQUERY_DATA,
DEFAULT_USE_QUERY_OPTIONS,
} from "utilities/constants";
import { isAndroid, isIPadOrIPhone } from "interfaces/platform";
@ -73,10 +75,12 @@ import PoliciesCard from "../cards/Policies";
import QueriesCard from "../cards/Queries";
import PacksCard from "../cards/Packs";
import PolicyDetailsModal from "../cards/Policies/HostPoliciesTable/PolicyDetailsModal";
import UnenrollMdmModal from "./modals/UnenrollMdmModal";
import CertificatesCard from "../cards/Certificates";
import TransferHostModal from "../../components/TransferHostModal";
import DeleteHostModal from "../../components/DeleteHostModal";
import UnenrollMdmModal from "./modals/UnenrollMdmModal";
import DiskEncryptionKeyModal from "./modals/DiskEncryptionKeyModal";
import HostActionsDropdown from "./HostActionsDropdown/HostActionsDropdown";
import OSSettingsModal from "../OSSettingsModal";
@ -95,6 +99,7 @@ import SoftwareDetailsModal from "../cards/Software/SoftwareDetailsModal";
import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware";
import { getErrorMessage } from "./helpers";
import CancelActivityModal from "./modals/CancelActivityModal";
import CertificateDetailsModal from "../modals/CertificateDetailsModal";
const baseClass = "host-details";
@ -129,6 +134,8 @@ interface IHostDetailsSubNavItem {
}
const DEFAULT_ACTIVITY_PAGE_SIZE = 8;
const DEFAULT_CERTIFICATES_PAGE_SIZE = 10;
const DEFAULT_CERTIFICATES_PAGE = 0;
const HostDetailsPage = ({
router,
@ -165,6 +172,7 @@ const HostDetailsPage = ({
const [showLockHostModal, setShowLockHostModal] = useState(false);
const [showUnlockHostModal, setShowUnlockHostModal] = useState(false);
const [showWipeModal, setShowWipeModal] = useState(false);
// Used in activities to show run script details modal
const [scriptExecutionId, setScriptExecutiontId] = useState("");
const [selectedPolicy, setSelectedPolicy] = useState<IHostPolicy | null>(
@ -209,6 +217,15 @@ const HostDetailsPage = ({
>("past");
const [activityPage, setActivityPage] = useState(0);
// certificates states
const [
selectedCertificate,
setSelectedCertificate,
] = useState<IHostCertificate | null>(null);
const [certificatePage, setCertificatePage] = useState(
DEFAULT_CERTIFICATES_PAGE
);
const { data: teams } = useQuery<ILoadTeamsResponse, Error, ITeam[]>(
"teams",
() => teamAPI.loadAll(),
@ -264,10 +281,41 @@ const HostDetailsPage = ({
}
);
const {
data: hostCertificates,
isLoading: isLoadingHostCertificates,
isError: isErrorHostCertificates,
refetch: refetchHostCertificates,
} = useQuery<
IGetHostCertificatesResponse,
Error,
IGetHostCertificatesResponse,
Array<{ scope: string; hostId: number; page: number; perPage: number }>
>(
[
{
scope: "host-certificates",
hostId: hostIdFromURL,
page: certificatePage,
perPage: DEFAULT_CERTIFICATES_PAGE_SIZE,
},
],
({ queryKey: [{ hostId, page, perPage }] }) =>
hostAPI.getHostCertificates(hostId, page, perPage),
{
...DEFAULT_USE_QUERY_OPTIONS,
// FIXME: is it worth disabling for unsupported platforms? we'd have to workaround the a
// catch-22 where we need to know the platform to know if it's supported but we also need to
// be able to include the cert refetch in the hosts query hook.
enabled: !!hostIdFromURL,
}
);
const refetchExtensions = () => {
deviceMapping !== null && refetchDeviceMapping();
macadmins !== null && refetchMacadmins();
mdm?.enrollment_status !== null && refetchMdm();
hostCertificates && refetchHostCertificates();
};
const {
@ -710,6 +758,10 @@ const HostDetailsPage = ({
setSelectedCancelActivity(activity);
};
const onSelectCertificate = (certificate: IHostCertificate) => {
setSelectedCertificate(certificate);
};
const renderActionDropdown = () => {
if (!host) {
return null;
@ -734,7 +786,8 @@ const HostDetailsPage = ({
!host ||
isLoadingHost ||
pastActivitiesIsLoading ||
upcomingActivitiesIsLoading
upcomingActivitiesIsLoading ||
isLoadingHostCertificates
) {
return <Spinner />;
}
@ -799,11 +852,12 @@ const HostDetailsPage = ({
name: host?.mdm.macos_setup?.bootstrap_package_name,
};
const isIosOrIpadosHost =
host.platform === "ios" || host.platform === "ipados";
const isDarwinHost = host.platform === "darwin";
const isIosOrIpadosHost = isIPadOrIPhone(host.platform);
const detailsPanelClass = classNames(`${baseClass}__details-panel`, {
[`${baseClass}__details-panel--ios-grid`]: isIosOrIpadosHost,
[`${baseClass}__details-panel--macos-grid`]: isDarwinHost,
});
return (
@ -909,6 +963,21 @@ const HostDetailsPage = ({
hostUsersEnabled={featuresConfig?.enable_host_users}
/>
)}
{(isIosOrIpadosHost || isDarwinHost) &&
!!hostCertificates?.certificates.length && (
<CertificatesCard
data={hostCertificates}
hostPlatform={host.platform}
onSelectCertificate={onSelectCertificate}
isError={isErrorHostCertificates}
page={certificatePage}
pageSize={DEFAULT_CERTIFICATES_PAGE_SIZE}
onNextPage={() => setCertificatePage(certificatePage + 1)}
onPreviousPage={() =>
setCertificatePage(certificatePage - 1)
}
/>
)}
</TabPanel>
<TabPanel>
<SoftwareCard
@ -925,7 +994,7 @@ const HostDetailsPage = ({
hostTeamId={host.team_id || 0}
hostMDMEnrolled={host.mdm.connected_to_fleet}
/>
{host?.platform === "darwin" && macadmins?.munki?.version && (
{isDarwinHost && macadmins?.munki?.version && (
<MunkiIssuesCard
isLoading={isLoadingHost}
munkiIssues={macadmins.munki_issues}
@ -1095,6 +1164,12 @@ const HostDetailsPage = ({
onCancel={() => setSelectedCancelActivity(null)}
/>
)}
{selectedCertificate && (
<CertificateDetailsModal
certificate={selectedCertificate}
onExit={() => setSelectedCertificate(null)}
/>
)}
</>
</MainContent>
);

View file

@ -6,6 +6,7 @@
}
@media screen and (min-width: $break-md) {
// default grid to show for non macos, ios, or ipados hosts.
&__details-panel.react-tabs__tab-panel--selected {
// Must be selected to show grid
grid-template-columns: 1fr 1fr;
@ -14,18 +15,29 @@
"activity agent-options"
"activity labels"
"users users";
grid-auto-flow: column;
}
// No agent options card for i(Pad)OS, so extend Labels card vertically
// No agent options card for i(Pad)OS, so extend Labels card vertically.
// We also add the certs card to the grid layout on mac hosts
&__details-panel--ios-grid.react-tabs__tab-panel--selected {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"about about"
"activity labels";
grid-auto-flow: column;
"activity labels"
"certs certs";
}
// We add the certs card to the grid layout on mac hosts
&__details-panel--macos-grid.react-tabs__tab-panel--selected {
grid-template-areas:
"about about"
"activity agent-options"
"activity labels"
"users users"
"certs certs";
}
.about-card {
grid-area: about;
}
@ -45,6 +57,10 @@
.users-card {
grid-area: users;
}
.certificates-card {
grid-area: certs;
}
}
.about-card,

View file

@ -217,10 +217,10 @@ const About = ({
<Card
borderRadiusSize="xxlarge"
includeShadow
paddingSize="large"
paddingSize="xxlarge"
className={baseClass}
>
<p className="card__header">About</p>
<h2>About</h2>
<div className="info-flex">
<DataSet
title="Added to Fleet"

View file

@ -1,4 +1,8 @@
.about-card {
h2 {
font-size: $medium;
margin: 0 0 $pad-large;
}
.truncated-tooltip {
.about-card__device-mapping__source {
@ -52,7 +56,7 @@
.component__tooltip-wrapper__element {
max-width: 350px;
}
@media (min-width: $break-md) {
max-height: 250px;

View file

@ -2,7 +2,7 @@
position: relative;
h2 {
font-size: 20px;
font-size: $medium;
margin: 0 0 $pad-large;
}

View file

@ -0,0 +1,66 @@
import React from "react";
import { IHostCertificate } from "interfaces/certificates";
import { HostPlatform } from "interfaces/platform";
import { IGetHostCertificatesResponse } from "services/entities/hosts";
import Card from "components/Card";
import DataError from "components/DataError";
import CertificatesTable from "./CertificatesTable";
const baseClass = "certificates-card";
interface ICertificatesProps {
data: IGetHostCertificatesResponse;
hostPlatform: HostPlatform;
page: number;
pageSize: number;
isError: boolean;
isMyDevicePage?: boolean;
onSelectCertificate: (certificate: IHostCertificate) => void;
onNextPage: () => void;
onPreviousPage: () => void;
}
const CertificatesCard = ({
data,
hostPlatform,
isError,
page,
pageSize,
isMyDevicePage = false,
onSelectCertificate,
onNextPage,
onPreviousPage,
}: ICertificatesProps) => {
const renderContent = () => {
if (isError) return <DataError />;
return (
<CertificatesTable
data={data}
showHelpText={!isMyDevicePage && hostPlatform === "darwin"}
page={page}
pageSize={pageSize}
onSelectCertificate={onSelectCertificate}
onNextPage={onNextPage}
onPreviousPage={onPreviousPage}
/>
);
};
return (
<Card
className={baseClass}
borderRadiusSize="xxlarge"
includeShadow
paddingSize="xxlarge"
>
<h2>Certificates</h2>
{renderContent()}
</Card>
);
};
export default CertificatesCard;

View file

@ -0,0 +1,89 @@
import React, { useCallback } from "react";
import { IHostCertificate } from "interfaces/certificates";
import { IGetHostCertificatesResponse } from "services/entities/hosts";
import TableContainer from "components/TableContainer";
import CustomLink from "components/CustomLink";
import TableCount from "components/TableContainer/TableCount";
import { ITableQueryData } from "components/TableContainer/TableContainer";
import generateTableConfig from "./CertificatesTableConfig";
const baseClass = "certificates-table";
interface ICertificatesTableProps {
data: IGetHostCertificatesResponse;
showHelpText: boolean;
page: number;
pageSize: number;
onSelectCertificate: (certificate: IHostCertificate) => void;
onNextPage: () => void;
onPreviousPage: () => void;
}
const CertificatesTable = ({
data,
showHelpText,
page,
pageSize,
onSelectCertificate,
onNextPage,
onPreviousPage,
}: ICertificatesTableProps) => {
const tableConfig = generateTableConfig();
const onClickTableRow = (row: any) => {
onSelectCertificate(row.original);
};
const onQueryChange = useCallback(
async (newTableQuery: ITableQueryData) => {
console.log(newTableQuery);
if (page === newTableQuery.pageIndex) return;
if (newTableQuery.pageIndex > page) {
onNextPage();
} else {
onPreviousPage();
}
},
[onNextPage, onPreviousPage, page]
);
const helpText = showHelpText ? (
<p>
Showing certificates in the system keychain. To get all certificates, you
can query the certificates table.{" "}
<CustomLink
text="Learn more"
url="https://fleetdm.com/learn-more-about/certificates-query"
newTab
/>
</p>
) : null;
return (
<TableContainer
className={baseClass}
columnConfigs={tableConfig}
data={data.certificates}
emptyComponent={() => null}
isAllPagesSelected={false}
showMarkAllPages={false}
isLoading={false}
disableMultiRowSelect
onSelectSingleRow={onClickTableRow}
renderTableHelpText={() => helpText}
renderCount={() => (
<TableCount name="certificates" count={data.certificates.length} />
)}
pageSize={pageSize}
defaultPageIndex={page}
onQueryChange={onQueryChange}
disableNextPage={data?.meta.has_next_results === false}
/>
);
};
export default CertificatesTable;

View file

@ -0,0 +1,67 @@
import React from "react";
import { Column } from "react-table";
import { IHostCertificate } from "interfaces/certificates";
import { monthDayYearFormat } from "utilities/date_format";
import { hasExpired, willExpireWithinXDays } from "utilities/helpers";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell";
import TextCell from "components/TableContainer/DataTable/TextCell";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import StatusIndicator from "components/StatusIndicator";
import { IIndicatorValue } from "components/StatusIndicator/StatusIndicator";
type IHostCertificatesTableConfig = Column<IHostCertificate>;
const generateTableConfig = (): IHostCertificatesTableConfig[] => {
return [
{
accessor: "common_name",
Header: (cellProps) => (
<HeaderCell value="Name" isSortedDesc={cellProps.column.isSortedDesc} />
),
Cell: (cellProps) => <TextCell value={cellProps.cell.value} />,
},
{
accessor: "not_valid_after",
Header: (cellProps) => (
<HeaderCell
value="Expires"
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
Cell: (cellProps) => {
let status: IIndicatorValue = "success";
if (hasExpired(cellProps.value)) {
status = "error";
} else if (willExpireWithinXDays(cellProps.value, 30)) {
status = "warning";
}
return (
<StatusIndicator
value={monthDayYearFormat(cellProps.value)}
indicator={status}
/>
);
},
},
{
Header: "",
id: "view-all-hosts",
disableSortBy: true,
Cell: () => {
return (
<ViewAllHostsLink
className="view-cert-details"
noLink
rowHover
excludeChevron
customText="View details"
/>
);
},
},
];
};
export default generateTableConfig;

View file

@ -0,0 +1,3 @@
.certificates-table {
}

View file

@ -0,0 +1 @@
export { default } from "./CertificatesTable";

View file

@ -0,0 +1,7 @@
.certificates-card {
h2 {
font-size: $medium;
margin: 0 0 $pad-large;
}
}

View file

@ -0,0 +1 @@
export { default } from "./Certificates";

View file

@ -37,10 +37,10 @@ const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => {
<Card
borderRadiusSize="xxlarge"
includeShadow
largePadding
paddingSize="xxlarge"
className={classNames}
>
<p className="card__header">Labels</p>
<h2>Labels</h2>
{labels.length === 0 ? (
<p className="info-flex__item">
No labels are associated with this host.

View file

@ -0,0 +1,7 @@
.labels-card {
h2 {
font-size: $medium;
margin: 0 0 $pad-large;
}
}

View file

@ -63,11 +63,11 @@ const Users = ({
<Card
borderRadiusSize="xxlarge"
includeShadow
largePadding
paddingSize="xxlarge"
className={baseClass}
>
<>
<p className="card__header">Users</p>
<h2 className="card__header">Users</h2>
{users?.length ? (
<TableContainer
columnConfigs={tableHeaders}

View file

@ -1,4 +1,9 @@
.users-card {
h2 {
font-size: $medium;
margin: 0 0 $pad-large;
}
.data-table-block {
.data-table__table {
thead {

View file

@ -0,0 +1,227 @@
import React from "react";
import { IHostCertificate } from "interfaces/certificates";
import Modal from "components/Modal";
import DataSet from "components/DataSet";
import Button from "components/buttons/Button";
import { monthDayYearFormat } from "utilities/date_format";
const baseClass = "certificate-details-modal";
interface ICertificateDetailsModalProps {
certificate: IHostCertificate;
onExit: () => void;
}
const CertificateDetailsModal = ({
certificate,
onExit,
}: ICertificateDetailsModalProps) => {
// Destructure the certificate object so we can check for presence of values
const {
subject: {
country: subjectCountry,
organization: subjectOrganization,
organizational_unit: subjectOrganizationalUnit,
common_name: subjectCommonName,
},
issuer: {
country: issuerCountry,
organization: issuerOrganization,
organizational_unit: issuerOrganizationalUnit,
common_name: issuerCommonName,
},
not_valid_before,
not_valid_after,
key_algorithm,
key_strength,
key_usage,
serial,
certificate_authority,
signing_algorithm,
} = certificate;
const showSubjectSection = Boolean(
subjectCountry ||
subjectOrganization ||
subjectOrganizationalUnit ||
subjectCommonName
);
const showIssuerNameSection = Boolean(
issuerCommonName ||
issuerCountry ||
issuerOrganization ||
issuerOrganizationalUnit
);
const showValidityPeriodSection = Boolean(
not_valid_before || not_valid_after
);
const showKeyInfoSection = Boolean(
key_algorithm || key_strength || key_usage || serial
);
const showSignatureSection = Boolean(signing_algorithm);
return (
<Modal className={baseClass} title="Certificate details" onExit={onExit}>
<>
<div className={`${baseClass}__content`}>
{showSubjectSection && (
<div className={`${baseClass}__section`}>
<h3>Subject Name</h3>
<dl>
{subjectCountry && (
<DataSet
title="Country or region"
value={subjectCountry}
orientation="horizontal"
/>
)}
{subjectOrganization && (
<DataSet
title="Organization"
value={subjectOrganization}
orientation="horizontal"
/>
)}
{subjectOrganizationalUnit && (
<DataSet
title="Organizational unit"
value={subjectOrganizationalUnit}
orientation="horizontal"
/>
)}
{subjectCommonName && (
<DataSet
title="Common name"
value={subjectCommonName}
orientation="horizontal"
/>
)}
</dl>
</div>
)}
{showIssuerNameSection && (
<div className={`${baseClass}__section`}>
<h3>Issuer name</h3>
<dl>
{issuerCountry && (
<DataSet
title="Country or region"
value={issuerCountry}
orientation="horizontal"
/>
)}
{issuerOrganization && (
<DataSet
title="Organization"
value={issuerOrganization}
orientation="horizontal"
/>
)}
{issuerOrganizationalUnit && (
<DataSet
title="Organizational unit"
value={issuerOrganizationalUnit}
orientation="horizontal"
/>
)}
{issuerCommonName && (
<DataSet
title="Common name"
value={issuerCommonName}
orientation="horizontal"
/>
)}
</dl>
</div>
)}
{showValidityPeriodSection && (
<div className={`${baseClass}__section`}>
<h3>Validity period</h3>
<dl>
{not_valid_before && (
<DataSet
title="Not valid before"
value={monthDayYearFormat(not_valid_before)}
orientation="horizontal"
/>
)}
{not_valid_after && (
<DataSet
title="Not valid after"
value={monthDayYearFormat(not_valid_after)}
orientation="horizontal"
/>
)}
</dl>
</div>
)}
{showKeyInfoSection && (
<div className={`${baseClass}__section`}>
<h3>Key info</h3>
<dl>
{key_algorithm && (
<DataSet
title="Algorithm"
value={key_algorithm}
orientation="horizontal"
/>
)}
{key_strength && (
<DataSet
title="Key size"
value={key_strength}
orientation="horizontal"
/>
)}
{key_usage && (
<DataSet
title="Key usage"
value={key_usage}
orientation="horizontal"
/>
)}
{serial && (
<DataSet
title="Serial number"
value={serial}
orientation="horizontal"
/>
)}
</dl>
</div>
)}
{/* will always show this section */}
<div className={`${baseClass}__section`}>
<h3>Basic constraints</h3>
<dl>
<DataSet
title="Certificate authority"
value={certificate_authority ? "Yes" : "No"}
orientation="horizontal"
/>
</dl>
</div>
{showSignatureSection && (
<div className={`${baseClass}__section`}>
<h3>Signature</h3>
<dl>
<DataSet
title="Algorithm"
value={signing_algorithm}
orientation="horizontal"
/>
</dl>
</div>
)}
</div>
<div className="modal-cta-wrap">
<Button onClick={onExit}>Done</Button>
</div>
</>
</Modal>
);
};
export default CertificateDetailsModal;

View file

@ -0,0 +1,32 @@
.certificate-details-modal {
&__content {
display: flex;
flex-direction: column;
gap: $pad-xlarge;
}
h3 {
margin: 0;
font-size: $small;
}
&__section {
display: flex;
gap: $pad-small;
flex-direction: column;
padding-top: $pad-xlarge;
border-top: 1px solid $ui-fleet-black-10;
&:first-child {
padding-top: 0;
border-top: none;
}
dl {
display: flex;
flex-direction: column;
gap: $pad-xsmall;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./CertificateDetailsModal";

View file

@ -1,8 +1,11 @@
import { IDeviceUserResponse } from "interfaces/host";
import { IDeviceSoftware } from "interfaces/software";
import { IHostCertificate } from "interfaces/certificates";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
import { createMockGetHostCertificatesResponse } from "__mocks__/certificatesMock";
import { IHostSoftwareQueryParams } from "./hosts";
export type ILoadHostDetailsExtension = "device_mapping" | "macadmins";
@ -27,6 +30,14 @@ interface IGetDeviceDetailsRequest {
exclude_software?: boolean;
}
export interface IGetDeviceCertificatesResponse {
certificates: IHostCertificate[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export default {
loadHostDetails: ({
token,
@ -74,4 +85,20 @@ export default {
return sendRequest("POST", path);
},
getDeviceCertificates: (
deviceToken: string,
page = 0,
perPage = 10
): Promise<IGetDeviceCertificatesResponse> => {
const { DEVICE_CERTIFICATES } = endpoints;
const path = `${DEVICE_CERTIFICATES(
deviceToken
)}?${buildQueryStringFromParams({
page,
per_page: perPage,
})}`;
return sendRequest("GET", path);
},
};

View file

@ -23,6 +23,11 @@ import {
} from "interfaces/mdm";
import { IMunkiIssuesAggregate } from "interfaces/macadmins";
import { PlatformValueOptions, PolicyResponse } from "utilities/constants";
import { IHostCertificate } from "interfaces/certificates";
import {
createMockGetHostCertificatesResponse,
createMockHostCertificate,
} from "__mocks__/certificatesMock";
export interface ISortOption {
key: string;
@ -171,6 +176,14 @@ export interface IHostSoftwareQueryKey extends IHostSoftwareQueryParams {
softwareUpdatedAt?: string;
}
export interface IGetHostCertificatesResponse {
certificates: IHostCertificate[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export type ILoadHostDetailsExtension = "device_mapping" | "macadmins";
const LABEL_PREFIX = "labels/";
@ -579,6 +592,7 @@ export default {
HOST_SOFTWARE_PACKAGE_INSTALL(hostId, softwareId)
);
},
uninstallHostSoftwarePackage: (hostId: number, softwareId: number) => {
const { HOST_SOFTWARE_PACKAGE_UNINSTALL } = endpoints;
return sendRequest(
@ -586,4 +600,18 @@ export default {
HOST_SOFTWARE_PACKAGE_UNINSTALL(hostId, softwareId)
);
},
getHostCertificates: (
hostId: number,
page = 0,
perPage = 10
): Promise<IGetHostCertificatesResponse> => {
const { HOST_CERTIFICATES } = endpoints;
const path = `${HOST_CERTIFICATES(hostId)}?${buildQueryStringFromParams({
page,
per_page: perPage,
})}`;
return sendRequest("GET", path);
},
};

View file

@ -6,9 +6,11 @@ import createMockDeviceUser, {
import createMockHost from "__mocks__/hostMock";
import createMockLicense from "__mocks__/licenseMock";
import createMockMacAdmins from "__mocks__/macAdminsMock";
import { createMockHostCertificate } from "__mocks__/certificatesMock";
import { baseUrl } from "test/test-utils";
import { IDeviceUserResponse } from "interfaces/host";
import { IGetDeviceSoftwareResponse } from "services/entities/device_user";
import { IGetHostCertificatesResponse } from "services/entities/hosts";
export const defaultDeviceHandler = http.get(baseUrl("/device/:token"), () => {
return HttpResponse.json({
@ -63,3 +65,16 @@ export const customDeviceSoftwareHandler = (
http.get(baseUrl("/device/:token/software"), () => {
return HttpResponse.json(createMockDeviceSoftwareResponse(overrides));
});
export const defaultDeviceCertificatesHandler = http.get(
baseUrl("/device/:token/certificates"),
() => {
return HttpResponse.json<IGetHostCertificatesResponse>({
certificates: [createMockHostCertificate()],
meta: {
has_next_results: false,
has_previous_results: false,
},
});
}
);

View file

@ -40,6 +40,9 @@ export default {
DEVICE_TRIGGER_LINUX_DISK_ENCRYPTION_KEY_ESCROW: (token: string): string => {
return `/${API_VERSION}/fleet/device/${token}/mdm/linux/trigger_escrow`;
},
DEVICE_CERTIFICATES: (token: string): string => {
return `/${API_VERSION}/fleet/device/${token}/certificates`;
},
// Host endpoints
HOST_SUMMARY: `/${API_VERSION}/fleet/host_summary`,
@ -61,6 +64,8 @@ export default {
`/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/install`,
HOST_SOFTWARE_PACKAGE_UNINSTALL: (hostId: number, softwareId: number) =>
`/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/uninstall`,
HOST_CERTIFICATES: (id: number) =>
`/${API_VERSION}/fleet/hosts/${id}/certificates`,
INVITES: `/${API_VERSION}/fleet/invites`,
INVITE_VERIFY: (token: string) => `/${API_VERSION}/fleet/invites/${token}`,

View file

@ -627,6 +627,13 @@ export const hasLicenseExpired = (expiration: string): boolean => {
return isAfter(new Date(), new Date(expiration));
};
// just a rename of hasLicenseExpired so that it can be used in other contexts.
// TODO: change hasLicenseExpired instances to hasExpired
/**
* determines if a date has expired. This will check against the current date and time.
*/
export const hasExpired = hasLicenseExpired;
/**
* determines if a date will expire within "x" number of days. If the date has
* has already expired, this function will return false.

View file

@ -22,6 +22,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
@ -739,6 +740,28 @@ func (c *TestAppleMDMClient) AcknowledgeInstalledApplicationList(udid, cmdUUID s
return c.sendAndDecodeCommandResponse(payload)
}
func (c *TestAppleMDMClient) AcknowledgeCertificateList(udid, cmdUUID string, certTemplates []*x509.Certificate) (*mdm.Command, error) {
var certList []fleet.MDMAppleCertificateListItem
for _, cert := range certTemplates {
b, _, err := mysql.GenerateTestCertBytes(cert)
if err != nil {
return nil, err
}
certList = append(certList, fleet.MDMAppleCertificateListItem{
CommonName: cert.Subject.CommonName,
Data: b,
})
}
cmd := map[string]any{
"CommandUUID": cmdUUID,
"UDID": udid,
"Status": "Acknowledged",
"CertificateList": certList,
}
return c.sendAndDecodeCommandResponse(cmd)
}
func (c *TestAppleMDMClient) GetBootstrapToken() ([]byte, error) {
payload := map[string]any{
"MessageType": "GetBootstrapToken",

View file

@ -0,0 +1,192 @@
package mysql
import (
"context"
"encoding/hex"
"fmt"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/go-kit/kit/log/level"
"github.com/jmoiron/sqlx"
)
func (ds *Datastore) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
return listHostCertsDB(ctx, ds.reader(ctx), hostID, opts)
}
func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error {
incomingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(certs))
for _, cert := range certs {
if cert.HostID != hostID {
// caller should ensure this does not happen
level.Debug(ds.logger).Log("msg", fmt.Sprintf("host certificates: host ID does not match provided certificate: %d %d", hostID, cert.HostID))
}
if _, ok := incomingBySHA1[strings.ToUpper(hex.EncodeToString(cert.SHA1Sum))]; ok {
// TODO: sha1 is broken so this could be a sign of a problem, how should we handle?
level.Info(ds.logger).Log("msg", "host certificates: host has multiple certificates with the same SHA1, only the first will be recorded", "host_id", hostID, "sha1", string(cert.SHA1Sum))
continue
}
incomingBySHA1[strings.ToUpper(hex.EncodeToString(cert.SHA1Sum))] = cert
}
// get existing certs for this host; we'll use the reader because we expect certs to change
// infrequently and they will be eventually consistent
existingCerts, _, err := listHostCertsDB(ctx, ds.reader(ctx), hostID, fleet.ListOptions{}) // requesting unpaginated results with default limit of 1 million
if err != nil {
return ctxerr.Wrap(ctx, err, "list host certificates for update")
}
existingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(existingCerts))
for _, ec := range existingCerts {
existingBySHA1[strings.ToUpper(hex.EncodeToString(ec.SHA1Sum))] = ec
}
toInsert := make([]*fleet.HostCertificateRecord, 0, len(incomingBySHA1))
// toUpdate := make([]*fleet.HostCertificateRecord, 0, len(incomingBySHA1))
for sha1, incoming := range incomingBySHA1 {
if _, ok := existingBySHA1[sha1]; ok {
// TODO: should we always update existing records? skipping updates reduces db load but
// osquery is using sha1 so we consider subtleties
level.Debug(ds.logger).Log("msg", fmt.Sprintf("host certificates: already exists: %s", sha1), "host_id", hostID) // TODO: silence this log after initial rollout period
} else {
toInsert = append(toInsert, incoming)
}
}
toDelete := make([]uint, 0, len(existingBySHA1))
for sha1, existing := range existingBySHA1 {
if _, ok := incomingBySHA1[sha1]; !ok {
toDelete = append(toDelete, existing.ID)
}
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
if err := insertHostCertsDB(ctx, tx, toInsert); err != nil {
return ctxerr.Wrap(ctx, err, "insert host certs")
}
if err := softDeleteHostCertsDB(ctx, tx, hostID, toDelete); err != nil {
return ctxerr.Wrap(ctx, err, "soft delete host certs")
}
return nil
})
}
func listHostCertsDB(ctx context.Context, tx sqlx.QueryerContext, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
stmt := `
SELECT
id,
sha1_sum,
host_id,
created_at,
deleted_at,
not_valid_before,
not_valid_after,
certificate_authority,
common_name,
key_algorithm,
key_strength,
key_usage,
serial,
signing_algorithm,
subject_country,
subject_org,
subject_org_unit,
subject_common_name,
issuer_country,
issuer_org,
issuer_org_unit,
issuer_common_name
FROM
host_certificates
WHERE
host_id = ?
AND deleted_at IS NULL`
args := []interface{}{hostID}
stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, args, &opts)
var certs []*fleet.HostCertificateRecord
if err := sqlx.SelectContext(ctx, tx, &certs, stmtPaged, args...); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "selecting host certificates")
}
var metaData *fleet.PaginationMetadata
if opts.IncludeMetadata {
metaData = &fleet.PaginationMetadata{HasPreviousResults: opts.Page > 0}
if len(certs) > int(opts.PerPage) { //nolint:gosec // dismiss G115
metaData.HasNextResults = true
certs = certs[:len(certs)-1]
}
}
return certs, metaData, nil
}
func insertHostCertsDB(ctx context.Context, tx sqlx.ExtContext, certs []*fleet.HostCertificateRecord) error {
if len(certs) == 0 {
return nil
}
stmt := `
INSERT INTO host_certificates (
host_id,
sha1_sum,
not_valid_before,
not_valid_after,
certificate_authority,
common_name,
key_algorithm,
key_strength,
key_usage,
serial,
signing_algorithm,
subject_country,
subject_org,
subject_org_unit,
subject_common_name,
issuer_country,
issuer_org,
issuer_org_unit,
issuer_common_name
) VALUES %s`
placeholders := make([]string, 0, len(certs))
args := make([]interface{}, 0, len(certs)*19)
for _, cert := range certs {
placeholders = append(placeholders, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
args = append(args,
cert.HostID, cert.SHA1Sum, cert.NotValidBefore, cert.NotValidAfter, cert.CertificateAuthority, cert.CommonName,
cert.KeyAlgorithm, cert.KeyStrength, cert.KeyUsage, cert.Serial, cert.SigningAlgorithm,
cert.SubjectCountry, cert.SubjectOrganization, cert.SubjectOrganizationalUnit, cert.SubjectCommonName,
cert.IssuerCountry, cert.IssuerOrganization, cert.IssuerOrganizationalUnit, cert.IssuerCommonName)
}
stmt = fmt.Sprintf(stmt, strings.Join(placeholders, ","))
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "inserting host certificates")
}
return nil
}
func softDeleteHostCertsDB(ctx context.Context, tx sqlx.ExtContext, hostID uint, toDelete []uint) error {
// TODO: consider whether we should hard delete certs after a certain period of time if we are seeing
// the table grow too large with soft deleted records
if len(toDelete) == 0 {
return nil
}
stmt := `UPDATE host_certificates SET deleted_at = NOW(6) WHERE host_id = ? AND id IN (?)`
stmt, args, err := sqlx.In(stmt, hostID, toDelete)
if err != nil {
return ctxerr.Wrap(ctx, err, "building soft delete query")
}
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
return ctxerr.Wrap(ctx, err, "soft deleting host certificates")
}
return nil
}

View file

@ -0,0 +1,120 @@
package mysql
import (
"context"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/stretchr/testify/require"
)
func TestHostCertificates(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"UpdateAndList", testUpdateAndListHostCertificates},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
expected1 := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "test.example.com",
Organization: []string{"Org 1"},
OrganizationalUnit: []string{"Engineering"},
},
Issuer: pkix.Name{
Country: []string{"US"},
CommonName: "issuer.test.example.com",
Organization: []string{"Issuer 1"},
},
SerialNumber: big.NewInt(1337),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
expected2 := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "another.test.example.com",
Organization: []string{"Org 2"},
OrganizationalUnit: []string{"Engineering"},
},
SerialNumber: big.NewInt(1337),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(48 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
payload := []*fleet.HostCertificateRecord{
generateTestHostCertificateRecord(t, 1, &expected1),
generateTestHostCertificateRecord(t, 1, &expected2),
}
require.NoError(t, ds.UpdateHostCertificates(context.Background(), 1, payload))
// verify that we saved the records correctly
certs, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, certs, 2)
// default ordering is by common name ascending
require.Equal(t, expected2.Subject.CommonName, certs[0].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs[0].SubjectCommonName)
require.Equal(t, expected1.Subject.CommonName, certs[1].CommonName)
require.Equal(t, expected1.Subject.CommonName, certs[1].SubjectCommonName)
// order by not_valid_after descending
certs2, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{OrderKey: "not_valid_after", OrderDirection: fleet.OrderAscending})
require.NoError(t, err)
require.Len(t, certs2, 2)
require.Equal(t, expected1.Subject.CommonName, certs2[0].CommonName)
require.Equal(t, expected1.Subject.CommonName, certs2[0].SubjectCommonName)
require.Equal(t, expected2.Subject.CommonName, certs2[1].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs2[1].SubjectCommonName)
// simulate removal of a certificate
require.NoError(t, ds.UpdateHostCertificates(context.Background(), 1, []*fleet.HostCertificateRecord{payload[1]}))
certs3, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, certs3, 1)
require.Equal(t, expected2.Subject.CommonName, certs3[0].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs3[0].SubjectCommonName)
}
func generateTestHostCertificateRecord(t *testing.T, hostID uint, template *x509.Certificate) *fleet.HostCertificateRecord {
b, _, err := GenerateTestCertBytes(template)
require.NoError(t, err)
block, _ := pem.Decode(b)
parsed, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err)
require.NotNil(t, parsed)
return fleet.NewHostCertificateRecord(hostID, parsed)
}

View file

@ -542,6 +542,7 @@ var hostRefs = []string{
"host_mdm_actions",
"host_calendar_events",
"upcoming_activities",
"host_certificates",
"android_devices",
}

View file

@ -2,6 +2,7 @@ package mysql
import (
"context"
"crypto/sha1"
"crypto/sha256"
"database/sql"
"encoding/json"
@ -7055,6 +7056,13 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, added)
// Add a host certificate
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, []*fleet.HostCertificateRecord{{
HostID: host.ID,
CommonName: "foo",
SHA1Sum: sha1.New().Sum([]byte("foo")),
}}))
// create an android device from this host
_, err = ds.writer(context.Background()).Exec(`
INSERT INTO android_devices (host_id, device_id)

View file

@ -0,0 +1,47 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up_20250226000000, Down_20250226000000)
}
func Up_20250226000000(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE host_certificates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
host_id INT UNSIGNED NOT NULL,
not_valid_after DATETIME(6) NOT NULL,
not_valid_before DATETIME(6) NOT NULL,
certificate_authority TINYINT(1) NOT NULL,
common_name VARCHAR(255) NOT NULL,
key_algorithm VARCHAR(255) NOT NULL,
key_strength INT NOT NULL,
key_usage VARCHAR(255) NOT NULL,
serial VARCHAR(255) NOT NULL,
signing_algorithm VARCHAR(255) NOT NULL,
subject_country VARCHAR(2) NOT NULL,
subject_org VARCHAR(255) NOT NULL,
subject_org_unit VARCHAR(255) NOT NULL,
subject_common_name VARCHAR(255) NOT NULL,
issuer_country VARCHAR(2) NOT NULL,
issuer_org VARCHAR(255) NOT NULL,
issuer_org_unit VARCHAR(255) NOT NULL,
issuer_common_name VARCHAR(255) NOT NULL,
sha1_sum BINARY(20) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT NOW(6),
deleted_at DATETIME(6) NULL DEFAULT NULL,
PRIMARY KEY (id),
INDEX idx_host_certs_hid_cn (host_id, common_name),
INDEX idx_host_certs_not_valid_after (host_id, not_valid_after)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;`)
return err
}
func Down_20250226000000(tx *sql.Tx) error {
return nil
}

File diff suppressed because one or more lines are too long

View file

@ -5,15 +5,13 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
"os"
"os/exec"
"path"
@ -32,6 +30,7 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/mysql/common_mysql/testing_utils"
"github.com/fleetdm/fleet/v4/server/fleet"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
"github.com/go-kit/log"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
@ -642,7 +641,7 @@ func CreateAndSetABMToken(t testing.TB, ds *Datastore, orgName string) *fleet.AB
}
func SetTestABMAssets(t testing.TB, ds *Datastore, orgName string) *fleet.ABMToken {
apnsCert, apnsKey, err := GenerateTestCertBytes()
apnsCert, apnsKey, err := GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
certPEM, keyPEM, tokenBytes, err := GenerateTestABMAssets(t)
@ -673,7 +672,7 @@ func SetTestABMAssets(t testing.TB, ds *Datastore, orgName string) *fleet.ABMTok
}
func GenerateTestABMAssets(t testing.TB) ([]byte, []byte, []byte, error) {
certPEM, keyPEM, err := GenerateTestCertBytes()
certPEM, keyPEM, err := GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
testBMToken := &nanodep_client.OAuth1Tokens{
@ -712,32 +711,17 @@ func GenerateTestABMAssets(t testing.TB) ([]byte, []byte, []byte, error) {
return certPEM, keyPEM, []byte(tokenBytes), nil
}
// TODO: move to mdmcrypto?
func GenerateTestCertBytes() ([]byte, []byte, error) {
func GenerateTestCertBytes(template *x509.Certificate) ([]byte, []byte, error) {
if template == nil {
return nil, nil, errors.New("template is nil")
}
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Test Org"},
ExtraNames: []pkix.AttributeTypeAndValue{
{
Type: asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1},
Value: "com.apple.mgmt.Example",
},
},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
certBytes, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, err
}

View file

@ -365,6 +365,9 @@ type Datastore interface {
// IsHostConnectedToFleetMDM verifies if the host has an active Fleet MDM enrollment with this server
IsHostConnectedToFleetMDM(ctx context.Context, host *Host) (bool, error)
ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificateRecord, *PaginationMetadata, error)
UpdateHostCertificates(ctx context.Context, hostID uint, certs []*HostCertificateRecord) error
// AreHostsConnectedToFleetMDM checks each host MDM enrollment with
// this server and returns a map indexed by the host uuid and a boolean
// indicating if the enrollment is active.

View file

@ -0,0 +1,226 @@
package fleet
import (
"crypto/sha1" // nolint:gosec // used for compatibility with existing osquery certificates table schema
"crypto/x509"
"errors"
"strings"
"time"
)
// HostCertificateRecord is the database model for a host certificate.
type HostCertificateRecord struct {
ID uint `json:"-" db:"id"`
HostID uint `json:"-" db:"host_id"`
// SHA1Sum is a SHA-1 hash of the DER encoded certificate.
SHA1Sum []byte `json:"-" db:"sha1_sum"`
// CreatedAt is the time the certificate was recorded by Fleet (i.e. certificate initially
// reported to Fleet).
CreatedAt time.Time `json:"-" db:"created_at"`
// DeletedAt is the time the certificate was soft deleted by Fleet (i.e. previously reported to
// Fleet certificate is subsequently not reported).
DeletedAt *time.Time `json:"-" db:"deleted_at"`
// The following fields are extracted from the certificate.
NotValidAfter time.Time `json:"-" db:"not_valid_after"`
NotValidBefore time.Time `json:"-" db:"not_valid_before"`
CertificateAuthority bool `json:"-" db:"certificate_authority"`
CommonName string `json:"-" db:"common_name"`
KeyAlgorithm string `json:"-" db:"key_algorithm"`
KeyStrength int `json:"-" db:"key_strength"`
KeyUsage string `json:"-" db:"key_usage"`
Serial string `json:"-" db:"serial"`
SigningAlgorithm string `json:"-" db:"signing_algorithm"`
SubjectCountry string `json:"-" db:"subject_country"`
SubjectOrganization string `json:"-" db:"subject_org"`
SubjectOrganizationalUnit string `json:"-" db:"subject_org_unit"`
SubjectCommonName string `json:"-" db:"subject_common_name"`
IssuerCountry string `json:"-" db:"issuer_country"`
IssuerOrganization string `json:"-" db:"issuer_org"`
IssuerOrganizationalUnit string `json:"-" db:"issuer_org_unit"`
IssuerCommonName string `json:"-" db:"issuer_common_name"`
}
func NewHostCertificateRecord(
hostID uint,
cert *x509.Certificate,
) *HostCertificateRecord {
hash := sha1.Sum(cert.Raw) // nolint:gosec
return &HostCertificateRecord{
HostID: hostID,
SHA1Sum: hash[:], // nolint:gosec
NotValidAfter: cert.NotAfter,
NotValidBefore: cert.NotBefore,
CertificateAuthority: cert.IsCA,
// TODO: we need to define methodology for determining common name analogous to osquery,
// which seems to preferentially use Subject.CommonName for this value:
// https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L253
CommonName: cert.Subject.CommonName,
KeyAlgorithm: cert.PublicKeyAlgorithm.String(),
// TODO: we need to define methodology for determining key strength analogous to osquery,
// which describes this value as "Key size used for RSA/DSA, or curve name":
// https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L337
KeyStrength: 0, // TODO: add key strength here
// TODO: we need to define methodology for determining key usage analogous to osquery, which
// describes this as "Certificate key usage and extended key usage":
// https://github.com/osquery/osquery/blob/16bb01508eeca6d663b6d4f7e15034306be0fc3d/osquery/tables/system/posix/openssl_utils.cpp#L166
KeyUsage: "",
Serial: cert.SerialNumber.String(),
SigningAlgorithm: cert.SignatureAlgorithm.String(),
SubjectCommonName: cert.Subject.CommonName,
SubjectCountry: firstOrEmpty(cert.Subject.Country), // TODO: confirm methodology
SubjectOrganization: firstOrEmpty(cert.Subject.Organization), // TODO: confirm methodology
SubjectOrganizationalUnit: firstOrEmpty(cert.Subject.OrganizationalUnit), // TODO: confirm methodology
IssuerCommonName: cert.Issuer.CommonName,
IssuerCountry: firstOrEmpty(cert.Issuer.Country), // TODO: confirm methodology
IssuerOrganization: firstOrEmpty(cert.Issuer.Organization), // TODO: confirm methodology
IssuerOrganizationalUnit: firstOrEmpty(cert.Issuer.OrganizationalUnit), // TODO: confirm methodology
}
}
// ToPayload fills a HostCertificatePayload with the fields of a
// HostCertificateRecord. The HostCertificatePayload is used in API responses.
func (r *HostCertificateRecord) ToPayload() *HostCertificatePayload {
subject := &HostCertificateNameDetails{
CommonName: r.SubjectCommonName,
Country: r.SubjectCountry,
Organization: r.SubjectOrganization,
OrganizationalUnit: r.SubjectOrganizationalUnit,
}
issuer := &HostCertificateNameDetails{
CommonName: r.IssuerCommonName,
Country: r.IssuerCountry,
Organization: r.IssuerOrganization,
OrganizationalUnit: r.IssuerOrganizationalUnit,
}
return &HostCertificatePayload{
ID: r.ID,
NotValidAfter: r.NotValidAfter,
NotValidBefore: r.NotValidBefore,
CertificateAuthority: r.CertificateAuthority,
CommonName: r.CommonName,
KeyAlgorithm: r.KeyAlgorithm,
KeyStrength: r.KeyStrength,
KeyUsage: r.KeyUsage,
Serial: r.Serial,
SigningAlgorithm: r.SigningAlgorithm,
Subject: subject,
Issuer: issuer,
}
}
// HostCertificatePayload is the JSON model for API endpoints that return host certificates.
type HostCertificatePayload struct {
ID uint `json:"id"`
NotValidAfter time.Time `json:"not_valid_after"`
NotValidBefore time.Time `json:"not_valid_before"`
CertificateAuthority bool `json:"certificate_authority"`
CommonName string `json:"common_name"`
KeyAlgorithm string `json:"key_algorithm"`
KeyStrength int `json:"key_strength"`
KeyUsage string `json:"key_usage"`
Serial string `json:"serial"`
SigningAlgorithm string `json:"signing_algorithm"`
Subject *HostCertificateNameDetails `json:"subject,omitempty"`
Issuer *HostCertificateNameDetails `json:"issuer,omitempty"`
}
type HostCertificateNameDetails struct {
CommonName string `json:"common_name"`
Country string `json:"country"`
Organization string `json:"organization"`
OrganizationalUnit string `json:"organizational_unit"`
}
// MDMAppleCertificateListResponse is the plist model for a certificate list response.
// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse
type MDMAppleCertificateListResponse struct {
CertificateList []MDMAppleCertificateListItem `plist:"CertificateList"`
CommandUUID string `plist:"CommandUUID"`
EnrollmentID string `plist:"EnrollmentID"`
EnrollmentUserID string `plist:"EnrollmentUserID"`
ErrorChain []MDMAppleErrorChainItem `plist:"ErrorChain"`
NotOnConsole bool `plist:"NotOnConsole"`
Status string `plist:"Status"`
UDID string `plist:"UDID"`
UserID string `plist:"UserID"`
UserLongName string `plist:"UserLongName"`
UserShortName string `plist:"UserShortName"`
}
// MDMAppleCertificateListItem is the plist model for a certificate.
// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse/certificatelistitem
type MDMAppleCertificateListItem struct {
CommonName string `plist:"CommonName"`
// Data is the DER-encoded certificate.
Data []byte `plist:"Data"`
IsIdentity bool `plist:"IsIdentity"`
}
func (c *MDMAppleCertificateListItem) Parse(hostID uint) (*HostCertificateRecord, error) {
cert, err := x509.ParseCertificate(c.Data)
if err != nil {
return nil, err
}
return NewHostCertificateRecord(hostID, cert), nil
}
// MdmAppleErrorChainItem is the plist model for an error chain item.
// https://developer.apple.com/documentation/devicemanagement/certificatelistresponse/errorchainitem
type MDMAppleErrorChainItem struct {
ErrorCode int `plist:"ErrorCode"`
ErrorDomain string `plist:"ErrorDomain"`
LocalizedDescription string `plist:"LocalizedDescription"`
USEnglishDescription string `plist:"USEnglishDescription"`
}
// ExtractDetailsFromOsqueryDistinguishedName parses a distinguished name and returns the country,
// organization, and organizational unit. It assumes provided string follows the formatting used by
// osquery `certificates` table[1], which appears to follow the style used by openSSL for `-subj`
// values). Key-value pairs are assumed to be separated by forward slashes, for example:
// "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM".
//
// See https://osquery.io/schema/5.15.0/#certificates
func ExtractDetailsFromOsqueryDistinguishedName(str string) (*HostCertificateNameDetails, error) {
str = strings.TrimSpace(str)
str = strings.Trim(str, "/")
if !strings.Contains(str, "/") {
return nil, errors.New("invalid format, wrong separator")
}
parts := strings.Split(str, "/")
var details HostCertificateNameDetails
for _, part := range parts {
kv := strings.Split(part, "=")
if len(kv) != 2 {
return nil, errors.New("invalid distinguished name, wrong key value pair format")
}
switch strings.ToUpper(kv[0]) {
case "C":
details.Country = strings.Trim(kv[1], " ")
case "O":
details.Organization = strings.Trim(kv[1], " ")
case "OU":
details.OrganizationalUnit = strings.Trim(kv[1], " ")
case "CN":
details.CommonName = strings.Trim(kv[1], " ")
}
}
return &details, nil
}
func firstOrEmpty(s []string) string {
if len(s) > 0 {
return s[0]
}
return ""
}

View file

@ -0,0 +1,105 @@
package fleet
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestExtractHostCertificateNameDetails(t *testing.T) {
expected := HostCertificateNameDetails{
Country: "US",
Organization: "Fleet Device Management Inc.",
OrganizationalUnit: "Fleet Device Management Inc.",
CommonName: "FleetDM",
}
cases := []struct {
name string
input string
expected *HostCertificateNameDetails
err bool
}{
{
name: "valid",
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM",
expected: &expected,
},
{
name: "valid with different order",
input: "/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/C=US",
expected: &expected,
},
{
name: "valid with missing key",
input: "/C=US/O=Fleet Device Management Inc./CN=FleetDM ",
expected: &HostCertificateNameDetails{
Country: "US",
Organization: "Fleet Device Management Inc.",
OrganizationalUnit: "",
CommonName: "FleetDM",
},
},
{
name: "valid with additional keyr",
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/L=SomeCity",
expected: &expected,
},
{
name: "invalid format with extra slash",
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/invalid",
err: true,
},
{
name: "invalid format with wrong separator",
input: "C=US,O=Fleet Device Management Inc.,OU=Fleet Device Management Inc.,CN=FleetDM",
err: true,
},
{
name: "invalid format with extra equal",
input: "/C=US=/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM",
err: true,
},
{
name: "invalid format with malformed key values",
input: "/C=US/O/OU=Fleet Device Management Inc./=/CN=FleetDM",
err: true,
},
{
name: "empty",
input: "",
err: true,
},
{
name: "missing value",
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=",
expected: &HostCertificateNameDetails{
Country: "US",
Organization: "Fleet Device Management Inc.",
OrganizationalUnit: "Fleet Device Management Inc.",
CommonName: "",
},
},
{
name: "missing first slash",
input: "C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM",
expected: &expected,
},
{
name: "trailing slash",
input: "/C=US/O=Fleet Device Management Inc./OU=Fleet Device Management Inc./CN=FleetDM/",
expected: &expected,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
actual, err := ExtractDetailsFromOsqueryDistinguishedName(tc.input)
if tc.err {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expected, actual)
}
})
}
}

View file

@ -801,6 +801,7 @@ const (
RefetchBaseCommandUUIDPrefix = "REFETCH-"
RefetchDeviceCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "DEVICE-"
RefetchAppsCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "APPS-"
RefetchCertsCommandUUIDPrefix = RefetchBaseCommandUUIDPrefix + "CERTS-"
)
// VPPTokenInfo is the representation of the VPP token that we send out via API.

View file

@ -435,6 +435,9 @@ type Service interface {
// the specified host.
ListHostSoftware(ctx context.Context, hostID uint, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error)
// ListHostCertificates lists the certificates installed on the specified host.
ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificatePayload, *PaginationMetadata, error)
// /////////////////////////////////////////////////////////////////////////////
// AppConfigService provides methods for configuring the Fleet application

View file

@ -1211,7 +1211,7 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp
logger.Log("msg", "sending commands to refetch", "count", len(devices), "lookup-duration", time.Since(start))
commandUUID := uuid.NewString()
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 2*len(devices))
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 3*len(devices))
installedAppsUUIDs := make([]string, 0, len(devices))
for _, device := range devices {
if !slices.Contains(device.CommandsAlreadySent, fleet.RefetchAppsCommandUUIDPrefix) {
@ -1229,6 +1229,23 @@ func IOSiPadOSRefetch(ctx context.Context, ds fleet.Datastore, commander *MDMApp
}
}
certsListUUIDs := make([]string, 0, len(devices))
for _, device := range devices {
if !slices.Contains(device.CommandsAlreadySent, fleet.RefetchCertsCommandUUIDPrefix) {
certsListUUIDs = append(certsListUUIDs, device.UUID)
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
HostID: device.HostID,
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
})
}
}
if len(certsListUUIDs) > 0 {
err = commander.CertificateList(ctx, certsListUUIDs, fleet.RefetchCertsCommandUUIDPrefix+commandUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "send CertificateList commands to ios and ipados devices")
}
}
// DeviceInformation is last because the refetch response clears the refetch_requested flag
deviceInfoUUIDs := make([]string, 0, len(devices))
for _, device := range devices {

View file

@ -336,6 +336,34 @@ func (svc *MDMAppleCommander) InstalledApplicationList(ctx context.Context, host
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
}
// CertificateList sends the homonym [command][1] to the device to get a list of installed
// certificates on the device.
//
// Note that user-enrolled devices ignore the [ManagedOnly][2] value set below and will always
// include only managed certificates. This is a limitation imposed by Apple.
//
// [1]: https://developer.apple.com/documentation/devicemanagement/certificatelistcommand
// [2]: https://developer.apple.com/documentation/devicemanagement/certificatelistcommand/command-data.dictionary
func (svc *MDMAppleCommander) CertificateList(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CommandUUID</key>
<string>%s</string>
<key>Command</key>
<dict>
<key>MangedOnly</key>
<false/>
<key>RequestType</key>
<string>CertificateList</string>
</dict>
</dict>
</plist>`, cmdUUID)
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
}
// EnqueueCommand takes care of enqueuing the commands and sending push
// notifications to the devices.
//
@ -352,7 +380,8 @@ func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []st
}
func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []string, cmd *mdm.Command,
subtype mdm.CommandSubtype) error {
subtype mdm.CommandSubtype,
) error {
if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs,
&mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype}); err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing command")
@ -367,7 +396,8 @@ func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []
// EnqueueCommandInstallProfileWithSecrets is a special case of EnqueueCommand that does not expand secret variables.
// Secret variables are expanded when the command is sent to the device, and secrets are never stored in the database unencrypted.
func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx context.Context, hostUUIDs []string,
rawCommand mobileconfig.Mobileconfig, commandUUID string) error {
rawCommand mobileconfig.Mobileconfig, commandUUID string,
) error {
cmd := &mdm.Command{
CommandUUID: commandUUID,
Raw: []byte(rawCommand),

View file

@ -0,0 +1,29 @@
package testing_utils
import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"math/big"
"time"
)
func NewTestMDMAppleCertTemplate() *x509.Certificate {
return &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Test Org"},
ExtraNames: []pkix.AttributeTypeAndValue{
{
Type: asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1},
Value: "com.apple.mgmt.Example",
},
},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
}

View file

@ -274,6 +274,10 @@ type CleanupHostMDMAppleProfilesFunc func(ctx context.Context) error
type IsHostConnectedToFleetMDMFunc func(ctx context.Context, host *fleet.Host) (bool, error)
type ListHostCertificatesFunc func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error)
type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error
type AreHostsConnectedToFleetMDMFunc func(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error)
type AggregatedMunkiVersionFunc func(ctx context.Context, teamID *uint) ([]fleet.AggregatedMunkiVersion, time.Time, error)
@ -1617,6 +1621,12 @@ type DataStore struct {
IsHostConnectedToFleetMDMFunc IsHostConnectedToFleetMDMFunc
IsHostConnectedToFleetMDMFuncInvoked bool
ListHostCertificatesFunc ListHostCertificatesFunc
ListHostCertificatesFuncInvoked bool
UpdateHostCertificatesFunc UpdateHostCertificatesFunc
UpdateHostCertificatesFuncInvoked bool
AreHostsConnectedToFleetMDMFunc AreHostsConnectedToFleetMDMFunc
AreHostsConnectedToFleetMDMFuncInvoked bool
@ -3948,6 +3958,20 @@ func (s *DataStore) IsHostConnectedToFleetMDM(ctx context.Context, host *fleet.H
return s.IsHostConnectedToFleetMDMFunc(ctx, host)
}
func (s *DataStore) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
s.mu.Lock()
s.ListHostCertificatesFuncInvoked = true
s.mu.Unlock()
return s.ListHostCertificatesFunc(ctx, hostID, opts)
}
func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error {
s.mu.Lock()
s.UpdateHostCertificatesFuncInvoked = true
s.mu.Unlock()
return s.UpdateHostCertificatesFunc(ctx, hostID, certs)
}
func (s *DataStore) AreHostsConnectedToFleetMDM(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) {
s.mu.Lock()
s.AreHostsConnectedToFleetMDMFuncInvoked = true

View file

@ -3030,44 +3030,102 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe
return nil, ctxerr.Wrap(ctx, err, "failed to get host by identifier")
}
if strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) {
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
err = svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
HostID: host.ID,
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command")
}
switch {
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix):
return svc.handleRefetchAppsResults(ctx, host, cmdResult)
if host.Platform != "ios" && host.Platform != "ipados" {
return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host")
}
source := "ios_apps"
if host.Platform == "ipados" {
source = "ipados_apps"
}
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix):
return svc.handleRefetchCertsResults(ctx, host, cmdResult)
response := cmdResult.Raw
software, err := unmarshalAppList(ctx, response, source)
if err != nil {
return nil, err
}
_, err = svc.ds.UpdateHostSoftware(ctx, host.ID, software)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host software")
}
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix):
return svc.handleRefetchDeviceResults(ctx, host, cmdResult)
return nil, nil
default:
// This should never happen, but just in case we'll return an error.
return nil, ctxerr.New(ctx, fmt.Sprintf("unknown refetch command type %s", cmdResult.CommandUUID))
}
}
func (svc *MDMAppleCheckinAndCommandService) handleRefetchAppsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) {
// Caller should have checked this, but just in case we'll return an error.
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-APPS- prefix but got %s", cmdResult.CommandUUID))
}
// Otherwise, the command has prefix fleet.RefetchDeviceCommandUUIDPrefix, which is a refetch device command.
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
err = svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
HostID: host.ID,
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command")
}
if host.Platform != "ios" && host.Platform != "ipados" {
return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host")
}
source := "ios_apps"
if host.Platform == "ipados" {
source = "ipados_apps"
}
response := cmdResult.Raw
software, err := unmarshalAppList(ctx, response, source)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshal app list")
}
_, err = svc.ds.UpdateHostSoftware(ctx, host.ID, software)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "update host software")
}
return nil, nil
}
func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix) {
// Caller should have checked this, but just in case we'll return an error.
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-CERTS- prefix but got %s", cmdResult.CommandUUID))
}
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
HostID: host.ID,
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "refetch certs: remove refetch command")
}
var listResp fleet.MDMAppleCertificateListResponse
if err := plist.Unmarshal(cmdResult.Raw, &listResp); err != nil {
return nil, ctxerr.Wrap(ctx, err, "refetch certs: unmarshal certificate list command result")
}
payload := make([]*fleet.HostCertificateRecord, 0, len(listResp.CertificateList))
for _, cert := range listResp.CertificateList {
parsed, err := cert.Parse(host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "refetch certs: parse certificate")
}
payload = append(payload, parsed)
}
if err := svc.ds.UpdateHostCertificates(ctx, host.ID, payload); err != nil {
return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates")
}
return nil, nil
}
func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix) {
// Caller should have checked this, but just in case we'll return an error.
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-DEVICE- prefix but got %s", cmdResult.CommandUUID))
}
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
HostID: host.ID,
CommandType: fleet.RefetchDeviceCommandUUIDPrefix,
})
if err != nil {
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "remove refetch device command")
}
@ -3075,7 +3133,7 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe
QueryResponses map[string]interface{} `plist:"QueryResponses"`
}
if err := plist.Unmarshal(cmdResult.Raw, &deviceInformationResponse); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "failed to unmarshal device information command result")
return nil, ctxerr.Wrap(ctx, err, "failed to unmarshal device information command result")
}
deviceName := deviceInformationResponse.QueryResponses["DeviceName"].(string)
deviceCapacity := deviceInformationResponse.QueryResponses["DeviceCapacity"].(float64)
@ -3103,26 +3161,25 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdRe
host.HardwareModel = productName
host.DetailUpdatedAt = time.Now()
host.RefetchRequested = false
if err := svc.ds.UpdateHost(r.Context, host); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "failed to update host")
if err := svc.ds.UpdateHost(ctx, host); err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to update host")
}
if err := svc.ds.SetOrUpdateHostDisksSpace(r.Context, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity,
if err := svc.ds.SetOrUpdateHostDisksSpace(ctx, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity,
deviceCapacity); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "failed to update host storage")
return nil, ctxerr.Wrap(ctx, err, "failed to update host storage")
}
if err := svc.ds.UpdateHostOperatingSystem(r.Context, host.ID, fleet.OperatingSystem{
if err := svc.ds.UpdateHostOperatingSystem(ctx, host.ID, fleet.OperatingSystem{
Name: osVersionPrefix,
Version: osVersion,
Platform: platform,
}); err != nil {
return nil, ctxerr.Wrap(r.Context, err, "failed to update host operating system")
return nil, ctxerr.Wrap(ctx, err, "failed to update host operating system")
}
if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "Pending" {
// Since the device has been refetched, we can assume it's enrolled.
err = svc.ds.UpdateMDMData(ctx, host.ID, true)
if err != nil {
return nil, ctxerr.Wrap(r.Context, err, "failed to update MDM data")
if err := svc.ds.UpdateMDMData(ctx, host.ID, true); err != nil {
return nil, ctxerr.Wrap(ctx, err, "failed to update MDM data")
}
}
return nil, nil

View file

@ -55,6 +55,8 @@ import (
"github.com/smallstep/pkcs7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
)
type nopProfileMatcher struct{}
@ -218,7 +220,7 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi
ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) {
return document, nil, nil
}
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
crt, key, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
@ -3618,7 +3620,7 @@ func setupTest(t *testing.T) (context.Context, kitlog.Logger, *mock.Store, *conf
AppleSCEPCert: "./testdata/server.pem",
AppleSCEPKey: "./testdata/server.key",
}
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
appCfg := &fleet.AppConfig{}
@ -3826,7 +3828,7 @@ func TestRenewSCEPCertificatesBranches(t *testing.T) {
}
appleStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes()
apnsCert, apnsKey, err := mysql.GenerateTestCertBytes(mdmtesting.NewTestMDMAppleCertTemplate())
require.NoError(t, err)
cert, err := tls.X509KeyPair(apnsCert, apnsKey)
return &cert, "", err

View file

@ -685,3 +685,49 @@ func getDeviceSoftwareEndpoint(ctx context.Context, request interface{}, svc fle
}
return getDeviceSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil //nolint:gosec // dismiss G115
}
////////////////////////////////////////////////////////////////////////////////
// List Current Device's Certificates
////////////////////////////////////////////////////////////////////////////////
type listDeviceCertificatesRequest struct {
Token string `url:"token"`
fleet.ListOptions
}
func (r *listDeviceCertificatesRequest) ValidateRequest() error {
if r.ListOptions.OrderKey != "" && !listHostCertificatesSortCols[r.ListOptions.OrderKey] {
return badRequest("invalid order key")
}
return nil
}
func (r *listDeviceCertificatesRequest) deviceAuthToken() string {
return r.Token
}
type listDeviceCertificatesResponse struct {
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Err error `json:"error,omitempty"`
}
func (r listDeviceCertificatesResponse) Error() error { return r.Err }
func listDeviceCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
host, ok := hostctx.FromContext(ctx)
if !ok {
err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
return listDevicePoliciesResponse{Err: err}, nil
}
req := request.(*listDeviceCertificatesRequest)
res, meta, err := svc.ListHostCertificates(ctx, host.ID, req.ListOptions)
if err != nil {
return listDeviceCertificatesResponse{Err: err}, nil
}
if res == nil {
res = []*fleet.HostCertificatePayload{}
}
return listDeviceCertificatesResponse{Certificates: res, Meta: meta}, nil
}

View file

@ -396,6 +396,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", addLabelsToHostEndpoint, addLabelsToHostRequest{})
ue.DELETE("/api/_version_/fleet/hosts/{id:[0-9]+}/labels", removeLabelsFromHostEndpoint, removeLabelsFromHostRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/software", getHostSoftwareEndpoint, getHostSoftwareRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/certificates", listHostCertificatesEndpoint, listHostCertificatesRequest{})
ue.GET("/api/_version_/fleet/hosts/summary/mdm", getHostMDMSummary, getHostMDMSummaryRequest{})
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/mdm", getHostMDM, getHostMDMRequest{})
@ -797,6 +798,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
de.WithCustomMiddleware(
errorLimiter.Limit("install_self_service", desktopQuota),
).POST("/api/_version_/fleet/device/{token}/software/install/{software_title_id}", submitSelfServiceSoftwareInstall, fleetSelfServiceSoftwareInstallRequest{})
de.WithCustomMiddleware(
errorLimiter.Limit("get_device_certificates", desktopQuota),
).GET("/api/_version_/fleet/device/{token}/certificates", listDeviceCertificatesEndpoint, listDeviceCertificatesRequest{})
// mdm-related endpoints available via device authentication
demdm := de.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyAppleMDM())

View file

@ -1066,15 +1066,18 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
}
doAppRefetch := true
doDeviceInfoRefetch := true
doCertsRefetch := true
for _, cmd := range commands {
switch cmd.CommandType {
case fleet.RefetchDeviceCommandUUIDPrefix:
doDeviceInfoRefetch = false
case fleet.RefetchAppsCommandUUIDPrefix:
doAppRefetch = false
case fleet.RefetchCertsCommandUUIDPrefix:
doCertsRefetch = false
}
}
if !doAppRefetch && !doDeviceInfoRefetch {
if !doAppRefetch && !doDeviceInfoRefetch && !doCertsRefetch {
// Nothing to do.
return nil
}
@ -1082,7 +1085,7 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
if err != nil {
return err
}
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 2)
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 3)
cmdUUID := uuid.NewString()
if doAppRefetch {
err = svc.mdmAppleCommander.InstalledApplicationList(ctx, []string{host.UUID}, fleet.RefetchAppsCommandUUIDPrefix+cmdUUID)
@ -1094,6 +1097,15 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
})
}
if doCertsRefetch {
if err := svc.mdmAppleCommander.CertificateList(ctx, []string{host.UUID}, fleet.RefetchCertsCommandUUIDPrefix+cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "refetch certs with MDM")
}
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
HostID: host.ID,
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
})
}
if doDeviceInfoRefetch {
// DeviceInformation is last because the refetch response clears the refetch_requested flag
err = svc.mdmAppleCommander.DeviceInformation(ctx, []string{host.UUID}, fleet.RefetchDeviceCommandUUIDPrefix+cmdUUID)
@ -2704,3 +2716,77 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee
software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts)
return software, meta, ctxerr.Wrap(ctx, err, "list host software")
}
////////////////////////////////////////////////////////////////////////////////
// Host Certificates
////////////////////////////////////////////////////////////////////////////////
type listHostCertificatesRequest struct {
ID uint `url:"id"`
fleet.ListOptions
}
var listHostCertificatesSortCols = map[string]bool{
"common_name": true,
"not_valid_after": true,
}
func (r *listHostCertificatesRequest) ValidateRequest() error {
if r.ListOptions.OrderKey != "" && !listHostCertificatesSortCols[r.ListOptions.OrderKey] {
return badRequest("invalid order key")
}
return nil
}
type listHostCertificatesResponse struct {
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Err error `json:"error,omitempty"`
}
func (r listHostCertificatesResponse) Error() error { return r.Err }
func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*listHostCertificatesRequest)
res, meta, err := svc.ListHostCertificates(ctx, req.ID, req.ListOptions)
if err != nil {
return listHostCertificatesResponse{Err: err}, nil
}
if res == nil {
res = []*fleet.HostCertificatePayload{}
}
return listHostCertificatesResponse{Certificates: res, Meta: meta}, nil
}
func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) {
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, nil, ctxerr.Wrap(ctx, err, "failed to load host")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, nil, err
}
}
// query/after not supported, always include pagination info
opts.MatchQuery = ""
opts.After = ""
opts.IncludeMetadata = true
// default sort order is common name ascending
if opts.OrderKey == "" {
opts.OrderKey = "common_name"
}
certs, meta, err := svc.ds.ListHostCertificates(ctx, hostID, opts)
if err != nil {
return nil, nil, err
}
payload := make([]*fleet.HostCertificatePayload, 0, len(certs))
for _, cert := range certs {
payload = append(payload, cert.ToPayload())
}
return payload, meta, nil
}

View file

@ -665,6 +665,9 @@ func TestHostAuth(t *testing.T) {
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
return true, nil
}
ds.ListHostCertificatesFunc = func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) {
return nil, nil, nil
}
testCases := []struct {
name string
@ -812,6 +815,11 @@ func TestHostAuth(t *testing.T) {
_, _, err = svc.ListHostSoftware(ctx, 2, fleet.HostSoftwareTitleListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
_, _, err = svc.ListHostCertificates(ctx, 1, fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailTeamRead, err)
_, _, err = svc.ListHostCertificates(ctx, 2, fleet.ListOptions{})
checkAuthErr(t, tt.shouldFailGlobalRead, err)
})
}

View file

@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"crypto/sha1" // nolint: gosec
"database/sql"
"encoding/csv"
"encoding/json"
@ -13077,3 +13078,129 @@ func createAndroidHosts(t *testing.T, ds *mysql.Datastore, count int, teamID *ui
}
return ids
}
func (s *integrationTestSuite) TestHostCertificates() {
t := s.T()
ctx := context.Background()
token := "good_token"
host := createOrbitEnrolledHost(t, "linux", "host1", s.ds)
createDeviceTokenForHost(t, s.ds, host.ID, token)
// no certificate at the moment
var certResp listHostCertificatesResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp)
require.Empty(t, certResp.Certificates)
certResp = listHostCertificatesResponse{}
res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK)
err := json.NewDecoder(res.Body).Decode(&certResp)
require.NoError(t, err)
require.Empty(t, certResp.Certificates)
// create some certs for that host
certNames := []string{"a", "b", "c", "d", "e"}
now := time.Now()
// sorting by not_valid_after should get us "d", "c", "e", "a", "b"
notValidAfterTimes := []time.Time{
now.Add(time.Minute), now.Add(time.Hour),
now.Add(time.Second), now.Add(time.Millisecond),
now.Add(2 * time.Second),
}
certs := make([]*fleet.HostCertificateRecord, 0, len(certNames))
for i, name := range certNames {
certs = append(certs, &fleet.HostCertificateRecord{
HostID: host.ID,
CommonName: name,
SHA1Sum: sha1.New().Sum([]byte(name)), // nolint: gosec
SubjectCountry: "s" + name,
IssuerCountry: "i" + name,
NotValidAfter: notValidAfterTimes[i],
})
}
require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, certs))
// list all certs
certResp = listHostCertificatesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp)
require.Len(t, certResp.Certificates, len(certNames))
for i, cert := range certResp.Certificates {
want := certNames[i]
require.Equal(t, want, cert.CommonName)
require.NotNil(t, cert.Subject)
require.Equal(t, "s"+want, cert.Subject.Country)
require.NotNil(t, cert.Issuer)
require.Equal(t, "i"+want, cert.Issuer.Country)
}
certResp = listHostCertificatesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK)
err = json.NewDecoder(res.Body).Decode(&certResp)
require.NoError(t, err)
require.Len(t, certResp.Certificates, len(certNames))
for i, cert := range certResp.Certificates {
want := certNames[i]
require.Equal(t, want, cert.CommonName)
require.NotNil(t, cert.Subject)
require.Equal(t, "s"+want, cert.Subject.Country)
require.NotNil(t, cert.Issuer)
require.Equal(t, "i"+want, cert.Issuer.Country)
}
// non-existing host
certResp = listHostCertificatesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID+1000), nil, http.StatusNotFound, &certResp)
// for the device endpoint, the token is the authentication so if it doesn't
// exist, the endpoint is unauthorized.
certResp = listHostCertificatesResponse{}
s.DoRawNoAuth("GET", "/api/latest/fleet/device/NO-SUCH-TOKEN/certificates", nil, http.StatusUnauthorized)
pluckCertNames := func(certs []*fleet.HostCertificatePayload) []string {
names := make([]string, 0, len(certs))
for _, cert := range certs {
names = append(names, cert.CommonName)
}
return names
}
// fails if order_key is invalid
certResp = listHostCertificatesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusBadRequest, &certResp, "order_key", "no-such-column")
certResp = listHostCertificatesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusBadRequest, "order_key", "no-such-column")
require.Contains(t, extractServerErrorText(res.Body), "invalid order key")
// test the pagination options
cases := []struct {
queryParams []string
wantNames []string
wantMeta fleet.PaginationMetadata
}{
{queryParams: []string{"page", "0", "per_page", "2"}, wantNames: []string{"a", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
{queryParams: []string{"page", "1", "per_page", "2"}, wantNames: []string{"c", "d"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}},
{queryParams: []string{"page", "2", "per_page", "2"}, wantNames: []string{"e"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
{queryParams: []string{"page", "3", "per_page", "2"}, wantNames: []string{}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
{queryParams: []string{"page", "0", "per_page", "4", "order_direction", "desc"}, wantNames: []string{"e", "d", "c", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
{queryParams: []string{"page", "1", "per_page", "4", "order_direction", "desc"}, wantNames: []string{"a"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
{queryParams: []string{"page", "0", "per_page", "3", "order_key", "not_valid_after"}, wantNames: []string{"d", "c", "e"}, wantMeta: fleet.PaginationMetadata{HasNextResults: true}},
{queryParams: []string{"page", "1", "per_page", "3", "order_key", "not_valid_after"}, wantNames: []string{"a", "b"}, wantMeta: fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}},
}
for _, c := range cases {
t.Run(strings.Join(c.queryParams, "_"), func(t *testing.T) {
certResp = listHostCertificatesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/certificates", host.ID), nil, http.StatusOK, &certResp, c.queryParams...)
require.Len(t, certResp.Certificates, len(c.wantNames))
require.Equal(t, c.wantNames, pluckCertNames(certResp.Certificates))
require.Equal(t, c.wantMeta, *certResp.Meta)
certResp = listHostCertificatesResponse{}
res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/certificates", nil, http.StatusOK, c.queryParams...)
err = json.NewDecoder(res.Body).Decode(&certResp)
require.NoError(t, err)
require.Len(t, certResp.Certificates, len(c.wantNames))
require.Equal(t, c.wantNames, pluckCertNames(certResp.Certificates))
require.Equal(t, c.wantMeta, *certResp.Meta)
})
}
}

View file

@ -59,6 +59,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
filedepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/file"
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/mock"
"github.com/fleetdm/fleet/v4/server/service/osquery_utils"
@ -10730,6 +10731,8 @@ func (s *integrationMDMTestSuite) TestEnrollAfterDEPSyncIOSIPadOS() {
func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
t := s.T()
testCerts := []*x509.Certificate{mdmtesting.NewTestMDMAppleCertTemplate()}
// Try to refetch host that is not MDM enrolled
serialNumber := mdmtest.RandSerialNumber()
fleetHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
@ -10750,7 +10753,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
// Refetch host
_ = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", host.ID), nil, http.StatusOK)
const commandsSentPerRefetch = 2
const commandsSentPerRefetch = 3
commandsSent := commandsSentPerRefetch
var hostResp getHostResponse
@ -10763,6 +10766,7 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
require.Len(t, commands, commandsSent)
assert.ElementsMatch(t, []fleet.HostMDMCommand{
{HostID: host.ID, CommandType: fleet.RefetchAppsCommandUUIDPrefix},
{HostID: host.ID, CommandType: fleet.RefetchCertsCommandUUIDPrefix},
{HostID: host.ID, CommandType: fleet.RefetchDeviceCommandUUIDPrefix},
}, commands)
@ -10789,6 +10793,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID,
[]fleet.Software{expectedSoftware[0].Software})
require.NoError(t, err)
require.Equal(t, "CertificateList", cmd.Command.RequestType)
cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, testCerts)
require.NoError(t, err)
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
_, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceName, "iPhone SE")
require.NoError(t, err)
@ -10808,6 +10815,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
}
assert.ElementsMatch(t, expectedSoftware, hostResp.Host.Software)
// TODO: add test for GET /hosts/:id/certificates endpoint, should match up with testCerts
// Install the same app for iPadOS
hostIPad, mdmClientIPad := s.createAppleMobileHostThenEnrollMDM("ipados")
@ -10839,6 +10848,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
cmd, err = mdmClientIPad.AcknowledgeInstalledApplicationList(mdmClientIPad.UUID, cmd.CommandUUID,
[]fleet.Software{expectedSoftware[0].Software})
require.NoError(t, err)
require.Equal(t, "CertificateList", cmd.Command.RequestType)
cmd, err = mdmClientIPad.AcknowledgeCertificateList(mdmClientIPad.UUID, cmd.CommandUUID, testCerts)
require.NoError(t, err)
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
cmd, err = mdmClientIPad.AcknowledgeDeviceInformation(mdmClientIPad.UUID, cmd.CommandUUID, deviceNameIPad, "iPad 10")
require.NoError(t, err)
@ -10855,6 +10867,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
}
assert.ElementsMatch(t, expectedSoftware, hostResp.Host.Software)
// TODO: add test for GET /hosts/:id/certificates endpoint, should match up with testCerts
hostsCountTs := time.Now().UTC()
require.NoError(t, s.ds.SyncHostsSoftware(context.Background(), hostsCountTs))
ctx := context.Background()
@ -10917,6 +10931,9 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID, []fleet.Software{})
require.NoError(t, err)
require.Equal(t, "CertificateList", cmd.Command.RequestType)
cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, []*x509.Certificate{})
require.NoError(t, err)
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
const deviceNameRenamed = "My new iPhone"
cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE")
@ -10930,6 +10947,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
assert.Equal(t, deviceNameRenamed, hostResp.Host.ComputerName)
assert.Empty(t, hostResp.Host.Software)
// TODO: add test for GET /hosts/:id/certificates endpoint, should be empty
// Mark host as unenrolled and refetch.
require.NoError(t, s.ds.UpdateMDMData(ctx, host.ID, false))
hostResp = getHostResponse{}
@ -10976,6 +10995,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
require.Equal(t, "InstalledApplicationList", cmd.Command.RequestType)
cmd, err = mdmClient.AcknowledgeInstalledApplicationList(mdmClient.UUID, cmd.CommandUUID, []fleet.Software{})
require.NoError(t, err)
require.Equal(t, "CertificateList", cmd.Command.RequestType)
cmd, err = mdmClient.AcknowledgeCertificateList(mdmClient.UUID, cmd.CommandUUID, []*x509.Certificate{})
require.Equal(t, "DeviceInformation", cmd.Command.RequestType)
cmd, err = mdmClient.AcknowledgeDeviceInformation(mdmClient.UUID, cmd.CommandUUID, deviceNameRenamed, "iPhone SE")
require.NoError(t, err)
@ -10988,6 +11009,8 @@ func (s *integrationMDMTestSuite) TestRefetchIOSIPadOS() {
require.NotNil(t, hostResp.Host.MDM.EnrollmentStatus)
assert.Equal(t, "On (automatic)", *hostResp.Host.MDM.EnrollmentStatus)
// TODO: add test for GET /hosts/:id/certificates endpoint, should be empty
// list commands should return all the commands we sent
var listCmdResp listMDMAppleCommandsResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)

View file

@ -301,6 +301,12 @@ type RequestDecoder interface {
DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error)
}
// A value that implements requestValidator is called after having the values
// decoded into it to apply further validations.
type requestValidator interface {
ValidateRequest() error
}
// MakeDecoder creates a decoder for the type for the struct passed on. If the
// struct has at least 1 json tag it'll unmarshall the body. If the struct has
// a `url` tag with value list_options it'll gather fleet.ListOptions from the
@ -439,6 +445,11 @@ func MakeDecoder(
}
}
if rv, ok := v.Interface().(requestValidator); ok {
if err := rv.ValidateRequest(); err != nil {
return nil, err
}
}
return v.Interface(), nil
}
}

View file

@ -693,6 +693,20 @@ var extraDetailQueries = map[string]DetailQuery{
Platforms: []string{"windows"},
DirectIngestFunc: directIngestDiskEncryption,
},
"certificates_darwin": {
Query: `
SELECT
ca, common_name, subject, issuer,
key_algorithm, key_strength, key_usage, signing_algorithm,
not_valid_after, not_valid_before,
serial, sha1
FROM
certificates
WHERE
path = '/Library/Keychains/System.keychain';`,
Platforms: []string{"darwin"},
DirectIngestFunc: directIngestHostCertificates,
},
}
// mdmQueries are used by the Fleet server to compliment certain MDM
@ -2382,3 +2396,57 @@ func directIngestWindowsProfiles(
}
return microsoft_mdm.VerifyHostMDMProfiles(ctx, logger, ds, host, rawResponse)
}
func directIngestHostCertificates(
ctx context.Context,
logger log.Logger,
host *fleet.Host,
ds fleet.Datastore,
rows []map[string]string,
) error {
if len(rows) == 0 {
// if there are no results, it probably may indicate a problem so we log it
level.Debug(logger).Log("component", "service", "method", "directIngestHostCertificates", "msg", "no rows returned", "host_id", host.ID)
return nil
}
certs := make([]*fleet.HostCertificateRecord, 0, len(rows))
for _, row := range rows {
csum, err := hex.DecodeString(row["sha1"])
if err != nil {
return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: decoding sha1")
}
subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(row["subject"])
if err != nil {
return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: extracting subject details")
}
issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(row["issuer"])
if err != nil {
return ctxerr.Wrap(ctx, err, "directIngestHostCertificates: extracting issuer details")
}
certs = append(certs, &fleet.HostCertificateRecord{
HostID: host.ID,
SHA1Sum: csum,
NotValidAfter: time.Unix(cast.ToInt64(row["not_valid_after"]), 0).UTC(),
NotValidBefore: time.Unix(cast.ToInt64(row["not_valid_before"]), 0).UTC(),
CertificateAuthority: cast.ToBool(row["ca"]),
CommonName: row["common_name"],
KeyAlgorithm: row["key_algorithm"],
KeyStrength: cast.ToInt(row["key_strength"]),
KeyUsage: row["key_usage"],
Serial: row["serial"],
SigningAlgorithm: row["signing_algorithm"],
SubjectCountry: subject.Country,
SubjectOrganizationalUnit: subject.OrganizationalUnit,
SubjectOrganization: subject.Organization,
SubjectCommonName: subject.CommonName,
IssuerCountry: issuer.Country,
IssuerOrganizationalUnit: issuer.OrganizationalUnit,
IssuerOrganization: issuer.Organization,
IssuerCommonName: issuer.CommonName,
})
}
return ds.UpdateHostCertificates(ctx, host.ID, certs)
}

View file

@ -290,13 +290,14 @@ func TestGetDetailQueries(t *testing.T) {
"disk_encryption_linux",
"disk_encryption_windows",
"chromeos_profile_user_info",
"certificates_darwin",
}
require.Len(t, queriesNoConfig, len(baseQueries))
sortedKeysCompare(t, queriesNoConfig, baseQueries)
queriesWithoutWinOSVuln := GetDetailQueries(context.Background(), config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{DisableWinOSVulnerabilities: true}}, nil, nil)
require.Len(t, queriesWithoutWinOSVuln, 25)
require.Len(t, queriesWithoutWinOSVuln, 26)
queriesWithUsers := GetDetailQueries(context.Background(), config.FleetConfig{App: config.AppConfig{EnableScheduledQueryStats: true}}, nil, &fleet.Features{EnableHostUsers: true})
qs := baseQueries
@ -2220,6 +2221,57 @@ func TestIngestNetworkInterface(t *testing.T) {
})
}
func TestDirectIngestHostCertificates(t *testing.T) {
ds := new(mock.Store)
ctx := context.Background()
logger := log.NewNopLogger()
host := &fleet.Host{ID: 1}
row1 := map[string]string{
"ca": "0",
"common_name": "Cert 1 Common Name",
"issuer": "/C=US/O=Issuer 1 Inc./CN=Issuer 1 Common Name",
"subject": "/C=US/O=Subject 1 Inc./OU=Subject 1 Org Unit/CN=Subject 1 Common Name",
"key_algorithm": "rsaEncryption",
"key_strength": "2048",
"key_usage": "Data Encipherment, Key Encipherment, Digital Signature",
"serial": "123abc",
"signing_algorithm": "sha256WithRSAEncryption",
"not_valid_after": "1822755797",
"not_valid_before": "1770228826",
"sha1": "9c1e9c00d8120c1a9d96274d2a17c38ffa30fd31",
}
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, certs []*fleet.HostCertificateRecord) error {
require.Equal(t, host.ID, hostID)
require.Len(t, certs, 1)
require.Equal(t, "9c1e9c00d8120c1a9d96274d2a17c38ffa30fd31", hex.EncodeToString(certs[0].SHA1Sum))
require.Equal(t, "Cert 1 Common Name", certs[0].CommonName)
require.Equal(t, "Subject 1 Common Name", certs[0].SubjectCommonName)
require.Equal(t, "Subject 1 Inc.", certs[0].SubjectOrganization)
require.Equal(t, "Subject 1 Org Unit", certs[0].SubjectOrganizationalUnit)
require.Equal(t, "US", certs[0].SubjectCountry)
require.Equal(t, "Issuer 1 Common Name", certs[0].IssuerCommonName)
require.Equal(t, "Issuer 1 Inc.", certs[0].IssuerOrganization)
require.Empty(t, certs[0].IssuerOrganizationalUnit)
require.Equal(t, "US", certs[0].IssuerCountry)
require.Equal(t, "rsaEncryption", certs[0].KeyAlgorithm)
require.Equal(t, 2048, certs[0].KeyStrength)
require.Equal(t, "Data Encipherment, Key Encipherment, Digital Signature", certs[0].KeyUsage)
require.Equal(t, "123abc", certs[0].Serial)
require.Equal(t, "sha256WithRSAEncryption", certs[0].SigningAlgorithm)
require.Equal(t, int64(1822755797), certs[0].NotValidAfter.Unix())
require.Equal(t, int64(1770228826), certs[0].NotValidBefore.Unix())
require.False(t, certs[0].CertificateAuthority)
return nil
}
err := directIngestHostCertificates(ctx, logger, host, ds, []map[string]string{row1})
require.NoError(t, err)
require.True(t, ds.UpdateHostCertificatesFuncInvoked)
}
func TestGenerateSQLForAllExists(t *testing.T) {
// Combine two queries
query1 := "SELECT 1 WHERE foo = bar"

View file

@ -257,8 +257,8 @@ func (ts *withServer) DoRaw(verb string, path string, rawBytes []byte, expectedS
}, queryParams...)
}
func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int) *http.Response {
return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil)
func (ts *withServer) DoRawNoAuth(verb string, path string, rawBytes []byte, expectedStatusCode int, queryParams ...string) *http.Response {
return ts.DoRawWithHeaders(verb, path, rawBytes, expectedStatusCode, nil, queryParams...)
}
func (ts *withServer) DoJSON(verb, path string, params interface{}, expectedStatusCode int, v interface{}, queryParams ...string) {