From 7a3503db18abba7b898cf21056cf371b7c0586c0 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:49:50 +0000 Subject: [PATCH 01/17] Show certificates on host details page for Windows (#31294) Surface the existing "Certificates" card on the host details page for Windows hosts, with parity to macOS. Requires osquery 5.23.1 or higher. Backend (server/service/osquery_utils/queries.go): - certificates_windows query now selects sid, store_location, subject2, issuer2, gated by a Discovery query on the subject2 column so older osquery agents (< 5.23.1, which lack the column) collect nothing instead of erroring. - Scope is derived from the registry hive SID (S-1-5-21-* => User, else System with no owner) rather than the username == "SYSTEM" heuristic, which mislabeled machine-wide LocalMachine certs. Dedup by (SHA1, scope, username) collapses redundant hive views. DN parsing (server/fleet/host_certificates.go): - Replace the parseWindowsDN stub with a real X.500 (CERT_X500_NAME_STR) parser for subject2/issuer2: comma-separated key=value RDNs, quoted values, multi-valued (+) RDNs. Shared CN/O/OU/C mapping with the macOS parser. Populates the Issuer column and details modal for Windows. Reconciliation (server/datastore/mysql/host_certificates.go): - UpdateHostCertificates takes an observedScopes argument. nil (macOS/MDM) reconciles every scope, unchanged. The Windows path passes the scopes it observed so a logged-off user's certificates (hive not loaded) are preserved rather than soft-deleted, while System is always observed. Frontend: - Un-gate the Certificates card for Windows hosts, rename the "Keychain" column to "Scope", and make the table help text platform-aware. Claude-Session: https://claude.ai/code/session_01H3HZ8eEZcVZoBU9rjy6ci8 --- changes/31294-windows-host-certificates | 2 + .../HostDetailsPage/HostDetailsPage.tsx | 3 +- .../cards/Certificates/Certificates.tsx | 6 +- .../CertificatesTable.tests.tsx | 86 +++++++ .../CertificatesTable/CertificatesTable.tsx | 8 +- .../CertificatesTableConfig.tsx | 2 +- server/datastore/mysql/apple_mdm_test.go | 4 +- server/datastore/mysql/host_certificates.go | 83 +++++- .../datastore/mysql/host_certificates_test.go | 175 ++++++++++--- server/datastore/mysql/hosts_test.go | 2 +- server/fleet/datastore.go | 8 +- server/fleet/host_certificates.go | 146 ++++++++--- server/fleet/host_certificates_test.go | 86 +++++++ server/mock/datastore_mock.go | 6 +- server/service/apple_mdm.go | 4 +- server/service/integration_core_test.go | 2 +- server/service/osquery_utils/queries.go | 90 +++++-- server/service/osquery_utils/queries_test.go | 239 ++++++++++-------- 18 files changed, 729 insertions(+), 223 deletions(-) create mode 100644 changes/31294-windows-host-certificates create mode 100644 frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tests.tsx diff --git a/changes/31294-windows-host-certificates b/changes/31294-windows-host-certificates new file mode 100644 index 00000000000..f4acefcc733 --- /dev/null +++ b/changes/31294-windows-host-certificates @@ -0,0 +1,2 @@ +- Added the certificates list to the host details page for Windows hosts, showing each certificate's scope (System or + User). This requires osquery 5.23.1 or higher on the host. diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index a6e9e290e7f..765a47294cb 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1315,7 +1315,8 @@ const HostDetailsPage = ({ const showAgentOptionsCard = !isIosOrIpadosHost && !isAndroidHost; const showLocalUserAccountsCard = !isIosOrIpadosHost && !isAndroidHost; const showCertificatesCard = - isAppleDeviceHost && !!hostCertificates?.certificates.length; + (isAppleDeviceHost || isWindowsHost) && + !!hostCertificates?.certificates.length; const renderSoftwareCard = () => { return ( diff --git a/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx b/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx index be6156069ed..c8f51cd1ef6 100644 --- a/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx @@ -59,7 +59,11 @@ const CertificatesCard = ({ return ( { + it("renders the platform-agnostic 'Scope' column header (replacing 'Keychain')", () => { + const render = createCustomRenderer(); + render( + + ); + + expect(screen.getByText("Scope")).toBeInTheDocument(); + expect(screen.queryByText("Keychain")).not.toBeInTheDocument(); + }); + + it("shows macOS keychain help text on a darwin host", () => { + const render = createCustomRenderer(); + render( + + ); + + expect(screen.getByText(/login \(user\) keychain/i)).toBeInTheDocument(); + }); + + it("shows Personal certificate store help text on a windows host", () => { + const render = createCustomRenderer(); + render( + + ); + + expect(screen.getByText(/Personal certificate store/i)).toBeInTheDocument(); + }); + + it("renders the User scope for a user-scoped certificate", () => { + const render = createCustomRenderer(); + render( + + ); + + expect(screen.getByText("User")).toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx index 559470f714a..9929999a7ba 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -4,6 +4,7 @@ import { Row } from "react-table"; import { IHostCertificate } from "interfaces/certificates"; import { IGetHostCertificatesResponse } from "services/entities/hosts"; import { IListSort } from "interfaces/list_options"; +import { HostPlatform } from "interfaces/platform"; import TableContainer from "components/TableContainer"; import CustomLink from "components/CustomLink"; @@ -16,6 +17,7 @@ const baseClass = "certificates-table"; interface ICertificatesTableProps { data: IGetHostCertificatesResponse; + hostPlatform: HostPlatform; showHelpText: boolean; page: number; pageSize: number; @@ -29,6 +31,7 @@ interface ICertificatesTableProps { const CertificatesTable = ({ data, + hostPlatform, showHelpText, page, pageSize, @@ -67,8 +70,9 @@ const CertificatesTable = ({ const helpText = showHelpText ? (

- Showing certificates in the system and login (user) keychain. To get all - certificates, you can query the certificates table.{" "} + {hostPlatform === "windows" + ? "Showing certificates in the Personal certificate store. To get all certificates, you can query the certificates table. " + : "Showing certificates in the system and login (user) keychain. To get all certificates, you can query the certificates table. "} { { accessor: "source", disableSortBy: true, - Header: "Keychain", + Header: "Scope", Cell: (cellProps) => { if (cellProps.cell.value === "system") { return ; diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index f273a495763..4b6b229a85b 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -12766,9 +12766,9 @@ func testMDMTurnOffSoftDeletesMDMCertificates(t *testing.T, ds *Datastore) { require.NoError(t, ds.SetOrUpdateMDMData(ctx, host.ID, false, true, "https://mdm.example.com", false, "Fleet", "", false)) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{mkCert(host.ID, "osquery.example.com")}, fleet.HostCertificateOriginOsquery)) + []*fleet.HostCertificateRecord{mkCert(host.ID, "osquery.example.com")}, fleet.HostCertificateOriginOsquery, nil)) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{mkCert(host.ID, "mdm-acme.example.com")}, fleet.HostCertificateOriginMDM)) + []*fleet.HostCertificateRecord{mkCert(host.ID, "mdm-acme.example.com")}, fleet.HostCertificateOriginMDM, nil)) certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{OrderKey: "common_name"}) require.NoError(t, err) diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index f8b7144255f..4819ff224f7 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -41,12 +41,55 @@ func (ds *Datastore) ListHostCertificates(ctx context.Context, hostID uint, opts return listHostCertsDB(ctx, ds.reader(ctx), hostID, opts) } -func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { +func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { type certSourceToSet struct { Source fleet.HostCertificateSource Username string } + // observedScopes restricts which (source, username) scopes reconciliation may + // soft-delete. A nil slice means every scope was observed this run (the macOS + // keychain model, where all keychains are always readable, and the MDM path), + // so reconciliation behaves exactly as before. A non-nil slice (the Windows + // path) preserves certificates whose scope is not listed, because osquery can + // only enumerate a user's certificates while that user is logged in. + var observedSet map[certSourceToSet]struct{} + if observedScopes != nil { + observedSet = make(map[certSourceToSet]struct{}, len(observedScopes)) + for _, s := range observedScopes { + observedSet[certSourceToSet{Source: s.Source, Username: s.Username}] = struct{}{} + } + } + isObserved := func(s certSourceToSet) bool { + if observedScopes == nil { + return true + } + _, ok := observedSet[s] + return ok + } + // desiredSources returns the source set to persist for a certificate: every + // source reported in the incoming batch, plus any existing source whose scope + // was NOT observed this run (so e.g. a logged-off Windows user's source is + // preserved). For the macOS/MDM path (nil observedScopes) every scope is + // observed, so this returns exactly the incoming sources. + desiredSources := func(incoming, existing []certSourceToSet) []certSourceToSet { + result := append([]certSourceToSet(nil), incoming...) + have := make(map[certSourceToSet]struct{}, len(incoming)) + for _, s := range incoming { + have[s] = struct{}{} + } + for _, s := range existing { + if isObserved(s) { + continue + } + if _, ok := have[s]; ok { + continue + } + result = append(result, s) + } + return result + } + incomingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(certs)) incomingSourcesBySHA1 := make(map[string][]certSourceToSet, len(certs)) for _, cert := range certs { @@ -118,11 +161,15 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho } return strings.Compare(a.Username, b.Username) } - slices.SortFunc(incomingSources, sliceSortFunc) + // Persist the reported sources plus any existing source in a scope we did + // not observe (preserving a logged-off user's source). For the macOS/MDM + // path this is exactly incomingSources. + newSources := desiredSources(incomingSources, existingSources) + slices.SortFunc(newSources, sliceSortFunc) slices.SortFunc(existingSources, sliceSortFunc) - if !slices.Equal(incomingSources, existingSources) { - toSetSourcesBySHA1[sha1] = incomingSources + if !slices.Equal(newSources, existingSources) { + toSetSourcesBySHA1[sha1] = newSources } existing, hasExisting := existingBySHA1[sha1] @@ -306,14 +353,28 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho toDelete := make([]uint, 0, len(existingBySHA1)) for sha1, existing := range existingBySHA1 { - if _, ok := incomingBySHA1[sha1]; !ok { - // Source-scoped delete: only remove rows whose origin matches the - // calling ingestion source. An osquery sync omitting an MDM-only cert - // must not delete that cert, and vice versa. - if existing.Origin != origin { - continue - } + if _, ok := incomingBySHA1[sha1]; ok { + // present in the incoming batch, reconciled above + continue + } + // Source-scoped delete: only remove rows whose origin matches the + // calling ingestion source. An osquery sync omitting an MDM-only cert + // must not delete that cert, and vice versa. + if existing.Origin != origin { + continue + } + // Preserve sources in scopes we did not observe this run (e.g. a + // logged-off Windows user) and drop the observed-but-no-longer-reported + // ones. Soft-delete the certificate only when no live source remains. + // For the macOS/MDM path (all scopes observed) nothing is preserved, so + // an absent certificate is always fully deleted, exactly as before. + existingSources := existingSourcesBySHA1[sha1] + preserved := desiredSources(nil, existingSources) + switch { + case len(preserved) == 0: toDelete = append(toDelete, existing.ID) + case len(preserved) != len(existingSources): + toSetSourcesBySHA1[sha1] = preserved } } diff --git a/server/datastore/mysql/host_certificates_test.go b/server/datastore/mysql/host_certificates_test.go index 109d28d8044..9317a953deb 100644 --- a/server/datastore/mysql/host_certificates_test.go +++ b/server/datastore/mysql/host_certificates_test.go @@ -33,6 +33,7 @@ func TestHostCertificates(t *testing.T) { {"Insert host_mdm_managed_certificates from non-proxied ingestion", testInsertingHostMDMManagedCertificatesFromIngestion}, {"Matcher recovers stuck hmmc rows", testMatcherRecoversStuckHMMCRows}, {"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation}, + {"Windows scope-aware reconciliation", testUpdateHostCertificatesWindowsScopeReconciliation}, {"Origin-scoped delete", testUpdateHostCertificatesOriginScopedDelete}, {"Origin downgrade on osquery rediscovery", testUpdateHostCertificatesOriginDowngrade}, {"Create certificates with long country code", testHostCertificateWithInvalidCountryCode}, @@ -83,7 +84,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { generateTestHostCertificateRecord(t, 1, &expected2), } - require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery, nil)) // verify that we saved the records correctly certs, meta, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", IncludeMetadata: true}) @@ -107,7 +108,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { require.Equal(t, expected2.Subject.CommonName, certs[1].SubjectCommonName) // simulate removal of a certificate - require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1]}, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1]}, fleet.HostCertificateOriginOsquery, nil)) certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"}) require.NoError(t, err) require.Len(t, certs, 1) @@ -117,7 +118,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { // re-add first certificate but as a "user" source payload[0].Source = fleet.UserHostCertificate payload[0].Username = "A" - require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[0], payload[1]}, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[0], payload[1]}, fleet.HostCertificateOriginOsquery, nil)) certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"}) require.NoError(t, err) require.Len(t, certs, 2) @@ -161,7 +162,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { for _, c := range cases { t.Log(c.desc) - err := ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", c.ingest, fleet.HostCertificateOriginOsquery) + err := ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", c.ingest, fleet.HostCertificateOriginOsquery, nil) require.NoError(t, err) certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", TestSecondaryOrderKey: "username"}) require.NoError(t, err) @@ -307,7 +308,7 @@ func testUpdatingHostMDMManagedCertificates(t *testing.T, ds *Datastore) { generateTestHostCertificateRecord(t, host.ID, &expected3), } - require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery, nil)) // verify that we saved the records correctly certs, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{OrderKey: "common_name"}) @@ -354,7 +355,7 @@ func testUpdatingHostMDMManagedCertificates(t *testing.T, ds *Datastore) { assert.Equal(t, "step-ca", profile2.CAName) // simulate removal of a certificate - require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1], payload[2]}, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1], payload[2]}, fleet.HostCertificateOriginOsquery, nil)) certs3, _, err := ds.ListHostCertificates(context.Background(), host.ID, fleet.ListOptions{OrderKey: "common_name"}) require.NoError(t, err) require.Len(t, certs3, 2) @@ -497,7 +498,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) { for _, c := range certs { payload = append(payload, generateTestHostCertificateRecord(t, host.ID, c)) } - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery, nil)) return payload } @@ -508,7 +509,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) { payload = append(payload, existingRecs...) unrelated := unrelatedCertTemplate(fmt.Sprintf("unrelated-%d", unrelatedSerial), 24*time.Hour, unrelatedSerial) payload = append(payload, generateTestHostCertificateRecord(t, host.ID, unrelated)) - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery, nil)) } t.Run("MissedIngestRecovered", func(t *testing.T) { @@ -621,7 +622,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) { backdateHMMC(t, profileUUID, 5*time.Hour) // Re-pass the same records — toInsert will be empty, but recovery still runs. - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, recs, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, recs, fleet.HostCertificateOriginOsquery, nil)) got := getApple(t, profileUUID, "ca-stable") require.NotNil(t, got.NotValidAfter) @@ -644,7 +645,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) { olderCert := renewalCertTemplate(profileUUID, "-old", time.Now().Add(-48*time.Hour).Truncate(time.Second).UTC(), time.Now().Add(48*time.Hour).Truncate(time.Second).UTC(), 4501) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{ generateTestHostCertificateRecord(t, host.ID, olderCert), - }, fleet.HostCertificateOriginOsquery)) + }, fleet.HostCertificateOriginOsquery, nil)) got := getApple(t, profileUUID, "ca-mono") require.NotNil(t, got.NotValidAfter) @@ -807,7 +808,7 @@ func testInsertingHostMDMManagedCertificatesFromIngestion(t *testing.T, ds *Data generateTestHostCertificateRecord(t, host.ID, &certUnrelated), } - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery, nil)) // nonProxiedProfileUUID — row was inserted with NULL Type, matching cert's metadata. all, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID) @@ -852,7 +853,7 @@ func testInsertingHostMDMManagedCertificatesFromIngestion(t *testing.T, ds *Data generateTestHostCertificateRecord(t, host.ID, &certProxied), generateTestHostCertificateRecord(t, host.ID, &certUnrelated), } - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, renewedPayload, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, renewedPayload, fleet.HostCertificateOriginOsquery, nil)) all2, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID) require.NoError(t, err) @@ -914,6 +915,116 @@ func generateTestHostCertificateRecordWithParent(t *testing.T, hostID uint, cert return fleet.NewHostCertificateRecord(hostID, parsed) } +// testUpdateHostCertificatesWindowsScopeReconciliation exercises the +// observed-scopes reconciliation used by the Windows ingestion path: osquery +// can only enumerate a user's certificates while that user is logged in, so a +// logged-off user's certificates must be preserved rather than soft-deleted. +// (The macOS / MDM path passes nil observedScopes and is covered by the other +// subtests, which always reconcile every scope.) +func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Datastore) { + ctx := t.Context() + const ( + hostID = uint(42) + hostUUID = "windows-scope-host-uuid" + ) + + mkCert := func(commonName string, source fleet.HostCertificateSource, username string) *fleet.HostCertificateRecord { + tmpl := x509.Certificate{ + Subject: pkix.Name{CommonName: commonName, Organization: []string{"Org"}}, + Issuer: pkix.Name{CommonName: "issuer.example.com"}, + SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec + NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(), + NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(), + BasicConstraintsValid: true, + } + rec := generateTestHostCertificateRecord(t, hostID, &tmpl) + rec.Source = source + rec.Username = username + return rec + } + + listKeys := func() []string { + certs, _, err := ds.ListHostCertificates(ctx, hostID, fleet.ListOptions{OrderKey: "common_name"}) + require.NoError(t, err) + keys := make([]string, 0, len(certs)) + for _, c := range certs { + keys = append(keys, fmt.Sprintf("%s|%s|%s", c.CommonName, c.Source, c.Username)) + } + return keys + } + + sysScope := fleet.HostCertificateScope{Source: fleet.SystemHostCertificate} + aliceScope := fleet.HostCertificateScope{Source: fleet.UserHostCertificate, Username: "alice"} + bobScope := fleet.HostCertificateScope{Source: fleet.UserHostCertificate, Username: "bob"} + + certSys := mkCert("sys.example.com", fleet.SystemHostCertificate, "") + certAlice := mkCert("alice-old.example.com", fleet.UserHostCertificate, "alice") + certBob := mkCert("bob.example.com", fleet.UserHostCertificate, "bob") + // shared cert: present in the System store and in alice's store (same SHA1, + // two sources). + sharedSys := mkCert("shared.example.com", fleet.SystemHostCertificate, "") + sharedAliceClone := *sharedSys + sharedAlice := &sharedAliceClone + sharedAlice.Source = fleet.UserHostCertificate + sharedAlice.Username = "alice" + + // 1. Initial report: alice and bob both logged in. + require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, + []*fleet.HostCertificateRecord{certSys, certAlice, certBob, sharedSys, sharedAlice}, + fleet.HostCertificateOriginOsquery, + []fleet.HostCertificateScope{sysScope, aliceScope, bobScope})) + require.ElementsMatch(t, []string{ + "sys.example.com|system|", + "alice-old.example.com|user|alice", + "bob.example.com|user|bob", + "shared.example.com|system|", + "shared.example.com|user|alice", + }, listKeys()) + + // 2. Alice logs off: her hive is not loaded so her certs are simply absent. + // Bob is still logged in. Alice's certs (including her shared source) must + // be preserved. + require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, + []*fleet.HostCertificateRecord{certSys, certBob, sharedSys}, + fleet.HostCertificateOriginOsquery, + []fleet.HostCertificateScope{sysScope, bobScope})) + require.ElementsMatch(t, []string{ + "sys.example.com|system|", + "alice-old.example.com|user|alice", // preserved (alice not observed) + "bob.example.com|user|bob", + "shared.example.com|system|", + "shared.example.com|user|alice", // preserved + }, listKeys()) + + // 3. Alice logs back in but has removed her old cert and her copy of the + // shared cert, and installed a new one. Because alice is observed this run, + // her removed certs are reconciled, while the shared cert survives via its + // still-reported System source. + certAlice2 := mkCert("alice-new.example.com", fleet.UserHostCertificate, "alice") + require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, + []*fleet.HostCertificateRecord{certSys, certBob, sharedSys, certAlice2}, + fleet.HostCertificateOriginOsquery, + []fleet.HostCertificateScope{sysScope, aliceScope, bobScope})) + require.ElementsMatch(t, []string{ + "sys.example.com|system|", + "bob.example.com|user|bob", + "shared.example.com|system|", // alice's shared source dropped, system kept + "alice-new.example.com|user|alice", + }, listKeys()) + + // 4. A System certificate removed while present in the report → deleted, since + // System scope is always observed. + require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, + []*fleet.HostCertificateRecord{certBob, sharedSys, certAlice2}, + fleet.HostCertificateOriginOsquery, + []fleet.HostCertificateScope{sysScope, aliceScope, bobScope})) + require.ElementsMatch(t, []string{ + "bob.example.com|user|bob", + "shared.example.com|system|", + "alice-new.example.com|user|alice", + }, listKeys()) +} + func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { // regression test for #30574 ctx := context.Background() @@ -985,8 +1096,8 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { host2Cert.Username = "jsmith" // Add the same certificate to both hosts - require.NoError(t, ds.UpdateHostCertificates(ctx, host1.ID, host1.UUID, []*fleet.HostCertificateRecord{host1Cert}, fleet.HostCertificateOriginOsquery)) - require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2Cert}, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host1.ID, host1.UUID, []*fleet.HostCertificateRecord{host1Cert}, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2Cert}, fleet.HostCertificateOriginOsquery, nil)) // Verify both hosts have the correct certs, with the correct sources host1Certs, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{}) @@ -1007,7 +1118,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { host2CertUpdated.Source = fleet.UserHostCertificate host2CertUpdated.Username = "janesmith" - require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery, nil)) // Verify host1's certificate source was *not* updated host1CertsAfter, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{}) @@ -1022,7 +1133,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { require.Equal(t, "janesmith", host2CertsAfter[0].Username) // Verify no-op case - err = ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery) + err = ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery, nil) require.NoError(t, err) // Verify host2's certificate source was updated @@ -1036,7 +1147,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { systemCertOnHost2 := fleet.NewHostCertificateRecord(host2.ID, parsed) systemCertOnHost2.Source = fleet.SystemHostCertificate - require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated, systemCertOnHost2}, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated, systemCertOnHost2}, fleet.HostCertificateOriginOsquery, nil)) // Verify host2 now has the certificate with both sources host2CertsMultiSource, _, err := ds.ListHostCertificates(ctx, host2.ID, fleet.ListOptions{}) @@ -1108,9 +1219,9 @@ func testUpdateHostCertificatesOriginScopedDelete(t *testing.T, ds *Datastore) { // Initial state: osquery reports osqueryOnly; MDM reports mdmOnly. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery)) + []*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery, nil)) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{mdmOnly}, fleet.HostCertificateOriginMDM)) + []*fleet.HostCertificateRecord{mdmOnly}, fleet.HostCertificateOriginMDM, nil)) certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -1131,7 +1242,7 @@ func testUpdateHostCertificatesOriginScopedDelete(t *testing.T, ds *Datastore) { // Osquery sync runs again with an EMPTY cert list. The osquery-only cert // should be soft-deleted, but the mdm-only cert must survive. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginOsquery)) + []*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginOsquery, nil)) certs, _, err = ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -1142,9 +1253,9 @@ func testUpdateHostCertificatesOriginScopedDelete(t *testing.T, ds *Datastore) { // Now the symmetric case: osquery re-reports its cert, MDM sync runs with an // empty list. The mdm-only cert should be soft-deleted, osquery-only survives. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery)) + []*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery, nil)) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginMDM)) + []*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginMDM, nil)) certs, _, err = ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -1197,7 +1308,7 @@ func testUpdateHostCertificatesOriginDowngrade(t *testing.T, ds *Datastore) { // MDM ingestion sees both certs first. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{rootCA, mdmDelivered}, fleet.HostCertificateOriginMDM)) + []*fleet.HostCertificateRecord{rootCA, mdmDelivered}, fleet.HostCertificateOriginMDM, nil)) originByCN := func() map[string]fleet.HostCertificateOrigin { certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{}) @@ -1215,7 +1326,7 @@ func testUpdateHostCertificatesOriginDowngrade(t *testing.T, ds *Datastore) { // Osquery rediscovers the Root CA; row downgrades. MDM-only cert unchanged. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{rootCA}, fleet.HostCertificateOriginOsquery)) + []*fleet.HostCertificateRecord{rootCA}, fleet.HostCertificateOriginOsquery, nil)) require.Equal(t, map[string]fleet.HostCertificateOrigin{ "user-installed-root-ca": fleet.HostCertificateOriginOsquery, "mdm-delivered-only": fleet.HostCertificateOriginMDM, @@ -1223,7 +1334,7 @@ func testUpdateHostCertificatesOriginDowngrade(t *testing.T, ds *Datastore) { // Downgrade is sticky across repeated osquery syncs. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{rootCA}, fleet.HostCertificateOriginOsquery)) + []*fleet.HostCertificateRecord{rootCA}, fleet.HostCertificateOriginOsquery, nil)) require.Equal(t, fleet.HostCertificateOriginOsquery, originByCN()["user-installed-root-ca"]) // Source-scoped delete preserved: osquery omitting the MDM-only cert does not soft-delete it. @@ -1233,7 +1344,7 @@ func testUpdateHostCertificatesOriginDowngrade(t *testing.T, ds *Datastore) { // One-way: MDM rediscovery does not re-upgrade the downgraded row. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{rootCA, mdmDelivered}, fleet.HostCertificateOriginMDM)) + []*fleet.HostCertificateRecord{rootCA, mdmDelivered}, fleet.HostCertificateOriginMDM, nil)) require.Equal(t, map[string]fleet.HostCertificateOrigin{ "user-installed-root-ca": fleet.HostCertificateOriginOsquery, "mdm-delivered-only": fleet.HostCertificateOriginMDM, @@ -1321,7 +1432,7 @@ func testHostCertificateWithInvalidCountryCode(t *testing.T, ds *Datastore) { payload[1].SubjectCountry = certWithNormalCountryTemplate.Subject.Country[0] payload[1].IssuerCountry = parentWithLongIssuerCountryTemplate.Subject.Country[0] - require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery, nil)) // verify that we saved the records correctly certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"}) @@ -1430,7 +1541,7 @@ func testTruncateLongCertificateFields(t *testing.T, ds *Datastore) { require.NoError(t, err) // Update certificates - this should trigger truncation - err = ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{cert}, fleet.HostCertificateOriginOsquery) + err = ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{cert}, fleet.HostCertificateOriginOsquery, nil) require.NoError(t, err) // Retrieve the certificate and verify all fields were truncated @@ -1513,7 +1624,7 @@ func testListHostCertificatesCountMatches(t *testing.T, ds *Datastore) { certUser.Source = fleet.UserHostCertificate certUser.Username = "alice" - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser}, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser}, fleet.HostCertificateOriginOsquery, nil)) // Now list with metadata certs, meta, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{IncludeMetadata: true}) @@ -1570,15 +1681,15 @@ func testSoftDeleteMDMHostCertificatesForUnenrolledHosts(t *testing.T, ds *Datas // Unenrolled host with both an osquery-origin and an mdm-origin cert. unenrolled := newHost("unenrolled") require.NoError(t, ds.UpdateHostCertificates(ctx, unenrolled.ID, unenrolled.UUID, - []*fleet.HostCertificateRecord{mkCert(unenrolled.ID, "u-osquery")}, fleet.HostCertificateOriginOsquery)) + []*fleet.HostCertificateRecord{mkCert(unenrolled.ID, "u-osquery")}, fleet.HostCertificateOriginOsquery, nil)) require.NoError(t, ds.UpdateHostCertificates(ctx, unenrolled.ID, unenrolled.UUID, - []*fleet.HostCertificateRecord{mkCert(unenrolled.ID, "u-mdm")}, fleet.HostCertificateOriginMDM)) + []*fleet.HostCertificateRecord{mkCert(unenrolled.ID, "u-mdm")}, fleet.HostCertificateOriginMDM, nil)) require.NoError(t, ds.SetOrUpdateMDMData(ctx, unenrolled.ID, false, false, "https://mdm.example.com", false, "Fleet", "", false)) // Enrolled host with an mdm-origin cert that must NOT be swept. enrolled := newHost("enrolled") require.NoError(t, ds.UpdateHostCertificates(ctx, enrolled.ID, enrolled.UUID, - []*fleet.HostCertificateRecord{mkCert(enrolled.ID, "e-mdm")}, fleet.HostCertificateOriginMDM)) + []*fleet.HostCertificateRecord{mkCert(enrolled.ID, "e-mdm")}, fleet.HostCertificateOriginMDM, nil)) require.NoError(t, ds.SetOrUpdateMDMData(ctx, enrolled.ID, false, true, "https://mdm.example.com", false, "Fleet", "", false)) count, err := ds.SoftDeleteMDMHostCertificatesForUnenrolledHosts(ctx) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index b2b2c50e554..abf386ba94a 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -9522,7 +9522,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { NotValidAfter: now.Add(365 * 24 * time.Hour), Source: fleet.SystemHostCertificate, Username: "test-user", - }}, fleet.HostCertificateOriginOsquery)) + }}, fleet.HostCertificateOriginOsquery, nil)) // create an android device from this host deviceID := strings.ReplaceAll(uuid.NewString(), "-", "") diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 0295833e1d1..f56d9dffb8c 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -477,8 +477,12 @@ type Datastore interface { ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificateRecord, *PaginationMetadata, error) // UpdateHostCertificates ingests certs reported by `origin`. Each call only // soft-deletes existing rows whose origin matches, so osquery and MDM - // ingestion don't clobber each other's view. - UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*HostCertificateRecord, origin HostCertificateOrigin) error + // ingestion don't clobber each other's view. observedScopes further limits + // reconciliation to the (source, username) scopes the agent could + // authoritatively enumerate this run; a nil slice means every scope was + // observed (macOS keychains and the MDM path), while a non-nil slice (Windows) + // preserves certificates for scopes it could not see, e.g. a logged-off user. + UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*HostCertificateRecord, origin HostCertificateOrigin, observedScopes []HostCertificateScope) error // SoftDeleteMDMHostCertificatesForUnenrolledHosts soft-deletes MDM-origin // cert rows for hosts reporting host_mdm.enrolled=0 — the cron complement to diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index aa7b7431ef7..3d50d75f2ab 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -41,6 +41,22 @@ const ( HostCertificateOriginMDM HostCertificateOrigin = "mdm" ) +// HostCertificateScope identifies a single (source, username) certificate +// scope. It is used to tell UpdateHostCertificates which scopes the agent could +// authoritatively enumerate during a collection cycle, so reconciliation does +// not soft-delete certificates for a scope it could not observe. +// +// A user's Windows certificates are only visible to osquery while that user is +// logged in (their registry hive is loaded), so the Windows ingestion path +// passes the set of observed scopes and absent users' certificates are +// preserved. The macOS path reads every keychain from disk on every run, so it +// passes a nil slice, meaning "all scopes observed" and any absent certificate +// may be deleted. +type HostCertificateScope struct { + Source HostCertificateSource + Username string +} + // HostCertificateRecord is the database model for a host certificate. type HostCertificateRecord struct { ID uint `json:"-" db:"id"` @@ -275,50 +291,108 @@ func parseDarwinDN(dn string) (*HostCertificateNameDetails, error) { value = strings.ReplaceAll(strings.Trim(value, " "), `<>`, `/`) // Replace our "safe" sequence with forward slash - switch strings.ToUpper(key) { - case "C": - details.Country = strings.Trim(value, " ") - case "O": - details.Organization = strings.Trim(value, " ") - case "OU": - // osquery is inconsistent in how it reports certs with multiple OUs; sometimes it - // concatenates them all joined by `+OU=` separator within the same `/` delimited - // string, other times it provides multiple `/` delimited strings that each contain - // distinct OU values. For example, compare the following two lines: - // /OU=SomeValue/OU=fleet-a3d5d6f4c-819e-4159-9a42-0d6243a80ff8/CN=SomeName - // /OU=SomeValue+OU=fleet-a0c039413-d0c7-4b1f-9488-b93c865351ac/CN=SomeName - // - // To handle both cases, we collect all OU values and join them with `+OU=` below. - // We should probably reconsider our approaches for normalization of cert data - // across the board. - // FIXME: How should this work with the edge case covered by PR 33152 (e.g., "+" separator above)? - ouParts = append(ouParts, strings.Trim(value, " ")) - case "CN": - details.CommonName = strings.Trim(value, " ") - } + applyDNAttribute(&details, &ouParts, key, value) } details.OrganizationalUnit = strings.Join(ouParts, "+OU=") return &details, nil } -// FIXME: parseWindowsDN takes a distinguished name string from a Windows host but does not parse it -// because the format of the distinguished name as reported by osquery on Windows hosts is not -// well-ordered. For now, it simply sets the provided string as the CommonName and leaves other fields -// empty. +// applyDNAttribute assigns a single distinguished-name attribute (key/value +// pair) to the matching field of details. It is shared by the macOS and Windows +// distinguished-name parsers so the attribute → field mapping stays identical +// across platforms (only the tokenization differs). Organizational units are +// accumulated because osquery can report multiple OU values for one +// certificate; the caller joins them. Attributes Fleet does not display (state, +// locality, bare dotted-decimal OIDs, ...) are ignored. +func applyDNAttribute(details *HostCertificateNameDetails, ouParts *[]string, key, value string) { + switch strings.ToUpper(strings.TrimSpace(key)) { + case "C": + details.Country = value + case "O": + details.Organization = value + case "OU": + // osquery is inconsistent in how it reports certs with multiple OUs; sometimes it + // concatenates them all joined by `+OU=` separator within the same `/` delimited + // string, other times it provides multiple `/` delimited strings that each contain + // distinct OU values. For example, compare the following two lines: + // /OU=SomeValue/OU=fleet-a3d5d6f4c-819e-4159-9a42-0d6243a80ff8/CN=SomeName + // /OU=SomeValue+OU=fleet-a0c039413-d0c7-4b1f-9488-b93c865351ac/CN=SomeName + // + // To handle both cases, we collect all OU values and join them with `+OU=` (done by + // the caller). We should probably reconsider our approaches for normalization of cert + // data across the board. + *ouParts = append(*ouParts, value) + case "CN": + details.CommonName = value + } +} + +// parseWindowsDN parses a distinguished name in the X.500 string form that +// osquery emits in the `subject2` / `issuer2` columns on Windows starting with +// osquery 5.23.1 (osquery/osquery#8963), for example: +// +// CN=Example, O="Example, Inc.", OU=A + OU=B, C=US // -// To address this, we will likely need to modify the osquery certificates table. The issue is that -// osquery on Windows reports only the values in a comma-separated list without corresponding keys -// (instead of key-value pairs as on macOS, e.g., /C=US/O=Org/OU=Unit/CN=Name), When a value is missing -// (country, for example), the list shifts left such that the position of the values is not -// consistent, making it very difficult to map which value is which. +// Relative distinguished names (RDNs) are comma-separated; a multi-valued RDN +// joins its attributes with `+`; a value containing a separator (`,`, `+`, `=`, +// ...) is wrapped in double quotes with any embedded quote doubled. Unlike the +// macOS form parsed by parseDarwinDN (a slash-delimited openSSL style with the +// attribute keys preserved), this form is comma-delimited and quoted, so it +// needs its own tokenizer. Malformed fragments are skipped rather than failing +// the whole certificate, since a single odd attribute should not block +// ingestion of the batch. func parseWindowsDN(dn string) (*HostCertificateNameDetails, error) { - return &HostCertificateNameDetails{ - CommonName: dn, - Country: "", - Organization: "", - OrganizationalUnit: "", - }, nil + var details HostCertificateNameDetails + var ouParts []string + for _, attr := range splitX500Attributes(dn) { + key, value, found := strings.Cut(attr, "=") + if !found { + continue + } + applyDNAttribute(&details, &ouParts, key, unquoteX500Value(strings.TrimSpace(value))) + } + details.OrganizationalUnit = strings.Join(ouParts, "+OU=") + + return &details, nil +} + +// splitX500Attributes splits an X.500 distinguished name into its individual +// `key=value` attributes, treating both `,` (RDN separator) and `+` +// (multi-valued RDN separator) as delimiters but ignoring any delimiter that +// appears inside a double-quoted value. +func splitX500Attributes(dn string) []string { + var attrs []string + var buf strings.Builder + inQuotes := false + for i := 0; i < len(dn); i++ { + c := dn[i] + switch { + case c == '"': + inQuotes = !inQuotes + buf.WriteByte(c) + case (c == ',' || c == '+') && !inQuotes: + attrs = append(attrs, buf.String()) + buf.Reset() + default: + buf.WriteByte(c) + } + } + if buf.Len() > 0 { + attrs = append(attrs, buf.String()) + } + return attrs +} + +// unquoteX500Value removes the surrounding double quotes that CERT_X500_NAME_STR +// adds to a value containing special characters, and un-doubles any escaped +// quote inside it. A value without surrounding quotes is returned unchanged. +func unquoteX500Value(v string) string { + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + v = v[1 : len(v)-1] + v = strings.ReplaceAll(v, `""`, `"`) + } + return v } // DecodeHexEscapes replaces literal \xHH escape sequences with the actual byte values. diff --git a/server/fleet/host_certificates_test.go b/server/fleet/host_certificates_test.go index 36385d06a56..4427a78119e 100644 --- a/server/fleet/host_certificates_test.go +++ b/server/fleet/host_certificates_test.go @@ -173,6 +173,92 @@ func TestExtractHostCertificateNameDetails(t *testing.T) { } } +func TestExtractWindowsCertificateNameDetails(t *testing.T) { + // osquery 5.23.1+ reports the Windows subject2 / issuer2 columns in the X.500 + // CERT_X500_NAME_STR form: comma-separated key=value RDNs, with values quoted + // when they contain separators and multi-valued RDNs joined by '+'. + cases := []struct { + name string + input string + expected *HostCertificateNameDetails + }{ + { + name: "basic", + input: "CN=Example, O=Example Inc, C=US", + expected: &HostCertificateNameDetails{ + CommonName: "Example", + Organization: "Example Inc", + Country: "US", + }, + }, + { + name: "with organizational unit", + input: "CN=device.example.com, OU=Engineering, O=Example Inc, C=US", + expected: &HostCertificateNameDetails{ + CommonName: "device.example.com", + OrganizationalUnit: "Engineering", + Organization: "Example Inc", + Country: "US", + }, + }, + { + name: "quoted value containing a comma", + input: `CN=Issuing CA, O="Example, Inc.", C=US`, + expected: &HostCertificateNameDetails{ + CommonName: "Issuing CA", + Organization: "Example, Inc.", + Country: "US", + }, + }, + { + name: "multiple organizational units", + input: "OU=Engineering, OU=fleet-a3ffb5cfa-3c69-433f-88af-d982ef9c3f67, CN=Multi OU, C=US", + expected: &HostCertificateNameDetails{ + OrganizationalUnit: "Engineering+OU=fleet-a3ffb5cfa-3c69-433f-88af-d982ef9c3f67", + CommonName: "Multi OU", + Country: "US", + }, + }, + { + name: "multi-valued RDN joined by plus", + input: "CN=Name + OU=A + OU=B, C=US", + expected: &HostCertificateNameDetails{ + CommonName: "Name", + OrganizationalUnit: "A+OU=B", + Country: "US", + }, + }, + { + name: "doubled quotes inside a quoted value", + input: `CN="A ""quoted"" name", C=US`, + expected: &HostCertificateNameDetails{ + CommonName: `A "quoted" name`, + Country: "US", + }, + }, + { + name: "unmapped attributes are ignored", + input: "CN=Name, S=California, L=San Francisco, 2.5.4.5=ABC123, C=US", + expected: &HostCertificateNameDetails{ + CommonName: "Name", + Country: "US", + }, + }, + { + name: "empty input returns empty details without error", + input: "", + expected: &HostCertificateNameDetails{}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + actual, err := ExtractDetailsFromOsqueryDistinguishedName("windows", tc.input) + require.NoError(t, err) + require.Equal(t, tc.expected, actual) + }) + } +} + func TestExtractHostCertificateFromMDMAppleCertificateList(t *testing.T) { privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index faac330823d..dcab2d83826 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -356,7 +356,7 @@ type IsHostConnectedToFleetMDMFunc func(ctx context.Context, host *fleet.Host) ( type ListHostCertificatesFunc func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) -type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error +type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error type SoftDeleteMDMHostCertificatesForUnenrolledHostsFunc func(ctx context.Context) (int64, error) @@ -6475,11 +6475,11 @@ func (s *DataStore) ListHostCertificates(ctx context.Context, hostID uint, opts return s.ListHostCertificatesFunc(ctx, hostID, opts) } -func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { +func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { s.mu.Lock() s.UpdateHostCertificatesFuncInvoked = true s.mu.Unlock() - return s.UpdateHostCertificatesFunc(ctx, hostID, hostUUID, certs, origin) + return s.UpdateHostCertificatesFunc(ctx, hostID, hostUUID, certs, origin, observedScopes) } func (s *DataStore) SoftDeleteMDMHostCertificatesForUnenrolledHosts(ctx context.Context) (int64, error) { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 078ba480dfb..9b43c7f7faa 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -5211,7 +5211,9 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx conte payload = append(payload, parsed) } - if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginMDM); err != nil { + // nil observedScopes: MDM CertificateList reports the full set every time, so + // every scope is observed and any absent cert may be reconciled. + if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginMDM, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates") } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 8328a7225ca..4ed6674525c 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -15676,7 +15676,7 @@ func (s *integrationTestSuite) TestHostCertificates() { Source: fleet.SystemHostCertificate, }) } - require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery)) + require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery, nil)) // list all certs certResp = listHostCertificatesResponse{} diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 7fb028b9a13..3a503849cf6 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -864,15 +864,22 @@ var extraDetailQueries = map[string]DetailQuery{ "certificates_windows": { Query: ` SELECT - ca, common_name, subject, issuer, + ca, common_name, subject2, issuer2, key_algorithm, key_strength, key_usage, signing_algorithm, not_valid_after, not_valid_before, - serial, sha1, username, + serial, sha1, username, sid, store_location, path FROM certificates WHERE store = 'Personal';`, + // subject2/issuer2 preserve the distinguished name attribute keys (CN, O, + // OU, C). They are only populated on Windows starting with osquery 5.23.1 + // (osquery/osquery#8963); on older agents the columns do not exist, so + // selecting them would fail the whole query. Gate on the column's presence + // so older agents simply collect nothing (no error, no log spew) until the + // bundled osquery is upgraded. + Discovery: `SELECT 1 FROM pragma_table_info('certificates') WHERE name = 'subject2'`, Platforms: []string{"windows"}, DirectIngestFunc: directIngestHostCertificatesWindows, }, @@ -3603,7 +3610,9 @@ func directIngestHostCertificatesDarwin( return nil } - return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery) + // nil observedScopes: macOS reads every keychain file from disk on every run, + // so all scopes are observed and any absent cert may be reconciled. + return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery, nil) } func directIngestHostCertificatesWindows( @@ -3620,38 +3629,47 @@ func directIngestHostCertificatesWindows( } certs := make([]*fleet.HostCertificateRecord, 0, len(rows)) - // on windows, the osquery certificates table returns duplicate - // entries for the same certificate if it is present in multiple - // certificate stores so we deduplicate them here based on the - // SHA1 sum + username - existsSha1User := make(map[string]bool, len(rows)) + // On Windows, osquery enumerates the same certificate from multiple redundant + // registry hives (the LocalSystem account's CurrentUser/Services views, + // per-user `_Classes` sub-hives, etc.), so we deduplicate by SHA1 + scope + + // username. + seen := make(map[string]struct{}, len(rows)) for _, row := range rows { // Unescape \xHH sequences in fields that may contain non-ASCII // characters (e.g. Cyrillic) in the certificate's distinguished name. row["common_name"] = fleet.DecodeHexEscapes(row["common_name"]) - row["subject"] = fleet.DecodeHexEscapes(row["subject"]) - row["issuer"] = fleet.DecodeHexEscapes(row["issuer"]) + row["subject2"] = fleet.DecodeHexEscapes(row["subject2"]) + row["issuer2"] = fleet.DecodeHexEscapes(row["issuer2"]) csum, err := hex.DecodeString(row["sha1"]) if err != nil { logger.ErrorContext(ctx, "decoding sha1", "component", "service", "method", "directIngestHostCertificates", "err", err) continue } - subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["subject"]) + // subject2/issuer2 preserve the distinguished name attribute keys (osquery + // 5.23.1+); the collection query is gated on the subject2 column existing. + subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["subject2"]) if err != nil { logger.ErrorContext(ctx, "extracting subject details", "component", "service", "method", "directIngestHostCertificates", "err", err) continue } - issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["issuer"]) + issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["issuer2"]) if err != nil { logger.ErrorContext(ctx, "extracting issuer details", "component", "service", "method", "directIngestHostCertificates", "err", err) continue } - username := row["username"] - source := fleet.UserHostCertificate - if username == "SYSTEM" { - source = fleet.SystemHostCertificate + // Classify scope from the registry hive (sid), not the owner name. Only a + // real interactive account's hive SID is `S-1-5-21-*`; everything else (the + // machine-wide LocalMachine store, which reports an empty sid, and built-in + // service accounts such as `S-1-5-18` SYSTEM) is System scope with no owner. + // The old `username == "SYSTEM"` heuristic mislabeled LocalMachine certs + // (blank username) as user certs. + source := fleet.SystemHostCertificate + username := "" + if strings.HasPrefix(row["sid"], "S-1-5-21-") { + source = fleet.UserHostCertificate + username = row["username"] } cert := &fleet.HostCertificateRecord{ @@ -3678,13 +3696,16 @@ func directIngestHostCertificatesWindows( Username: username, } - // deduplicate by SHA1 + Username - sha1UserKey := fmt.Sprintf("%x|%s", csum, username) - if exists := existsSha1User[sha1UserKey]; exists { - logger.DebugContext(ctx, "skipping duplicate certificate for sha1+user", + // Deduplicate by SHA1 + scope + username. System rows all collapse + // (username forced to ""), and a user's redundant hive views collapse into + // one entry per username. + key := fmt.Sprintf("%x|%s|%s", csum, source, username) + if _, ok := seen[key]; ok { + logger.DebugContext(ctx, "skipping duplicate certificate for sha1+scope+user", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, + "source", source, "username", username, "sha1", fmt.Sprintf("%x", csum), "issuer", cert.IssuerCommonName, @@ -3692,7 +3713,7 @@ func directIngestHostCertificatesWindows( "path", row["path"]) continue } - existsSha1User[sha1UserKey] = true + seen[key] = struct{}{} certs = append(certs, cert) } @@ -3701,7 +3722,32 @@ func directIngestHostCertificatesWindows( return nil } - return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery) + // Tell the datastore which scopes we actually observed this run so it does not + // soft-delete a logged-off user's certificates (their registry hive is not + // loaded, so they are simply absent). System is always observed because the + // LocalMachine store is readable regardless of who is logged in. + return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery, windowsObservedCertScopes(certs)) +} + +// windowsObservedCertScopes returns the set of (source, username) scopes that +// osquery could authoritatively enumerate in this report. System scope is +// always included because the LocalMachine store is always readable; each user +// that reported at least one certificate is included as its own scope. +func windowsObservedCertScopes(certs []*fleet.HostCertificateRecord) []fleet.HostCertificateScope { + scopes := []fleet.HostCertificateScope{{Source: fleet.SystemHostCertificate}} + seen := map[string]struct{}{string(fleet.SystemHostCertificate) + "|": {}} + for _, c := range certs { + if c.Source != fleet.UserHostCertificate { + continue + } + key := string(c.Source) + "|" + c.Username + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + scopes = append(scopes, fleet.HostCertificateScope{Source: c.Source, Username: c.Username}) + } + return scopes } func maybeUpdateLastRestartedAt(now time.Time, host *fleet.Host) { diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index b6862110c6e..0f19cd28312 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -2943,7 +2943,7 @@ func TestDirectIngestHostCertificates(t *testing.T) { "path": "/Library/Keychains/System.keychain", } - ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { + ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { require.Equal(t, host.ID, hostID) require.Equal(t, host.UUID, hostUUID) require.Equal(t, fleet.HostCertificateOriginOsquery, origin) @@ -3023,7 +3023,7 @@ func TestDirectIngestHostCertificatesDarwinHexEscapes(t *testing.T) { "path": "/Library/Keychains/System.keychain", } - ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { + ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { require.Equal(t, fleet.HostCertificateOriginOsquery, origin) require.Len(t, certs, 1) cert := certs[0] @@ -3050,133 +3050,158 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { logger := slog.New(slog.DiscardHandler) host := &fleet.Host{ID: 1, UUID: "host-uuid", Platform: "windows"} - // Fleet SCEP cert example based on data from a real Windows host - c1 := map[string]string{ - "ca": "-1", - "common_name": "494FE0F794940E21C757B790494B0FAFD97CFA4D5E9CC75856DB00DE78F3958D", - "subject": "Fleet, 494FE0F794940E21C757B790494B0FAFD97CFA4D5E9CC75856DB00DE78F3958D", - "issuer": "\"\", scep-ca, SCEP CA, FleetDM", + const ( + userSID = "S-1-5-21-1043593016-4249271388-1765263865-1000" + secondUSID = "S-1-5-21-1043593016-4249271388-1765263865-1500" + ) + + // Common fields shared by every example row (osquery 5.23.1+ shapes, with the + // keyed subject2 / issuer2 columns). + base := map[string]string{ + "ca": "0", "key_algorithm": "RSA", - "key_strength": "2160", - "key_usage": "CERT_KEY_ENCIPHERMENT_KEY_USAGE,CERT_DIGITAL_SIGNATURE_KEY_USAGE", + "key_strength": "2048", + "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", "signing_algorithm": "sha256RSA", "not_valid_after": "1780784467", "not_valid_before": "1749248467", "serial": "05", - "sha1": "1A395245953C61AE12657704FF45F31A1E7BC1E8", - "username": "Admin", - "path": "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000\\Personal", } - // Custom SCEP cert example based on data from a real Windows host - c2 := map[string]string{ - "ca": "-1", - "common_name": "wc215384b-5a6e-4ca5-a2a3-1289734a5a71 User\n CN", - "subject": "fleet-w2a6fd2c4-0018-4bdc-8046-c7342962b576, \"wc215384b-5a6e-4ca5-a2a3-1289734a5a71 User\n CN\"", - "issuer": "US, scep-ca, SCEP CA, MICROMDM SCEP CA", - "key_algorithm": "RSA", - "key_strength": "1120", - "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", - "signing_algorithm": "sha256RSA", - "not_valid_after": "1796430423", - "not_valid_before": "1764893823", - "serial": "23", - "sha1": "EE5E756CC1A0782078C7C45180A4544A37D0F6D7", - "username": "Admin", - "path": "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000\\Personal", + row := func(overrides map[string]string) map[string]string { + r := maps.Clone(base) + maps.Copy(r, overrides) + return r } - // We'll use the examples above to create rows with minor variations, similar to what - // we would get from a real Windows host. - c3 := maps.Clone(c1) - c3["username"] = "SYSTEM" - c3["path"] = "Users\\S-1-5-18\\Personal" - - c4 := maps.Clone(c1) - c4["username"] = "SYSTEM" - c4["path"] = "CurrentUser\\Personal" - - c5 := maps.Clone(c1) - c5["username"] = "SYSTEM" - c5["path"] = "Users\\S-1-5-18\\Personal" + const ( + machineSHA1 = "AAAA1111BBBB2222CCCC3333DDDD4444EEEE5555" + sysAcctSHA1 = "1111AAAA2222BBBB3333CCCC4444DDDD5555EEEE" + userSHA1 = "EE5E756CC1A0782078C7C45180A4544A37D0F6D7" + ) - c6 := maps.Clone(c1) - c6["path"] = "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000_Classes\\Personal" + // Machine-wide LocalMachine store: empty sid and empty username. The old + // heuristic mislabeled this as a user cert; it must be System scope. + machine := row(map[string]string{ + "common_name": "Fleet Root CA", + "subject2": "CN=Fleet Root CA, O=Fleet Device Management Inc., OU=Engineering, C=US", + "issuer2": "CN=Fleet Root CA, O=Fleet Device Management Inc., C=US", + "sha1": machineSHA1, + "username": "", + "sid": "", + "store_location": "LocalMachine", + "path": "LocalMachine\\Personal", + }) - c7 := maps.Clone(c2) - c7["path"] = "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000_Classes\\Personal" + // LocalSystem account (S-1-5-18) store, enumerated three times across + // redundant hive views. These must collapse into a single System entry and be + // retained (a distinct cert from LocalMachine, often a device/enrollment cert). + sysAcctCurrentUser := row(map[string]string{ + "common_name": "Device Enrollment", + "subject2": "CN=Device Enrollment, C=US", + "issuer2": "CN=Fleet SCEP CA, C=US", + "sha1": sysAcctSHA1, + "username": "SYSTEM", + "sid": "S-1-5-18", + "store_location": "CurrentUser", + "path": "CurrentUser\\Personal", + }) + sysAcctServices := maps.Clone(sysAcctCurrentUser) + sysAcctServices["store_location"] = "Services" + sysAcctServices["path"] = "Services\\S-1-5-18\\Personal" + sysAcctUsersHive := maps.Clone(sysAcctCurrentUser) + sysAcctUsersHive["store_location"] = "Users" + sysAcctUsersHive["path"] = "Users\\S-1-5-18\\Personal" + + // Real interactive user (S-1-5-21-*), present in the Personal hive and the + // redundant _Classes sub-hive (same base SID). These collapse into one + // User/Admin entry. The issuer carries a quoted comma to exercise the parser. + userAdmin := row(map[string]string{ + "common_name": "admin@example.com", + "subject2": "CN=admin@example.com, OU=fleet-abc, OU=People, O=Example", + "issuer2": `CN=SCEP CA, O="Example, Inc.", C=US`, + "sha1": userSHA1, + "username": "Admin", + "sid": userSID, + "store_location": "Users", + "path": "Users\\" + userSID + "\\Personal", + }) + userAdminClasses := maps.Clone(userAdmin) + userAdminClasses["sid"] = userSID + "_Classes" + userAdminClasses["store_location"] = "Users" + userAdminClasses["path"] = "Users\\" + userSID + "_Classes\\Personal" + + // The same certificate (same SHA1) also installed in a second user's store + // must yield a separate User entry for that user. + userBob := maps.Clone(userAdmin) + userBob["username"] = "Bob" + userBob["sid"] = secondUSID + userBob["path"] = "Users\\" + secondUSID + "\\Personal" + + rows := []map[string]string{ + machine, + sysAcctCurrentUser, sysAcctServices, sysAcctUsersHive, + userAdmin, userAdminClasses, + userBob, + } - rows := []map[string]string{c1, c2, c3, c4, c5, c6, c7} + type scopeKey struct { + sha1 string + source fleet.HostCertificateSource + username string + } - ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { + ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { require.Equal(t, host.ID, hostID) require.Equal(t, host.UUID, hostUUID) require.Equal(t, fleet.HostCertificateOriginOsquery, origin) - require.Len(t, certs, 3) - - // We expect that the ingest function will deduplicate certs based on SHA1+username - // so we should see only 3 unique combinations from the 7 rows above. - expectSha1Users := map[string]bool{ - "1A395245953C61AE12657704FF45F31A1E7BC1E8" + "Admin": true, // c1, c6 - "1A395245953C61AE12657704FF45F31A1E7BC1E8" + "SYSTEM": true, // c3, c4, c5 - "EE5E756CC1A0782078C7C45180A4544A37D0F6D7" + "Admin": true, // c2, c7 + + // 7 rows collapse to 4 distinct (SHA1, scope, username) entries. + require.Len(t, certs, 4) + + expected := map[scopeKey]bool{ + {machineSHA1, fleet.SystemHostCertificate, ""}: true, + {sysAcctSHA1, fleet.SystemHostCertificate, ""}: true, + {userSHA1, fleet.UserHostCertificate, "Admin"}: true, + {userSHA1, fleet.UserHostCertificate, "Bob"}: true, } - seenSha1Users := map[string]bool{} + seen := map[scopeKey]bool{} for _, cert := range certs { - s := strings.ToUpper(hex.EncodeToString(cert.SHA1Sum)) - _, ok := expectSha1Users[s+cert.Username] - require.True(t, ok, "unexpected cert SHA1+username combination: %s + %s", s, cert.Username) - seenSha1Users[s+cert.Username] = true - - // Validate fields that differ between the cert examples - switch s { - case "1A395245953C61AE12657704FF45F31A1E7BC1E8": - require.Equal(t, "CERT_KEY_ENCIPHERMENT_KEY_USAGE,CERT_DIGITAL_SIGNATURE_KEY_USAGE", cert.KeyUsage) - require.Equal(t, "05", cert.Serial) - require.Equal(t, int64(1780784467), cert.NotValidAfter.Unix()) - require.Equal(t, int64(1749248467), cert.NotValidBefore.Unix()) - require.Equal(t, 2160, cert.KeyStrength) - require.Equal(t, "494FE0F794940E21C757B790494B0FAFD97CFA4D5E9CC75856DB00DE78F3958D", cert.CommonName) - require.Equal(t, "Fleet, 494FE0F794940E21C757B790494B0FAFD97CFA4D5E9CC75856DB00DE78F3958D", cert.SubjectCommonName) - require.Equal(t, "\"\", scep-ca, SCEP CA, FleetDM", cert.IssuerCommonName) - require.Contains(t, []string{"Admin", "SYSTEM"}, cert.Username) - - case "EE5E756CC1A0782078C7C45180A4544A37D0F6D7": - require.Equal(t, "CERT_DIGITAL_SIGNATURE_KEY_USAGE", cert.KeyUsage) - require.Equal(t, "23", cert.Serial) - require.Equal(t, int64(1796430423), cert.NotValidAfter.Unix()) - require.Equal(t, int64(1764893823), cert.NotValidBefore.Unix()) - require.Equal(t, 1120, cert.KeyStrength) - require.Equal(t, "wc215384b-5a6e-4ca5-a2a3-1289734a5a71 User\n CN", cert.CommonName) - require.Equal(t, "fleet-w2a6fd2c4-0018-4bdc-8046-c7342962b576, \"wc215384b-5a6e-4ca5-a2a3-1289734a5a71 User\n CN\"", cert.SubjectCommonName) - require.Equal(t, "US, scep-ca, SCEP CA, MICROMDM SCEP CA", cert.IssuerCommonName) - require.Equal(t, "Admin", cert.Username) - - default: - t.Fatalf("unexpected cert SHA1: %s", s) - } + sha1 := strings.ToUpper(hex.EncodeToString(cert.SHA1Sum)) + k := scopeKey{sha1, cert.Source, cert.Username} + require.True(t, expected[k], "unexpected (sha1, scope, username): %+v", k) + seen[k] = true - // Validate fields common across all Windows certs in this test require.Equal(t, "RSA", cert.KeyAlgorithm) require.Equal(t, "sha256RSA", cert.SigningAlgorithm) - require.False(t, cert.CertificateAuthority) - if cert.Username == "SYSTEM" { - require.Equal(t, fleet.SystemHostCertificate, cert.Source) - } else { - require.Equal(t, fleet.UserHostCertificate, cert.Source) - } - - // For Windows certs, osquery squeezes all distinguished name fields into - // the comma-separated list that we store as Issuer/SubjectCommonName and - // we leave all other fields empty for now (see fleet.ExtractDetailsFromOsqueryDistinguishedName) - require.Empty(t, cert.SubjectOrganization) - require.Empty(t, cert.SubjectOrganizationalUnit) - require.Empty(t, cert.SubjectCountry) - require.Empty(t, cert.IssuerOrganization) - require.Empty(t, cert.IssuerOrganizationalUnit) - require.Empty(t, cert.IssuerCountry) + switch k { + case scopeKey{machineSHA1, fleet.SystemHostCertificate, ""}: + // distinguished name fields are now parsed from subject2 / issuer2 + require.Equal(t, "Fleet Root CA", cert.SubjectCommonName) + require.Equal(t, "Fleet Device Management Inc.", cert.SubjectOrganization) + require.Equal(t, "Engineering", cert.SubjectOrganizationalUnit) + require.Equal(t, "US", cert.SubjectCountry) + require.Equal(t, "Fleet Root CA", cert.IssuerCommonName) + require.Equal(t, "US", cert.IssuerCountry) + case scopeKey{userSHA1, fleet.UserHostCertificate, "Admin"}, scopeKey{userSHA1, fleet.UserHostCertificate, "Bob"}: + require.Equal(t, "admin@example.com", cert.SubjectCommonName) + require.Equal(t, "Example", cert.SubjectOrganization) + require.Equal(t, "fleet-abc+OU=People", cert.SubjectOrganizationalUnit) + // quoted comma inside the issuer organization must be preserved + require.Equal(t, "Example, Inc.", cert.IssuerOrganization) + require.Equal(t, "SCEP CA", cert.IssuerCommonName) + require.Equal(t, "US", cert.IssuerCountry) + } } - require.Equal(t, expectSha1Users, seenSha1Users) + require.Equal(t, expected, seen) + + // Observed scopes: System is always observed, plus each user that reported + // a cert. This is what lets reconciliation preserve logged-off users. + require.ElementsMatch(t, []fleet.HostCertificateScope{ + {Source: fleet.SystemHostCertificate}, + {Source: fleet.UserHostCertificate, Username: "Admin"}, + {Source: fleet.UserHostCertificate, Username: "Bob"}, + }, observedScopes) return nil } From 5a8d7e5b22da069cfe91149fd5eb26f039191495 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:18:09 +0000 Subject: [PATCH 02/17] Review fixes --- .../HostDetailsPage/HostDetailsPage.tsx | 2 +- server/datastore/mysql/host_certificates.go | 20 ++++- .../datastore/mysql/host_certificates_test.go | 74 +++++++++++++++++++ server/service/osquery_utils/queries.go | 25 ++++--- server/service/osquery_utils/queries_test.go | 32 ++++++-- 5 files changed, 132 insertions(+), 21 deletions(-) diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 765a47294cb..8931264c3e6 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1316,7 +1316,7 @@ const HostDetailsPage = ({ const showLocalUserAccountsCard = !isIosOrIpadosHost && !isAndroidHost; const showCertificatesCard = (isAppleDeviceHost || isWindowsHost) && - !!hostCertificates?.certificates.length; + (isErrorHostCertificates || !!hostCertificates?.certificates.length); const renderSoftwareCard = () => { return ( diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 4819ff224f7..2d124598190 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -53,18 +53,34 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho // so reconciliation behaves exactly as before. A non-nil slice (the Windows // path) preserves certificates whose scope is not listed, because osquery can // only enumerate a user's certificates while that user is logged in. + // canonicalScope folds legacy Windows scope representations onto the corrected + // scope before comparison. Pre-#31294 Windows ingestion stored System certs + // with username "SYSTEM" and mislabeled machine-wide (LocalMachine) certs as + // User scope with an empty username; the corrected scheme uses System scope + // with an empty username for both. Without this, on the first run after a host + // upgrades, those legacy source rows would look like distinct unobserved + // scopes and be preserved forever (phantom duplicate sources) instead of being + // reconciled against the observed System scope. A User scope always has a + // non-empty username under the corrected scheme, so an empty username + // unambiguously marks legacy/machine data. + canonicalScope := func(s certSourceToSet) certSourceToSet { + if s.Source == fleet.SystemHostCertificate || s.Username == "" { + return certSourceToSet{Source: fleet.SystemHostCertificate} + } + return s + } var observedSet map[certSourceToSet]struct{} if observedScopes != nil { observedSet = make(map[certSourceToSet]struct{}, len(observedScopes)) for _, s := range observedScopes { - observedSet[certSourceToSet{Source: s.Source, Username: s.Username}] = struct{}{} + observedSet[canonicalScope(certSourceToSet{Source: s.Source, Username: s.Username})] = struct{}{} } } isObserved := func(s certSourceToSet) bool { if observedScopes == nil { return true } - _, ok := observedSet[s] + _, ok := observedSet[canonicalScope(s)] return ok } // desiredSources returns the source set to persist for a certificate: every diff --git a/server/datastore/mysql/host_certificates_test.go b/server/datastore/mysql/host_certificates_test.go index 9317a953deb..e18b05fd38d 100644 --- a/server/datastore/mysql/host_certificates_test.go +++ b/server/datastore/mysql/host_certificates_test.go @@ -34,6 +34,7 @@ func TestHostCertificates(t *testing.T) { {"Matcher recovers stuck hmmc rows", testMatcherRecoversStuckHMMCRows}, {"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation}, {"Windows scope-aware reconciliation", testUpdateHostCertificatesWindowsScopeReconciliation}, + {"Windows legacy scope migration", testUpdateHostCertificatesWindowsLegacyScopeMigration}, {"Origin-scoped delete", testUpdateHostCertificatesOriginScopedDelete}, {"Origin downgrade on osquery rediscovery", testUpdateHostCertificatesOriginDowngrade}, {"Create certificates with long country code", testHostCertificateWithInvalidCountryCode}, @@ -1025,6 +1026,79 @@ func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Data }, listKeys()) } +// testUpdateHostCertificatesWindowsLegacyScopeMigration verifies that legacy +// Windows scope rows are reconciled onto the corrected scope when a host +// upgrades. Pre-#31294 Windows ingestion stored System certs with username +// "SYSTEM" and mislabeled machine-wide (LocalMachine) certs as User scope with +// an empty username; the corrected scheme uses System scope with an empty +// username for both. Those legacy source rows must NOT survive as phantom +// unobserved scopes. +func testUpdateHostCertificatesWindowsLegacyScopeMigration(t *testing.T, ds *Datastore) { + ctx := t.Context() + const ( + hostID = uint(77) + hostUUID = "windows-legacy-host-uuid" + ) + + mkCert := func(commonName string, source fleet.HostCertificateSource, username string) *fleet.HostCertificateRecord { + tmpl := x509.Certificate{ + Subject: pkix.Name{CommonName: commonName, Organization: []string{"Org"}}, + Issuer: pkix.Name{CommonName: "issuer.example.com"}, + SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec + NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(), + NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(), + BasicConstraintsValid: true, + } + rec := generateTestHostCertificateRecord(t, hostID, &tmpl) + rec.Source = source + rec.Username = username + return rec + } + + listKeys := func() []string { + certs, _, err := ds.ListHostCertificates(ctx, hostID, fleet.ListOptions{OrderKey: "common_name"}) + require.NoError(t, err) + keys := make([]string, 0, len(certs)) + for _, c := range certs { + keys = append(keys, fmt.Sprintf("%s|%s|%s", c.CommonName, c.Source, c.Username)) + } + return keys + } + + // 1. Seed legacy-format rows as the old Windows ingest would have (full + // reconcile via nil observedScopes): a System cert tagged "SYSTEM", and a + // machine cert mislabeled as User scope with an empty username. + legacySystem := mkCert("machine-system.example.com", fleet.SystemHostCertificate, "SYSTEM") + legacyMislabeled := mkCert("localmachine.example.com", fleet.UserHostCertificate, "") + require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, + []*fleet.HostCertificateRecord{legacySystem, legacyMislabeled}, + fleet.HostCertificateOriginOsquery, nil)) + require.ElementsMatch(t, []string{ + "machine-system.example.com|system|SYSTEM", + "localmachine.example.com|user|", + }, listKeys()) + + // 2. Host upgrades; the corrected ingest reports the SAME certificates + // canonically (both System scope, empty username) with Windows observed + // scopes. Clone the seeded records so SHA-1 and validity dates match. + correctedSystem := *legacySystem + correctedSystem.Username = "" + correctedMachine := *legacyMislabeled + correctedMachine.Source = fleet.SystemHostCertificate + correctedMachine.Username = "" + require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, + []*fleet.HostCertificateRecord{&correctedSystem, &correctedMachine}, + fleet.HostCertificateOriginOsquery, + []fleet.HostCertificateScope{{Source: fleet.SystemHostCertificate}})) + + // 3. The legacy scope rows are gone; each certificate has exactly one + // canonical System source (no phantom duplicates). + require.ElementsMatch(t, []string{ + "machine-system.example.com|system|", + "localmachine.example.com|system|", + }, listKeys()) +} + func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { // regression test for #30574 ctx := context.Background() diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 3a503849cf6..85bab393687 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -3659,15 +3659,17 @@ func directIngestHostCertificatesWindows( continue } - // Classify scope from the registry hive (sid), not the owner name. Only a - // real interactive account's hive SID is `S-1-5-21-*`; everything else (the - // machine-wide LocalMachine store, which reports an empty sid, and built-in - // service accounts such as `S-1-5-18` SYSTEM) is System scope with no owner. - // The old `username == "SYSTEM"` heuristic mislabeled LocalMachine certs - // (blank username) as user certs. + // Classify scope from the registry hive (sid), not the owner name. A real + // interactive account's hive SID is `S-1-5-21-*` (local or Active Directory + // accounts) or `S-1-12-1-*` (Microsoft Entra ID / Azure AD accounts on + // Entra-joined devices); everything else (the machine-wide LocalMachine + // store, which reports an empty sid, and built-in service accounts such as + // `S-1-5-18` SYSTEM) is System scope with no owner. The old + // `username == "SYSTEM"` heuristic mislabeled LocalMachine certs (blank + // username) as user certs. source := fleet.SystemHostCertificate username := "" - if strings.HasPrefix(row["sid"], "S-1-5-21-") { + if sid := row["sid"]; strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-") { source = fleet.UserHostCertificate username = row["username"] } @@ -3701,16 +3703,15 @@ func directIngestHostCertificatesWindows( // one entry per username. key := fmt.Sprintf("%x|%s|%s", csum, source, username) if _, ok := seen[key]; ok { + // Don't log user/cert identifiers here: usernames, registry paths, and + // subject/issuer values commonly contain PII (e.g. emails). host + source + // + sha1 is enough to diagnose a duplicate. logger.DebugContext(ctx, "skipping duplicate certificate for sha1+scope+user", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, "source", source, - "username", username, - "sha1", fmt.Sprintf("%x", csum), - "issuer", cert.IssuerCommonName, - "subject", cert.SubjectCommonName, - "path", row["path"]) + "sha1", fmt.Sprintf("%x", csum)) continue } seen[key] = struct{}{} diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 0f19cd28312..7dde032a562 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -3053,6 +3053,9 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { const ( userSID = "S-1-5-21-1043593016-4249271388-1765263865-1000" secondUSID = "S-1-5-21-1043593016-4249271388-1765263865-1500" + // Microsoft Entra ID (Azure AD) accounts on Entra-joined devices use the + // S-1-12-1 SID prefix rather than S-1-5-21. + entraSID = "S-1-12-1-1234567890-1234567890-1234567890-1234567890" ) // Common fields shared by every example row (osquery 5.23.1+ shapes, with the @@ -3077,6 +3080,7 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { machineSHA1 = "AAAA1111BBBB2222CCCC3333DDDD4444EEEE5555" sysAcctSHA1 = "1111AAAA2222BBBB3333CCCC4444DDDD5555EEEE" userSHA1 = "EE5E756CC1A0782078C7C45180A4544A37D0F6D7" + entraSHA1 = "FACE1234FACE1234FACE1234FACE1234FACE1234" ) // Machine-wide LocalMachine store: empty sid and empty username. The old @@ -3137,11 +3141,25 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { userBob["sid"] = secondUSID userBob["path"] = "Users\\" + secondUSID + "\\Personal" + // An Entra ID (Azure AD) user, whose hive SID uses the S-1-12-1 prefix, must + // also be classified as User scope with the owner preserved. + entraUser := row(map[string]string{ + "common_name": "entra@example.com", + "subject2": "CN=entra@example.com, O=Example", + "issuer2": "CN=SCEP CA, C=US", + "sha1": entraSHA1, + "username": "AzureAD\\entrauser", + "sid": entraSID, + "store_location": "Users", + "path": "Users\\" + entraSID + "\\Personal", + }) + rows := []map[string]string{ machine, sysAcctCurrentUser, sysAcctServices, sysAcctUsersHive, userAdmin, userAdminClasses, userBob, + entraUser, } type scopeKey struct { @@ -3155,14 +3173,15 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { require.Equal(t, host.UUID, hostUUID) require.Equal(t, fleet.HostCertificateOriginOsquery, origin) - // 7 rows collapse to 4 distinct (SHA1, scope, username) entries. - require.Len(t, certs, 4) + // 8 rows collapse to 5 distinct (SHA1, scope, username) entries. + require.Len(t, certs, 5) expected := map[scopeKey]bool{ - {machineSHA1, fleet.SystemHostCertificate, ""}: true, - {sysAcctSHA1, fleet.SystemHostCertificate, ""}: true, - {userSHA1, fleet.UserHostCertificate, "Admin"}: true, - {userSHA1, fleet.UserHostCertificate, "Bob"}: true, + {machineSHA1, fleet.SystemHostCertificate, ""}: true, + {sysAcctSHA1, fleet.SystemHostCertificate, ""}: true, + {userSHA1, fleet.UserHostCertificate, "Admin"}: true, + {userSHA1, fleet.UserHostCertificate, "Bob"}: true, + {entraSHA1, fleet.UserHostCertificate, "AzureAD\\entrauser"}: true, } seen := map[scopeKey]bool{} for _, cert := range certs { @@ -3201,6 +3220,7 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { {Source: fleet.SystemHostCertificate}, {Source: fleet.UserHostCertificate, Username: "Admin"}, {Source: fleet.UserHostCertificate, Username: "Bob"}, + {Source: fleet.UserHostCertificate, Username: "AzureAD\\entrauser"}, }, observedScopes) return nil From 0bc8adb6adab82979a0f47a5887a8705156d91c3 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:36:08 +0000 Subject: [PATCH 03/17] Review fixes --- .../cards/Certificates/Certificates.tsx | 8 +- .../CertificatesTable.tests.tsx | 36 ++++++++ .../CertificatesTable/CertificatesTable.tsx | 8 ++ ...30100331_ReparseWindowsHostCertificates.go | 92 +++++++++++++++++++ ...331_ReparseWindowsHostCertificates_test.go | 70 ++++++++++++++ server/datastore/mysql/schema.sql | 4 +- 6 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go create mode 100644 server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go diff --git a/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx b/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx index c8f51cd1ef6..174ff2085b5 100644 --- a/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx @@ -17,7 +17,9 @@ import CertificatesTable from "./CertificatesTable"; const baseClass = "certificates-card"; interface ICertificatesProps { - data: IGetHostCertificatesResponse; + // data may be undefined while the fetch is in flight or has errored; in the + // error case the card renders DataError below without reading it. + data?: IGetHostCertificatesResponse; hostPlatform: HostPlatform; page: number; pageSize: number; @@ -56,6 +58,10 @@ const CertificatesCard = ({ ); } + if (!data) { + return null; + } + return ( { expect(screen.getByText("User")).toBeInTheDocument(); }); + + it("renders a certificate present in two scopes as two distinct rows (shared id must not collapse)", () => { + const render = createCustomRenderer(); + // Same certificate (same id) installed in both the System store and a user's + // store comes back as two rows sharing host_certificates.id. They must each + // render rather than collapsing on the shared id. + render( + + ); + + // Both scope cells render — without a per-scope row id the two same-id rows + // collapse and only one scope would be shown. + expect(screen.getByText("System")).toBeInTheDocument(); + expect(screen.getByText("User")).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx index 9929999a7ba..745d588ecda 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -86,6 +86,14 @@ const CertificatesTable = ({ className={baseClass} columnConfigs={tableConfig} data={data.certificates} + // A certificate present in more than one scope (e.g. a device cert in both + // the System store and a user's store) is returned as multiple rows that + // share the same `id` (the underlying host_certificates row). Key rows on + // scope + username as well so those rows render distinctly instead of + // collapsing into one in react-table. + getRowId={(row: IHostCertificate) => + `${row.id}-${row.source}-${row.username}` + } emptyComponent={() => null} isAllPagesSelected={false} showMarkAllPages={false} diff --git a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go b/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go new file mode 100644 index 00000000000..6afeb403cb5 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go @@ -0,0 +1,92 @@ +package tables + +import ( + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" +) + +func init() { + MigrationClient.AddMigration(Up_20260630100331, Down_20260630100331) +} + +// Up_20260630100331 soft-deletes existing osquery-origin Windows host certificate +// rows so they are re-ingested with their distinguished name (subject/issuer) +// parsed from osquery's keyed subject2/issuer2 columns. +// +// Before #31294, Windows certificates were ingested from the legacy +// subject/issuer columns and parseWindowsDN dumped the whole raw string into the +// common name, leaving country/organization/organizational unit empty. +// UpdateHostCertificates skips re-inserting a certificate whose SHA-1 and +// validity dates are unchanged, so already-stored rows would otherwise keep +// their degraded fields until the certificate is renewed. Soft-deleting them +// here makes the next ingestion treat them as new and parse them correctly. +// +// Only osquery-origin Windows rows are affected: MDM-origin certificates are +// parsed directly from the certificate (not via parseWindowsDN), and other +// platforms were never affected by the Windows DN gap. A logged-off user's +// user-scoped certificates are hidden until that user next logs in and osquery +// re-reports them (one-time transition cost). The work is done in id-keyed +// batches with progress reporting since the table can be large. +func Up_20260630100331(tx *sql.Tx) error { + step := incrementalMigrationStep(countWindowsHostCertsToReparse, softDeleteWindowsHostCertsForReparse) + if err := step(tx); err != nil { + return fmt.Errorf("soft-deleting windows host certificates for re-parse: %w", err) + } + return nil +} + +func countWindowsHostCertsToReparse(tx *sql.Tx) (uint64, error) { + var total uint64 + err := tx.QueryRow(` + SELECT COUNT(*) + FROM host_certificates hc + JOIN hosts h ON h.id = hc.host_id + WHERE h.platform = 'windows' AND hc.origin = 'osquery' AND hc.deleted_at IS NULL`).Scan(&total) + return total, err +} + +// softDeleteWindowsHostCertsForReparse walks the osquery-origin Windows host +// certificates in id-keyed batches and soft-deletes each one, calling increment +// per row so progress is reported. The deleted_at IS NULL filter (plus the +// strictly increasing id cursor) makes the walk safe and resumable. +func softDeleteWindowsHostCertsForReparse(tx *sql.Tx, increment incrementCountFn) error { + txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} + + const batchSize = 1000 + var lastID uint64 + for { + var ids []uint64 + if err := txx.Select(&ids, ` + SELECT hc.id + FROM host_certificates hc + JOIN hosts h ON h.id = hc.host_id + WHERE h.platform = 'windows' AND hc.origin = 'osquery' AND hc.deleted_at IS NULL AND hc.id > ? + ORDER BY hc.id + LIMIT ?`, lastID, batchSize); err != nil { + return fmt.Errorf("selecting windows host certs batch after id %d: %w", lastID, err) + } + if len(ids) == 0 { + return nil + } + + query, args, err := sqlx.In(`UPDATE host_certificates SET deleted_at = NOW(6) WHERE id IN (?)`, ids) + if err != nil { + return fmt.Errorf("building soft-delete query: %w", err) + } + if _, err := txx.Exec(query, args...); err != nil { + return fmt.Errorf("soft-deleting windows host certs batch after id %d: %w", lastID, err) + } + + for range ids { + increment() + } + lastID = ids[len(ids)-1] + } +} + +func Down_20260630100331(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go b/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go new file mode 100644 index 00000000000..3b7dcb3a5f2 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go @@ -0,0 +1,70 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20260630100331(t *testing.T) { + db := applyUpToPrev(t) + + insertHost := func(platform, uuid string) uint { + execNoErr(t, db, `INSERT INTO hosts (osquery_host_id, node_key, hostname, uuid, platform) VALUES (?, ?, ?, ?, ?);`, + uuid, uuid, uuid, uuid, platform) + var id uint + require.NoError(t, db.Get(&id, `SELECT id FROM hosts WHERE uuid = ?`, uuid)) + return id + } + + // insertCert inserts a host_certificates row (deletedAt nil => live) and + // returns its id. sha1 values must be 20 bytes (binary(20)). + insertCert := func(hostID uint, serial, origin string, sha1 []byte, deletedAt any) uint { + execNoErr(t, db, ` + INSERT INTO host_certificates ( + host_id, not_valid_after, not_valid_before, 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, + sha1_sum, origin, deleted_at + ) VALUES (?, '2027-01-01', '2026-01-01', 0, 'cn', 'rsa', 2048, 'digitalSignature', + ?, 'sha256WithRSAEncryption', '', '', '', '', '', '', '', '', + ?, ?, ?)`, + hostID, serial, sha1, origin, deletedAt) + var id uint + require.NoError(t, db.Get(&id, `SELECT id FROM host_certificates WHERE sha1_sum = ?`, sha1)) + return id + } + + winHost := insertHost("windows", "win-uuid") + macHost := insertHost("darwin", "mac-uuid") + + // Windows osquery-origin live certs: must be soft-deleted by the migration so + // they re-parse on the next ingestion. + winOsq1 := insertCert(winHost, "1", "osquery", []byte("aaaaaaaaaaaaaaaaaaaa"), nil) + winOsq2 := insertCert(winHost, "2", "osquery", []byte("bbbbbbbbbbbbbbbbbbbb"), nil) + // Windows mdm-origin cert: parsed directly from the cert, must be left untouched. + winMDM := insertCert(winHost, "3", "mdm", []byte("cccccccccccccccccccc"), nil) + // Windows osquery cert already soft-deleted: must stay deleted (not re-touched). + winDeleted := insertCert(winHost, "4", "osquery", []byte("dddddddddddddddddddd"), "2026-01-01 00:00:00.000000") + // macOS osquery cert: unaffected by the Windows DN gap, must be left untouched. + macOsq := insertCert(macHost, "5", "osquery", []byte("eeeeeeeeeeeeeeeeeeee"), nil) + + applyNext(t, db) + + isDeleted := func(id uint) bool { + var deleted bool + require.NoError(t, db.Get(&deleted, `SELECT deleted_at IS NOT NULL FROM host_certificates WHERE id = ?`, id)) + return deleted + } + + // Windows osquery-origin live certs are now soft-deleted. + require.True(t, isDeleted(winOsq1)) + require.True(t, isDeleted(winOsq2)) + // Windows MDM-origin and macOS certs are untouched. + require.False(t, isDeleted(winMDM)) + require.False(t, isDeleted(macOsq)) + // Already-deleted Windows cert is still deleted. + require.True(t, isDeleted(winDeleted)) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 925c9cd199b..fd6367351c5 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -2089,9 +2089,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=559 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=560 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260611202649,1,'2020-01-01 01:01:01'),(549,20260615135619,1,'2020-01-01 01:01:01'),(550,20260617172853,1,'2020-01-01 01:01:01'),(551,20260617194413,1,'2020-01-01 01:01:01'),(552,20260622124714,1,'2020-01-01 01:01:01'),(553,20260622124734,1,'2020-01-01 01:01:01'),(554,20260623140135,1,'2020-01-01 01:01:01'),(555,20260624152755,1,'2020-01-01 01:01:01'),(556,20260624210253,1,'2020-01-01 01:01:01'),(557,20260624210311,1,'2020-01-01 01:01:01'),(558,20260626120000,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260611202649,1,'2020-01-01 01:01:01'),(549,20260615135619,1,'2020-01-01 01:01:01'),(550,20260617172853,1,'2020-01-01 01:01:01'),(551,20260617194413,1,'2020-01-01 01:01:01'),(552,20260622124714,1,'2020-01-01 01:01:01'),(553,20260622124734,1,'2020-01-01 01:01:01'),(554,20260623140135,1,'2020-01-01 01:01:01'),(555,20260624152755,1,'2020-01-01 01:01:01'),(556,20260624210253,1,'2020-01-01 01:01:01'),(557,20260624210311,1,'2020-01-01 01:01:01'),(558,20260626120000,1,'2020-01-01 01:01:01'),(559,20260630100331,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( From b76ad5a4b0103089481098d7aa917e394ba949a6 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:53:38 +0000 Subject: [PATCH 04/17] Review fixes --- server/fleet/host_certificates.go | 58 ++++++++++---------- server/fleet/host_certificates_test.go | 23 ++++++-- server/service/osquery_utils/queries.go | 9 +-- server/service/osquery_utils/queries_test.go | 42 ++++++++++++++ 4 files changed, 95 insertions(+), 37 deletions(-) diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index 3d50d75f2ab..878e10971e9 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -41,17 +41,14 @@ const ( HostCertificateOriginMDM HostCertificateOrigin = "mdm" ) -// HostCertificateScope identifies a single (source, username) certificate -// scope. It is used to tell UpdateHostCertificates which scopes the agent could -// authoritatively enumerate during a collection cycle, so reconciliation does -// not soft-delete certificates for a scope it could not observe. +// HostCertificateScope identifies a single (source, username) certificate scope. It is used to tell +// UpdateHostCertificates which scopes the agent could authoritatively enumerate during a collection cycle, so +// reconciliation does not soft-delete certificates for a scope it could not observe. // -// A user's Windows certificates are only visible to osquery while that user is -// logged in (their registry hive is loaded), so the Windows ingestion path -// passes the set of observed scopes and absent users' certificates are -// preserved. The macOS path reads every keychain from disk on every run, so it -// passes a nil slice, meaning "all scopes observed" and any absent certificate -// may be deleted. +// A user's Windows certificates are only visible to osquery while that user is logged in (their registry hive is +// loaded), so the Windows ingestion path passes the set of observed scopes and absent users' certificates are preserved. +// The macOS path reads every keychain from disk on every run, so it passes a nil slice, meaning "all scopes observed" +// and any absent certificate may be deleted. type HostCertificateScope struct { Source HostCertificateSource Username string @@ -298,13 +295,8 @@ func parseDarwinDN(dn string) (*HostCertificateNameDetails, error) { return &details, nil } -// applyDNAttribute assigns a single distinguished-name attribute (key/value -// pair) to the matching field of details. It is shared by the macOS and Windows -// distinguished-name parsers so the attribute → field mapping stays identical -// across platforms (only the tokenization differs). Organizational units are -// accumulated because osquery can report multiple OU values for one -// certificate; the caller joins them. Attributes Fleet does not display (state, -// locality, bare dotted-decimal OIDs, ...) are ignored. +// applyDNAttribute assigns a single distinguished-name attribute (key/value pair) to the matching field of details. +// Attributes Fleet does not display (state, locality, bare dotted-decimal OIDs, ...) are ignored. func applyDNAttribute(details *HostCertificateNameDetails, ouParts *[]string, key, value string) { switch strings.ToUpper(strings.TrimSpace(key)) { case "C": @@ -328,33 +320,41 @@ func applyDNAttribute(details *HostCertificateNameDetails, ouParts *[]string, ke } } -// parseWindowsDN parses a distinguished name in the X.500 string form that -// osquery emits in the `subject2` / `issuer2` columns on Windows starting with -// osquery 5.23.1 (osquery/osquery#8963), for example: +// parseWindowsDN parses a distinguished name in the X.500 string form that osquery emits in the `subject2` / `issuer2` +// columns on Windows starting with osquery 5.23.1, for example: // // CN=Example, O="Example, Inc.", OU=A + OU=B, C=US // -// Relative distinguished names (RDNs) are comma-separated; a multi-valued RDN -// joins its attributes with `+`; a value containing a separator (`,`, `+`, `=`, -// ...) is wrapped in double quotes with any embedded quote doubled. Unlike the -// macOS form parsed by parseDarwinDN (a slash-delimited openSSL style with the -// attribute keys preserved), this form is comma-delimited and quoted, so it -// needs its own tokenizer. Malformed fragments are skipped rather than failing -// the whole certificate, since a single odd attribute should not block -// ingestion of the batch. +// Relative distinguished names (RDNs) are comma-separated; a multi-valued RDN joins its attributes with `+`; a value +// containing a separator (`,`, `+`, `=`, ...) is wrapped in double quotes with any embedded quote doubled. Unlike the +// macOS form parsed by parseDarwinDN (a slash-delimited openSSL style with the attribute keys preserved), this form is +// comma-delimited and quoted, so it needs its own tokenizer. +// +// It always returns best-effort details (a single odd attribute must not drop the whole certificate). If it skips any +// non-empty fragment that is not a valid `key=value` RDN, it also returns a non-fatal error naming those fragments, so +// the caller can log them: that gives us visibility into malformed osquery output to refine parsing later. Empty +// fragments (e.g. an empty DN or a trailing separator) are skipped silently as they are benign. func parseWindowsDN(dn string) (*HostCertificateNameDetails, error) { var details HostCertificateNameDetails var ouParts []string + var malformed []string for _, attr := range splitX500Attributes(dn) { key, value, found := strings.Cut(attr, "=") if !found { + if trimmed := strings.TrimSpace(attr); trimmed != "" { + malformed = append(malformed, trimmed) + } continue } applyDNAttribute(&details, &ouParts, key, unquoteX500Value(strings.TrimSpace(value))) } details.OrganizationalUnit = strings.Join(ouParts, "+OU=") - return &details, nil + var err error + if len(malformed) > 0 { + err = fmt.Errorf("skipped %d malformed RDN fragment(s) in windows distinguished name: %q", len(malformed), malformed) + } + return &details, err } // splitX500Attributes splits an X.500 distinguished name into its individual diff --git a/server/fleet/host_certificates_test.go b/server/fleet/host_certificates_test.go index 4427a78119e..2368ffee1eb 100644 --- a/server/fleet/host_certificates_test.go +++ b/server/fleet/host_certificates_test.go @@ -178,9 +178,10 @@ func TestExtractWindowsCertificateNameDetails(t *testing.T) { // CERT_X500_NAME_STR form: comma-separated key=value RDNs, with values quoted // when they contain separators and multi-valued RDNs joined by '+'. cases := []struct { - name string - input string - expected *HostCertificateNameDetails + name string + input string + expected *HostCertificateNameDetails + expectErr bool }{ { name: "basic", @@ -249,11 +250,25 @@ func TestExtractWindowsCertificateNameDetails(t *testing.T) { input: "", expected: &HostCertificateNameDetails{}, }, + { + name: "malformed fragment is skipped but reported, details still parsed", + input: "CN=Good Cert, garbage-no-equals, C=US", + expected: &HostCertificateNameDetails{ + CommonName: "Good Cert", + Country: "US", + }, + expectErr: true, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { actual, err := ExtractDetailsFromOsqueryDistinguishedName("windows", tc.input) - require.NoError(t, err) + if tc.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + // details are always best-effort, even when a malformed fragment is reported require.Equal(t, tc.expected, actual) }) } diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 85bab393687..abf3e012a99 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -3648,15 +3648,16 @@ func directIngestHostCertificatesWindows( } // subject2/issuer2 preserve the distinguished name attribute keys (osquery // 5.23.1+); the collection query is gated on the subject2 column existing. + // parseWindowsDN returns best-effort details even when it skips malformed + // fragments, so we log the anomaly (for future handling) but still ingest + // the certificate rather than dropping it. subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["subject2"]) if err != nil { - logger.ErrorContext(ctx, "extracting subject details", "component", "service", "method", "directIngestHostCertificates", "err", err) - continue + logger.ErrorContext(ctx, "malformed certificate subject distinguished name", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, "err", err) } issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["issuer2"]) if err != nil { - logger.ErrorContext(ctx, "extracting issuer details", "component", "service", "method", "directIngestHostCertificates", "err", err) - continue + logger.ErrorContext(ctx, "malformed certificate issuer distinguished name", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, "err", err) } // Classify scope from the registry hive (sid), not the owner name. A real diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 7dde032a562..1a92b129054 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -3231,6 +3231,48 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { require.True(t, ds.UpdateHostCertificatesFuncInvoked) } +func TestDirectIngestHostCertificatesWindowsMalformedDN(t *testing.T) { + ds := new(mock.Store) + ctx := t.Context() + logger := slog.New(slog.DiscardHandler) + host := &fleet.Host{ID: 1, UUID: "host-uuid", Platform: "windows"} + + // subject2 contains a non-empty fragment with no '=' (malformed osquery output). + // The certificate must still be ingested best-effort, not dropped. + row := map[string]string{ + "ca": "0", + "common_name": "malformed.example.com", + "subject2": "CN=malformed.example.com, garbage-no-equals, C=US", + "issuer2": "CN=Issuer CA, C=US", + "key_algorithm": "RSA", + "key_strength": "2048", + "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", + "signing_algorithm": "sha256RSA", + "not_valid_after": "1780784467", + "not_valid_before": "1749248467", + "serial": "07", + "sha1": "1234123412341234123412341234123412341234", + "username": "", + "sid": "", + "store_location": "LocalMachine", + "path": "LocalMachine\\Personal", + } + + var got []*fleet.HostCertificateRecord + ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { + got = certs + return nil + } + + require.NoError(t, directIngestHostCertificatesWindows(ctx, logger, host, ds, []map[string]string{row})) + require.True(t, ds.UpdateHostCertificatesFuncInvoked) + // The cert is kept, with the parseable fields populated (the malformed fragment is dropped). + require.Len(t, got, 1) + require.Equal(t, "malformed.example.com", got[0].SubjectCommonName) + require.Equal(t, "US", got[0].SubjectCountry) + require.Equal(t, fleet.SystemHostCertificate, got[0].Source) +} + func TestGenerateSQLForAllExists(t *testing.T) { // Combine two queries query1 := "SELECT 1 WHERE foo = bar" From 47a2e6dbd3732bf45334435b1f8f5e2b6aee9632 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:12:47 +0000 Subject: [PATCH 05/17] Review fixes --- server/fleet/datastore.go | 12 +++--- server/fleet/host_certificates.go | 15 +++---- server/service/osquery_utils/queries.go | 55 ++++++++----------------- 3 files changed, 28 insertions(+), 54 deletions(-) diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index f56d9dffb8c..4e363021d7d 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -475,13 +475,11 @@ type Datastore interface { IsHostConnectedToFleetMDM(ctx context.Context, host *Host) (bool, error) ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificateRecord, *PaginationMetadata, error) - // UpdateHostCertificates ingests certs reported by `origin`. Each call only - // soft-deletes existing rows whose origin matches, so osquery and MDM - // ingestion don't clobber each other's view. observedScopes further limits - // reconciliation to the (source, username) scopes the agent could - // authoritatively enumerate this run; a nil slice means every scope was - // observed (macOS keychains and the MDM path), while a non-nil slice (Windows) - // preserves certificates for scopes it could not see, e.g. a logged-off user. + // UpdateHostCertificates ingests certs reported by `origin`. Each call only soft-deletes existing rows whose origin + // matches, so osquery and MDM ingestion don't clobber each other's view. observedScopes further limits + // reconciliation to the (source, username) scopes the agent could authoritatively enumerate this run; a nil slice + // means every scope was observed (macOS keychains and the MDM path), while a non-nil slice (Windows) preserves + // certificates for scopes it could not see, e.g. a logged-off user. UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*HostCertificateRecord, origin HostCertificateOrigin, observedScopes []HostCertificateScope) error // SoftDeleteMDMHostCertificatesForUnenrolledHosts soft-deletes MDM-origin diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index 878e10971e9..82944fe782d 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -332,8 +332,7 @@ func applyDNAttribute(details *HostCertificateNameDetails, ouParts *[]string, ke // // It always returns best-effort details (a single odd attribute must not drop the whole certificate). If it skips any // non-empty fragment that is not a valid `key=value` RDN, it also returns a non-fatal error naming those fragments, so -// the caller can log them: that gives us visibility into malformed osquery output to refine parsing later. Empty -// fragments (e.g. an empty DN or a trailing separator) are skipped silently as they are benign. +// the caller can log them func parseWindowsDN(dn string) (*HostCertificateNameDetails, error) { var details HostCertificateNameDetails var ouParts []string @@ -357,10 +356,9 @@ func parseWindowsDN(dn string) (*HostCertificateNameDetails, error) { return &details, err } -// splitX500Attributes splits an X.500 distinguished name into its individual -// `key=value` attributes, treating both `,` (RDN separator) and `+` -// (multi-valued RDN separator) as delimiters but ignoring any delimiter that -// appears inside a double-quoted value. +// splitX500Attributes splits an X.500 distinguished name into its individual `key=value` attributes, treating both `,` +// (RDN separator) and `+` (multi-valued RDN separator) as delimiters but ignoring any delimiter that appears inside a +// double-quoted value. func splitX500Attributes(dn string) []string { var attrs []string var buf strings.Builder @@ -384,9 +382,8 @@ func splitX500Attributes(dn string) []string { return attrs } -// unquoteX500Value removes the surrounding double quotes that CERT_X500_NAME_STR -// adds to a value containing special characters, and un-doubles any escaped -// quote inside it. A value without surrounding quotes is returned unchanged. +// unquoteX500Value removes the surrounding double quotes that CERT_X500_NAME_STR adds to a value containing special +// characters, and un-doubles any escaped quote inside it. A value without surrounding quotes is returned unchanged. func unquoteX500Value(v string) string { if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { v = v[1 : len(v)-1] diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index abf3e012a99..017ce202977 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -873,12 +873,8 @@ var extraDetailQueries = map[string]DetailQuery{ certificates WHERE store = 'Personal';`, - // subject2/issuer2 preserve the distinguished name attribute keys (CN, O, - // OU, C). They are only populated on Windows starting with osquery 5.23.1 - // (osquery/osquery#8963); on older agents the columns do not exist, so - // selecting them would fail the whole query. Gate on the column's presence - // so older agents simply collect nothing (no error, no log spew) until the - // bundled osquery is upgraded. + // subject2/issuer2 preserve the distinguished name attribute keys (CN, O, OU, C). They are only populated on + // Windows starting with osquery 5.23.1 Discovery: `SELECT 1 FROM pragma_table_info('certificates') WHERE name = 'subject2'`, Platforms: []string{"windows"}, DirectIngestFunc: directIngestHostCertificatesWindows, @@ -3610,8 +3606,6 @@ func directIngestHostCertificatesDarwin( return nil } - // nil observedScopes: macOS reads every keychain file from disk on every run, - // so all scopes are observed and any absent cert may be reconciled. return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery, nil) } @@ -3629,9 +3623,8 @@ func directIngestHostCertificatesWindows( } certs := make([]*fleet.HostCertificateRecord, 0, len(rows)) - // On Windows, osquery enumerates the same certificate from multiple redundant - // registry hives (the LocalSystem account's CurrentUser/Services views, - // per-user `_Classes` sub-hives, etc.), so we deduplicate by SHA1 + scope + + // On Windows, osquery enumerates the same certificate from multiple redundant registry hives (the LocalSystem + // account's CurrentUser/Services views, per-user `_Classes` sub-hives, etc.), so we deduplicate by SHA1 + scope + // username. seen := make(map[string]struct{}, len(rows)) for _, row := range rows { @@ -3646,28 +3639,20 @@ func directIngestHostCertificatesWindows( logger.ErrorContext(ctx, "decoding sha1", "component", "service", "method", "directIngestHostCertificates", "err", err) continue } - // subject2/issuer2 preserve the distinguished name attribute keys (osquery - // 5.23.1+); the collection query is gated on the subject2 column existing. - // parseWindowsDN returns best-effort details even when it skips malformed - // fragments, so we log the anomaly (for future handling) but still ingest - // the certificate rather than dropping it. subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["subject2"]) if err != nil { logger.ErrorContext(ctx, "malformed certificate subject distinguished name", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, "err", err) + ctxerr.Handle(ctx, err) } issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["issuer2"]) if err != nil { logger.ErrorContext(ctx, "malformed certificate issuer distinguished name", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, "err", err) + ctxerr.Handle(ctx, err) } - // Classify scope from the registry hive (sid), not the owner name. A real - // interactive account's hive SID is `S-1-5-21-*` (local or Active Directory - // accounts) or `S-1-12-1-*` (Microsoft Entra ID / Azure AD accounts on - // Entra-joined devices); everything else (the machine-wide LocalMachine - // store, which reports an empty sid, and built-in service accounts such as - // `S-1-5-18` SYSTEM) is System scope with no owner. The old - // `username == "SYSTEM"` heuristic mislabeled LocalMachine certs (blank - // username) as user certs. + // Classify scope from the registry hive security identifier (sid), not the owner name. + // S-1-5-21-... is local or AD account + // S-1-12-1-... is Entra ID account source := fleet.SystemHostCertificate username := "" if sid := row["sid"]; strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-") { @@ -3699,14 +3684,11 @@ func directIngestHostCertificatesWindows( Username: username, } - // Deduplicate by SHA1 + scope + username. System rows all collapse - // (username forced to ""), and a user's redundant hive views collapse into - // one entry per username. + // Deduplicate by SHA1 + scope + username. System rows all collapse (username forced to ""), and a user's + // redundant hive views collapse into one entry per username. key := fmt.Sprintf("%x|%s|%s", csum, source, username) if _, ok := seen[key]; ok { - // Don't log user/cert identifiers here: usernames, registry paths, and - // subject/issuer values commonly contain PII (e.g. emails). host + source - // + sha1 is enough to diagnose a duplicate. + // Don't log user/cert identifiers here (PII). logger.DebugContext(ctx, "skipping duplicate certificate for sha1+scope+user", "component", "service", "method", "directIngestHostCertificates", @@ -3724,17 +3706,14 @@ func directIngestHostCertificatesWindows( return nil } - // Tell the datastore which scopes we actually observed this run so it does not - // soft-delete a logged-off user's certificates (their registry hive is not - // loaded, so they are simply absent). System is always observed because the - // LocalMachine store is readable regardless of who is logged in. + // Tell the datastore which scopes we actually observed this run so it does not soft-delete a logged-off user's + // certificates. return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery, windowsObservedCertScopes(certs)) } -// windowsObservedCertScopes returns the set of (source, username) scopes that -// osquery could authoritatively enumerate in this report. System scope is -// always included because the LocalMachine store is always readable; each user -// that reported at least one certificate is included as its own scope. +// windowsObservedCertScopes returns the set of (source, username) scopes that osquery could authoritatively enumerate in +// this report. System scope is always included because the LocalMachine store is always readable; each user that +// reported at least one certificate is included as its own scope. func windowsObservedCertScopes(certs []*fleet.HostCertificateRecord) []fleet.HostCertificateScope { scopes := []fleet.HostCertificateScope{{Source: fleet.SystemHostCertificate}} seen := map[string]struct{}{string(fleet.SystemHostCertificate) + "|": {}} From d5fd1ddce303f1b4ca636be4146c42e7608dce91 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:25:05 +0000 Subject: [PATCH 06/17] Review fixes --- server/datastore/mysql/host_certificates.go | 43 +++++-------------- ...30100331_ReparseWindowsHostCertificates.go | 26 ++--------- ...331_ReparseWindowsHostCertificates_test.go | 6 +-- 3 files changed, 17 insertions(+), 58 deletions(-) diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 2d124598190..70ab218d555 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -47,22 +47,9 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho Username string } - // observedScopes restricts which (source, username) scopes reconciliation may - // soft-delete. A nil slice means every scope was observed this run (the macOS - // keychain model, where all keychains are always readable, and the MDM path), - // so reconciliation behaves exactly as before. A non-nil slice (the Windows - // path) preserves certificates whose scope is not listed, because osquery can - // only enumerate a user's certificates while that user is logged in. - // canonicalScope folds legacy Windows scope representations onto the corrected - // scope before comparison. Pre-#31294 Windows ingestion stored System certs - // with username "SYSTEM" and mislabeled machine-wide (LocalMachine) certs as - // User scope with an empty username; the corrected scheme uses System scope - // with an empty username for both. Without this, on the first run after a host - // upgrades, those legacy source rows would look like distinct unobserved - // scopes and be preserved forever (phantom duplicate sources) instead of being - // reconciled against the observed System scope. A User scope always has a - // non-empty username under the corrected scheme, so an empty username - // unambiguously marks legacy/machine data. + // observedScopes restricts which (source, username) scopes reconciliation may soft-delete. A nil slice means every + // scope was observed this run (the macOS keychain model, where all keychains are always readable, and the MDM path). A non-nil slice (the Windows path) preserves certificates whose scope + // is not listed, because osquery can only enumerate a user's certificates while that user is logged in. canonicalScope := func(s certSourceToSet) certSourceToSet { if s.Source == fleet.SystemHostCertificate || s.Username == "" { return certSourceToSet{Source: fleet.SystemHostCertificate} @@ -83,11 +70,9 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho _, ok := observedSet[canonicalScope(s)] return ok } - // desiredSources returns the source set to persist for a certificate: every - // source reported in the incoming batch, plus any existing source whose scope - // was NOT observed this run (so e.g. a logged-off Windows user's source is - // preserved). For the macOS/MDM path (nil observedScopes) every scope is - // observed, so this returns exactly the incoming sources. + // desiredSources returns the source set to persist for a certificate: every source reported in the incoming batch, + // plus any existing source whose scope was NOT observed this run. For the macOS/MDM path (nil observedScopes) every + // scope is observed. desiredSources := func(incoming, existing []certSourceToSet) []certSourceToSet { result := append([]certSourceToSet(nil), incoming...) have := make(map[certSourceToSet]struct{}, len(incoming)) @@ -177,9 +162,8 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho } return strings.Compare(a.Username, b.Username) } - // Persist the reported sources plus any existing source in a scope we did - // not observe (preserving a logged-off user's source). For the macOS/MDM - // path this is exactly incomingSources. + // Persist the reported sources plus any existing source in a scope we did not observe (preserving a logged-off + // user's source). For the macOS/MDM path this is exactly incomingSources. newSources := desiredSources(incomingSources, existingSources) slices.SortFunc(newSources, sliceSortFunc) slices.SortFunc(existingSources, sliceSortFunc) @@ -373,17 +357,12 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho // present in the incoming batch, reconciled above continue } - // Source-scoped delete: only remove rows whose origin matches the - // calling ingestion source. An osquery sync omitting an MDM-only cert - // must not delete that cert, and vice versa. + // Source-scoped delete: only remove rows whose origin matches the calling ingestion source. if existing.Origin != origin { continue } - // Preserve sources in scopes we did not observe this run (e.g. a - // logged-off Windows user) and drop the observed-but-no-longer-reported - // ones. Soft-delete the certificate only when no live source remains. - // For the macOS/MDM path (all scopes observed) nothing is preserved, so - // an absent certificate is always fully deleted, exactly as before. + // Preserve sources in scopes we did not observe this run (e.g. a logged-off Windows user) and drop the + // observed-but-no-longer-reported ones. existingSources := existingSourcesBySHA1[sha1] preserved := desiredSources(nil, existingSources) switch { diff --git a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go b/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go index 6afeb403cb5..2c5102c060f 100644 --- a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go +++ b/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go @@ -12,24 +12,8 @@ func init() { MigrationClient.AddMigration(Up_20260630100331, Down_20260630100331) } -// Up_20260630100331 soft-deletes existing osquery-origin Windows host certificate -// rows so they are re-ingested with their distinguished name (subject/issuer) -// parsed from osquery's keyed subject2/issuer2 columns. -// -// Before #31294, Windows certificates were ingested from the legacy -// subject/issuer columns and parseWindowsDN dumped the whole raw string into the -// common name, leaving country/organization/organizational unit empty. -// UpdateHostCertificates skips re-inserting a certificate whose SHA-1 and -// validity dates are unchanged, so already-stored rows would otherwise keep -// their degraded fields until the certificate is renewed. Soft-deleting them -// here makes the next ingestion treat them as new and parse them correctly. -// -// Only osquery-origin Windows rows are affected: MDM-origin certificates are -// parsed directly from the certificate (not via parseWindowsDN), and other -// platforms were never affected by the Windows DN gap. A logged-off user's -// user-scoped certificates are hidden until that user next logs in and osquery -// re-reports them (one-time transition cost). The work is done in id-keyed -// batches with progress reporting since the table can be large. +// Up_20260630100331 soft-deletes existing osquery-origin Windows host certificate rows so they are re-ingested with +// their distinguished name (subject/issuer) parsed from osquery's keyed subject2/issuer2 columns. func Up_20260630100331(tx *sql.Tx) error { step := incrementalMigrationStep(countWindowsHostCertsToReparse, softDeleteWindowsHostCertsForReparse) if err := step(tx); err != nil { @@ -48,10 +32,8 @@ func countWindowsHostCertsToReparse(tx *sql.Tx) (uint64, error) { return total, err } -// softDeleteWindowsHostCertsForReparse walks the osquery-origin Windows host -// certificates in id-keyed batches and soft-deletes each one, calling increment -// per row so progress is reported. The deleted_at IS NULL filter (plus the -// strictly increasing id cursor) makes the walk safe and resumable. +// softDeleteWindowsHostCertsForReparse walks the osquery-origin Windows host certificates in id-keyed batches and +// soft-deletes each one, calling increment per row so progress is reported. func softDeleteWindowsHostCertsForReparse(tx *sql.Tx, increment incrementCountFn) error { txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} diff --git a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go b/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go index 3b7dcb3a5f2..d8b0df456cb 100644 --- a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go +++ b/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go @@ -17,8 +17,7 @@ func TestUp_20260630100331(t *testing.T) { return id } - // insertCert inserts a host_certificates row (deletedAt nil => live) and - // returns its id. sha1 values must be 20 bytes (binary(20)). + // insertCert inserts a host_certificates row (deletedAt nil => live) and returns its id. insertCert := func(hostID uint, serial, origin string, sha1 []byte, deletedAt any) uint { execNoErr(t, db, ` INSERT INTO host_certificates ( @@ -40,8 +39,7 @@ func TestUp_20260630100331(t *testing.T) { winHost := insertHost("windows", "win-uuid") macHost := insertHost("darwin", "mac-uuid") - // Windows osquery-origin live certs: must be soft-deleted by the migration so - // they re-parse on the next ingestion. + // Windows osquery-origin live certs: must be soft-deleted by the migration so they re-parse on the next ingestion. winOsq1 := insertCert(winHost, "1", "osquery", []byte("aaaaaaaaaaaaaaaaaaaa"), nil) winOsq2 := insertCert(winHost, "2", "osquery", []byte("bbbbbbbbbbbbbbbbbbbb"), nil) // Windows mdm-origin cert: parsed directly from the cert, must be left untouched. From 60621d7195b636f48076564ec9207b09d9c4c548 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:44:33 +0000 Subject: [PATCH 07/17] Review fixes --- server/fleet/host_certificates_test.go | 34 ++++-- server/service/apple_mdm.go | 2 - server/service/osquery_utils/queries_test.go | 107 ++++++++++--------- 3 files changed, 79 insertions(+), 64 deletions(-) diff --git a/server/fleet/host_certificates_test.go b/server/fleet/host_certificates_test.go index 2368ffee1eb..bbfbb5331ac 100644 --- a/server/fleet/host_certificates_test.go +++ b/server/fleet/host_certificates_test.go @@ -174,14 +174,11 @@ func TestExtractHostCertificateNameDetails(t *testing.T) { } func TestExtractWindowsCertificateNameDetails(t *testing.T) { - // osquery 5.23.1+ reports the Windows subject2 / issuer2 columns in the X.500 - // CERT_X500_NAME_STR form: comma-separated key=value RDNs, with values quoted - // when they contain separators and multi-valued RDNs joined by '+'. cases := []struct { - name string - input string - expected *HostCertificateNameDetails - expectErr bool + name string + input string + expected *HostCertificateNameDetails + errContains string // if set, parsing must return an error containing this substring }{ { name: "basic", @@ -229,6 +226,15 @@ func TestExtractWindowsCertificateNameDetails(t *testing.T) { Country: "US", }, }, + { + name: "plus inside a quoted value is not a multi-valued RDN separator", + input: `CN=Name, O="A + B", C=US`, + expected: &HostCertificateNameDetails{ + CommonName: "Name", + Organization: "A + B", + Country: "US", + }, + }, { name: "doubled quotes inside a quoted value", input: `CN="A ""quoted"" name", C=US`, @@ -250,6 +256,14 @@ func TestExtractWindowsCertificateNameDetails(t *testing.T) { input: "", expected: &HostCertificateNameDetails{}, }, + { + name: "empty fragment between separators is skipped silently", + input: "CN=X,,C=US", + expected: &HostCertificateNameDetails{ + CommonName: "X", + Country: "US", + }, + }, { name: "malformed fragment is skipped but reported, details still parsed", input: "CN=Good Cert, garbage-no-equals, C=US", @@ -257,14 +271,14 @@ func TestExtractWindowsCertificateNameDetails(t *testing.T) { CommonName: "Good Cert", Country: "US", }, - expectErr: true, + errContains: "garbage-no-equals", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { actual, err := ExtractDetailsFromOsqueryDistinguishedName("windows", tc.input) - if tc.expectErr { - require.Error(t, err) + if tc.errContains != "" { + require.ErrorContains(t, err, tc.errContains) } else { require.NoError(t, err) } diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 9b43c7f7faa..0b27a0a5e9e 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -5211,8 +5211,6 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx conte payload = append(payload, parsed) } - // nil observedScopes: MDM CertificateList reports the full set every time, so - // every scope is observed and any absent cert may be reconciled. if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginMDM, nil); err != nil { return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates") } diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 1a92b129054..6f06e3b314e 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -3044,6 +3044,23 @@ func TestDirectIngestHostCertificatesDarwinHexEscapes(t *testing.T) { require.True(t, ds.UpdateHostCertificatesFuncInvoked) } +// windowsCertRow builds an osquery Windows `certificates` table row for tests, +// starting from a common set of base fields and applying the given overrides. +func windowsCertRow(overrides map[string]string) map[string]string { + r := map[string]string{ + "ca": "0", + "key_algorithm": "RSA", + "key_strength": "2048", + "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", + "signing_algorithm": "sha256RSA", + "not_valid_after": "1780784467", + "not_valid_before": "1749248467", + "serial": "05", + } + maps.Copy(r, overrides) + return r +} + func TestDirectIngestHostCertificatesWindows(t *testing.T) { ds := new(mock.Store) ctx := t.Context() @@ -3058,24 +3075,6 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { entraSID = "S-1-12-1-1234567890-1234567890-1234567890-1234567890" ) - // Common fields shared by every example row (osquery 5.23.1+ shapes, with the - // keyed subject2 / issuer2 columns). - base := map[string]string{ - "ca": "0", - "key_algorithm": "RSA", - "key_strength": "2048", - "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", - "signing_algorithm": "sha256RSA", - "not_valid_after": "1780784467", - "not_valid_before": "1749248467", - "serial": "05", - } - row := func(overrides map[string]string) map[string]string { - r := maps.Clone(base) - maps.Copy(r, overrides) - return r - } - const ( machineSHA1 = "AAAA1111BBBB2222CCCC3333DDDD4444EEEE5555" sysAcctSHA1 = "1111AAAA2222BBBB3333CCCC4444DDDD5555EEEE" @@ -3083,9 +3082,8 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { entraSHA1 = "FACE1234FACE1234FACE1234FACE1234FACE1234" ) - // Machine-wide LocalMachine store: empty sid and empty username. The old - // heuristic mislabeled this as a user cert; it must be System scope. - machine := row(map[string]string{ + // Machine-wide LocalMachine store: empty sid and empty username. + machine := windowsCertRow(map[string]string{ "common_name": "Fleet Root CA", "subject2": "CN=Fleet Root CA, O=Fleet Device Management Inc., OU=Engineering, C=US", "issuer2": "CN=Fleet Root CA, O=Fleet Device Management Inc., C=US", @@ -3096,10 +3094,9 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { "path": "LocalMachine\\Personal", }) - // LocalSystem account (S-1-5-18) store, enumerated three times across - // redundant hive views. These must collapse into a single System entry and be - // retained (a distinct cert from LocalMachine, often a device/enrollment cert). - sysAcctCurrentUser := row(map[string]string{ + // LocalSystem account (S-1-5-18) store, enumerated three times across redundant hive views. These must collapse into + // a single System entry and be retained (a distinct cert from LocalMachine, often a device/enrollment cert). + sysAcctCurrentUser := windowsCertRow(map[string]string{ "common_name": "Device Enrollment", "subject2": "CN=Device Enrollment, C=US", "issuer2": "CN=Fleet SCEP CA, C=US", @@ -3116,10 +3113,9 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { sysAcctUsersHive["store_location"] = "Users" sysAcctUsersHive["path"] = "Users\\S-1-5-18\\Personal" - // Real interactive user (S-1-5-21-*), present in the Personal hive and the - // redundant _Classes sub-hive (same base SID). These collapse into one - // User/Admin entry. The issuer carries a quoted comma to exercise the parser. - userAdmin := row(map[string]string{ + // Real interactive user (S-1-5-21-*), present in the Personal hive and the redundant _Classes sub-hive (same base + // SID). These collapse into one User/Admin entry. The issuer carries a quoted comma to exercise the parser. + userAdmin := windowsCertRow(map[string]string{ "common_name": "admin@example.com", "subject2": "CN=admin@example.com, OU=fleet-abc, OU=People, O=Example", "issuer2": `CN=SCEP CA, O="Example, Inc.", C=US`, @@ -3135,15 +3131,13 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { userAdminClasses["path"] = "Users\\" + userSID + "_Classes\\Personal" // The same certificate (same SHA1) also installed in a second user's store - // must yield a separate User entry for that user. userBob := maps.Clone(userAdmin) userBob["username"] = "Bob" userBob["sid"] = secondUSID userBob["path"] = "Users\\" + secondUSID + "\\Personal" - // An Entra ID (Azure AD) user, whose hive SID uses the S-1-12-1 prefix, must - // also be classified as User scope with the owner preserved. - entraUser := row(map[string]string{ + // An Entra ID (Azure AD) user, whose hive SID uses the S-1-12-1 prefix + entraUser := windowsCertRow(map[string]string{ "common_name": "entra@example.com", "subject2": "CN=entra@example.com, O=Example", "issuer2": "CN=SCEP CA, C=US", @@ -3195,13 +3189,25 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { switch k { case scopeKey{machineSHA1, fleet.SystemHostCertificate, ""}: - // distinguished name fields are now parsed from subject2 / issuer2 + // non-DN fields are mapped straight from the osquery row + require.Equal(t, "Fleet Root CA", cert.CommonName) + require.Equal(t, int64(1780784467), cert.NotValidAfter.Unix()) + require.Equal(t, int64(1749248467), cert.NotValidBefore.Unix()) + require.Equal(t, "05", cert.Serial) + require.Equal(t, 2048, cert.KeyStrength) + require.Equal(t, "CERT_DIGITAL_SIGNATURE_KEY_USAGE", cert.KeyUsage) + require.False(t, cert.CertificateAuthority) + // distinguished name fields are parsed from subject2 / issuer2 require.Equal(t, "Fleet Root CA", cert.SubjectCommonName) require.Equal(t, "Fleet Device Management Inc.", cert.SubjectOrganization) require.Equal(t, "Engineering", cert.SubjectOrganizationalUnit) require.Equal(t, "US", cert.SubjectCountry) require.Equal(t, "Fleet Root CA", cert.IssuerCommonName) require.Equal(t, "US", cert.IssuerCountry) + case scopeKey{sysAcctSHA1, fleet.SystemHostCertificate, ""}: + require.Equal(t, "Device Enrollment", cert.SubjectCommonName) + require.Equal(t, "US", cert.SubjectCountry) + require.Equal(t, "Fleet SCEP CA", cert.IssuerCommonName) case scopeKey{userSHA1, fleet.UserHostCertificate, "Admin"}, scopeKey{userSHA1, fleet.UserHostCertificate, "Bob"}: require.Equal(t, "admin@example.com", cert.SubjectCommonName) require.Equal(t, "Example", cert.SubjectOrganization) @@ -3210,6 +3216,11 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { require.Equal(t, "Example, Inc.", cert.IssuerOrganization) require.Equal(t, "SCEP CA", cert.IssuerCommonName) require.Equal(t, "US", cert.IssuerCountry) + case scopeKey{entraSHA1, fleet.UserHostCertificate, "AzureAD\\entrauser"}: + require.Equal(t, "entra@example.com", cert.SubjectCommonName) + require.Equal(t, "Example", cert.SubjectOrganization) + require.Equal(t, "SCEP CA", cert.IssuerCommonName) + require.Equal(t, "US", cert.IssuerCountry) } } require.Equal(t, expected, seen) @@ -3239,24 +3250,16 @@ func TestDirectIngestHostCertificatesWindowsMalformedDN(t *testing.T) { // subject2 contains a non-empty fragment with no '=' (malformed osquery output). // The certificate must still be ingested best-effort, not dropped. - row := map[string]string{ - "ca": "0", - "common_name": "malformed.example.com", - "subject2": "CN=malformed.example.com, garbage-no-equals, C=US", - "issuer2": "CN=Issuer CA, C=US", - "key_algorithm": "RSA", - "key_strength": "2048", - "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", - "signing_algorithm": "sha256RSA", - "not_valid_after": "1780784467", - "not_valid_before": "1749248467", - "serial": "07", - "sha1": "1234123412341234123412341234123412341234", - "username": "", - "sid": "", - "store_location": "LocalMachine", - "path": "LocalMachine\\Personal", - } + row := windowsCertRow(map[string]string{ + "common_name": "malformed.example.com", + "subject2": "CN=malformed.example.com, garbage-no-equals, C=US", + "issuer2": "CN=Issuer CA, C=US", + "sha1": "1234123412341234123412341234123412341234", + "username": "", + "sid": "", + "store_location": "LocalMachine", + "path": "LocalMachine\\Personal", + }) var got []*fleet.HostCertificateRecord ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { From 907660c71f712a265c72625403c89818ada5c86a Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:01:40 +0000 Subject: [PATCH 08/17] Review fixes --- server/datastore/mysql/host_certificates.go | 10 +- .../datastore/mysql/host_certificates_test.go | 103 ++++-------------- 2 files changed, 24 insertions(+), 89 deletions(-) diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 70ab218d555..3dcc7d651cd 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -50,24 +50,18 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho // observedScopes restricts which (source, username) scopes reconciliation may soft-delete. A nil slice means every // scope was observed this run (the macOS keychain model, where all keychains are always readable, and the MDM path). A non-nil slice (the Windows path) preserves certificates whose scope // is not listed, because osquery can only enumerate a user's certificates while that user is logged in. - canonicalScope := func(s certSourceToSet) certSourceToSet { - if s.Source == fleet.SystemHostCertificate || s.Username == "" { - return certSourceToSet{Source: fleet.SystemHostCertificate} - } - return s - } var observedSet map[certSourceToSet]struct{} if observedScopes != nil { observedSet = make(map[certSourceToSet]struct{}, len(observedScopes)) for _, s := range observedScopes { - observedSet[canonicalScope(certSourceToSet{Source: s.Source, Username: s.Username})] = struct{}{} + observedSet[certSourceToSet{Source: s.Source, Username: s.Username}] = struct{}{} } } isObserved := func(s certSourceToSet) bool { if observedScopes == nil { return true } - _, ok := observedSet[canonicalScope(s)] + _, ok := observedSet[s] return ok } // desiredSources returns the source set to persist for a certificate: every source reported in the incoming batch, diff --git a/server/datastore/mysql/host_certificates_test.go b/server/datastore/mysql/host_certificates_test.go index e18b05fd38d..a94b84337de 100644 --- a/server/datastore/mysql/host_certificates_test.go +++ b/server/datastore/mysql/host_certificates_test.go @@ -34,7 +34,6 @@ func TestHostCertificates(t *testing.T) { {"Matcher recovers stuck hmmc rows", testMatcherRecoversStuckHMMCRows}, {"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation}, {"Windows scope-aware reconciliation", testUpdateHostCertificatesWindowsScopeReconciliation}, - {"Windows legacy scope migration", testUpdateHostCertificatesWindowsLegacyScopeMigration}, {"Origin-scoped delete", testUpdateHostCertificatesOriginScopedDelete}, {"Origin downgrade on osquery rediscovery", testUpdateHostCertificatesOriginDowngrade}, {"Create certificates with long country code", testHostCertificateWithInvalidCountryCode}, @@ -916,12 +915,9 @@ func generateTestHostCertificateRecordWithParent(t *testing.T, hostID uint, cert return fleet.NewHostCertificateRecord(hostID, parsed) } -// testUpdateHostCertificatesWindowsScopeReconciliation exercises the -// observed-scopes reconciliation used by the Windows ingestion path: osquery -// can only enumerate a user's certificates while that user is logged in, so a -// logged-off user's certificates must be preserved rather than soft-deleted. -// (The macOS / MDM path passes nil observedScopes and is covered by the other -// subtests, which always reconcile every scope.) +// testUpdateHostCertificatesWindowsScopeReconciliation exercises the observed-scopes reconciliation used by the Windows +// ingestion path: osquery can only enumerate a user's certificates while that user is logged in, so a logged-off user's +// certificates must be preserved rather than soft-deleted. func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Datastore) { ctx := t.Context() const ( @@ -961,8 +957,7 @@ func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Data certSys := mkCert("sys.example.com", fleet.SystemHostCertificate, "") certAlice := mkCert("alice-old.example.com", fleet.UserHostCertificate, "alice") certBob := mkCert("bob.example.com", fleet.UserHostCertificate, "bob") - // shared cert: present in the System store and in alice's store (same SHA1, - // two sources). + // shared cert: present in the System store and in alice's store (same SHA1, two sources). sharedSys := mkCert("shared.example.com", fleet.SystemHostCertificate, "") sharedAliceClone := *sharedSys sharedAlice := &sharedAliceClone @@ -983,8 +978,6 @@ func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Data }, listKeys()) // 2. Alice logs off: her hive is not loaded so her certs are simply absent. - // Bob is still logged in. Alice's certs (including her shared source) must - // be preserved. require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, []*fleet.HostCertificateRecord{certSys, certBob, sharedSys}, fleet.HostCertificateOriginOsquery, @@ -997,10 +990,7 @@ func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Data "shared.example.com|user|alice", // preserved }, listKeys()) - // 3. Alice logs back in but has removed her old cert and her copy of the - // shared cert, and installed a new one. Because alice is observed this run, - // her removed certs are reconciled, while the shared cert survives via its - // still-reported System source. + // 3. Alice logs back in but has removed her old cert and her copy of the shared cert, and installed a new one. certAlice2 := mkCert("alice-new.example.com", fleet.UserHostCertificate, "alice") require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, []*fleet.HostCertificateRecord{certSys, certBob, sharedSys, certAlice2}, @@ -1013,8 +1003,7 @@ func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Data "alice-new.example.com|user|alice", }, listKeys()) - // 4. A System certificate removed while present in the report → deleted, since - // System scope is always observed. + // 4. A System certificate removed while present in the report → deleted, since System scope is always observed. require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, []*fleet.HostCertificateRecord{certBob, sharedSys, certAlice2}, fleet.HostCertificateOriginOsquery, @@ -1024,78 +1013,30 @@ func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Data "shared.example.com|system|", "alice-new.example.com|user|alice", }, listKeys()) -} - -// testUpdateHostCertificatesWindowsLegacyScopeMigration verifies that legacy -// Windows scope rows are reconciled onto the corrected scope when a host -// upgrades. Pre-#31294 Windows ingestion stored System certs with username -// "SYSTEM" and mislabeled machine-wide (LocalMachine) certs as User scope with -// an empty username; the corrected scheme uses System scope with an empty -// username for both. Those legacy source rows must NOT survive as phantom -// unobserved scopes. -func testUpdateHostCertificatesWindowsLegacyScopeMigration(t *testing.T, ds *Datastore) { - ctx := t.Context() - const ( - hostID = uint(77) - hostUUID = "windows-legacy-host-uuid" - ) - - mkCert := func(commonName string, source fleet.HostCertificateSource, username string) *fleet.HostCertificateRecord { - tmpl := x509.Certificate{ - Subject: pkix.Name{CommonName: commonName, Organization: []string{"Org"}}, - Issuer: pkix.Name{CommonName: "issuer.example.com"}, - SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec - NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(), - NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(), - BasicConstraintsValid: true, - } - rec := generateTestHostCertificateRecord(t, hostID, &tmpl) - rec.Source = source - rec.Username = username - return rec - } - listKeys := func() []string { - certs, _, err := ds.ListHostCertificates(ctx, hostID, fleet.ListOptions{OrderKey: "common_name"}) - require.NoError(t, err) - keys := make([]string, 0, len(certs)) - for _, c := range certs { - keys = append(keys, fmt.Sprintf("%s|%s|%s", c.CommonName, c.Source, c.Username)) - } - return keys - } - - // 1. Seed legacy-format rows as the old Windows ingest would have (full - // reconcile via nil observedScopes): a System cert tagged "SYSTEM", and a - // machine cert mislabeled as User scope with an empty username. - legacySystem := mkCert("machine-system.example.com", fleet.SystemHostCertificate, "SYSTEM") - legacyMislabeled := mkCert("localmachine.example.com", fleet.UserHostCertificate, "") + // 5. Alice logs back in and re-installs the shared cert, so it is again in both the System store and her store. require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{legacySystem, legacyMislabeled}, - fleet.HostCertificateOriginOsquery, nil)) + []*fleet.HostCertificateRecord{certBob, sharedSys, sharedAlice, certAlice2}, + fleet.HostCertificateOriginOsquery, + []fleet.HostCertificateScope{sysScope, aliceScope, bobScope})) require.ElementsMatch(t, []string{ - "machine-system.example.com|system|SYSTEM", - "localmachine.example.com|user|", + "bob.example.com|user|bob", + "shared.example.com|system|", + "shared.example.com|user|alice", + "alice-new.example.com|user|alice", }, listKeys()) - // 2. Host upgrades; the corrected ingest reports the SAME certificates - // canonically (both System scope, empty username) with Windows observed - // scopes. Clone the seeded records so SHA-1 and validity dates match. - correctedSystem := *legacySystem - correctedSystem.Username = "" - correctedMachine := *legacyMislabeled - correctedMachine.Source = fleet.SystemHostCertificate - correctedMachine.Username = "" + // 6. The shared cert is removed from the machine store while alice is logged off, so it is absent from the report + // entirely. Its (observed) System source is dropped, but her (unobserved) source keeps the cert alive (it survives + // showing only her scope). alice's other cert is likewise preserved. require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{&correctedSystem, &correctedMachine}, + []*fleet.HostCertificateRecord{certBob}, fleet.HostCertificateOriginOsquery, - []fleet.HostCertificateScope{{Source: fleet.SystemHostCertificate}})) - - // 3. The legacy scope rows are gone; each certificate has exactly one - // canonical System source (no phantom duplicates). + []fleet.HostCertificateScope{sysScope, bobScope})) require.ElementsMatch(t, []string{ - "machine-system.example.com|system|", - "localmachine.example.com|system|", + "bob.example.com|user|bob", + "shared.example.com|user|alice", // System source dropped, alice's preserved + "alice-new.example.com|user|alice", }, listKeys()) } From 0cdc73b27721a1a0a3cac7d49add2609085029af Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:20:46 +0000 Subject: [PATCH 09/17] Review fixes --- .../CertificatesTable.tests.tsx | 123 ++++++++---------- .../CertificatesTable/CertificatesTable.tsx | 8 +- 2 files changed, 59 insertions(+), 72 deletions(-) diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tests.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tests.tsx index a018abf9049..a1d318d10c6 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tests.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tests.tsx @@ -3,6 +3,8 @@ import { screen } from "@testing-library/react"; import { createCustomRenderer } from "test/test-utils"; import { noop } from "lodash"; +import { HostPlatform } from "interfaces/platform"; +import { IGetHostCertificatesResponse } from "services/entities/hosts"; import { createMockGetHostCertificatesResponse, createMockHostCertificate, @@ -21,98 +23,85 @@ const baseProps = { onSortChange: noop, }; +const renderTable = ({ + data = createMockGetHostCertificatesResponse(), + hostPlatform = "darwin", + showHelpText = false, +}: { + data?: IGetHostCertificatesResponse; + hostPlatform?: HostPlatform; + showHelpText?: boolean; +} = {}) => + createCustomRenderer()( + + ); + describe("CertificatesTable", () => { it("renders the platform-agnostic 'Scope' column header (replacing 'Keychain')", () => { - const render = createCustomRenderer(); - render( - - ); + renderTable(); expect(screen.getByText("Scope")).toBeInTheDocument(); expect(screen.queryByText("Keychain")).not.toBeInTheDocument(); }); it("shows macOS keychain help text on a darwin host", () => { - const render = createCustomRenderer(); - render( - - ); + renderTable({ hostPlatform: "darwin", showHelpText: true }); expect(screen.getByText(/login \(user\) keychain/i)).toBeInTheDocument(); }); it("shows Personal certificate store help text on a windows host", () => { - const render = createCustomRenderer(); - render( - - ); + renderTable({ hostPlatform: "windows", showHelpText: true }); expect(screen.getByText(/Personal certificate store/i)).toBeInTheDocument(); }); - it("renders the User scope for a user-scoped certificate", () => { - const render = createCustomRenderer(); - render( - - ); + it("renders a user-scoped certificate as 'User' with its owning username", async () => { + const { user } = renderTable({ + data: createMockGetHostCertificatesResponse({ + certificates: [ + createMockHostCertificate({ source: "user", username: "alice" }), + ], + count: 1, + }), + hostPlatform: "windows", + }); expect(screen.getByText("User")).toBeInTheDocument(); + // the owning username is surfaced in the scope cell's tooltip on hover + await user.hover(screen.getByText("User")); + expect(await screen.findByText("alice")).toBeInTheDocument(); }); it("renders a certificate present in two scopes as two distinct rows (shared id must not collapse)", () => { - const render = createCustomRenderer(); // Same certificate (same id) installed in both the System store and a user's // store comes back as two rows sharing host_certificates.id. They must each // render rather than collapsing on the shared id. - render( - - ); + renderTable({ + data: createMockGetHostCertificatesResponse({ + certificates: [ + createMockHostCertificate({ + id: 1, + common_name: "shared.example.com", + source: "system", + username: "", + }), + createMockHostCertificate({ + id: 1, + common_name: "shared.example.com", + source: "user", + username: "alice", + }), + ], + count: 2, + }), + hostPlatform: "windows", + }); // Both scope cells render — without a per-scope row id the two same-id rows // collapse and only one scope would be shown. diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx index 745d588ecda..d5cda6c88f2 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -86,11 +86,9 @@ const CertificatesTable = ({ className={baseClass} columnConfigs={tableConfig} data={data.certificates} - // A certificate present in more than one scope (e.g. a device cert in both - // the System store and a user's store) is returned as multiple rows that - // share the same `id` (the underlying host_certificates row). Key rows on - // scope + username as well so those rows render distinctly instead of - // collapsing into one in react-table. + // A certificate present in more than one scope (e.g. a device cert in both the System store and a user's store) + // is returned as multiple rows that share the same `id` (the underlying host_certificates row). Key rows on scope + // + username as well so those rows render distinctly instead of collapsing into one in react-table. getRowId={(row: IHostCertificate) => `${row.id}-${row.source}-${row.username}` } From 58069e914319ef8ad211215715b5ce8a1eba806d Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:53:47 +0000 Subject: [PATCH 10/17] Fix CI --- .../orchestration/understanding-host-vitals.md | 9 +++++++-- server/service/osquery_test.go | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md index 3396b7ad62f..271c236e942 100644 --- a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md +++ b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md @@ -50,13 +50,18 @@ SELECT - Platforms: windows +- Discovery query: +```sql +SELECT 1 FROM pragma_table_info('certificates') WHERE name = 'subject2' +``` + - Query: ```sql SELECT - ca, common_name, subject, issuer, + ca, common_name, subject2, issuer2, key_algorithm, key_strength, key_usage, signing_algorithm, not_valid_after, not_valid_before, - serial, sha1, username, + serial, sha1, username, sid, store_location, path FROM certificates diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index 8785c1b2f59..a30c34168d2 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -1205,6 +1205,7 @@ func verifyDiscovery(t *testing.T, queries, discovery map[string]string) { hostDetailQueryPrefix + "software_deb_last_opened_at": {}, hostDetailQueryPrefix + "disk_space_darwin": {}, hostDetailQueryPrefix + "disk_space_darwin_legacy": {}, + hostDetailQueryPrefix + "certificates_windows": {}, } for name := range queries { require.NotEmpty(t, discovery[name]) From 2491172fba5ee8562cb775d259d5e70af4dec9a6 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:04:55 +0000 Subject: [PATCH 11/17] Updated osquery-perf for certs --- cmd/osquery-perf/agent.go | 160 +------------ cmd/osquery-perf/certificates.go | 392 +++++++++++++++++++++++++++++++ 2 files changed, 397 insertions(+), 155 deletions(-) create mode 100644 cmd/osquery-perf/certificates.go diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index 52e45c3bf2e..d77f2ac37a8 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -10,7 +10,6 @@ import ( "crypto/tls" "embed" "encoding/base64" - "encoding/hex" "encoding/json" "encoding/xml" "errors" @@ -545,12 +544,13 @@ type agent struct { // a-ok for osquery-perf and load testing). bufferedResults map[resultLog]int - // cache of certificates returned by this agent. Note that this requires - // a mutex even though only used in a.processQuery, that's because both - // the runLoop and the live query goroutines may call DistributedWrite + // cache of this host's per-host certificates (the certs unique to this + // host, excluding the shared certs every host reports). Note that this + // requires a mutex even though only used in a.processQuery, that's because + // both the runLoop and the live query goroutines may call DistributedWrite // (which calls processQuery). certificatesMutex sync.RWMutex - certificatesCache []map[string]string + hostCertSpecs []simulatedCert commonSoftwareNameSuffix string entraIDDeviceID string @@ -2815,156 +2815,6 @@ func (a *agent) diskEncryptionLinux() []map[string]string { } } -func (a *agent) certificatesDarwin() []map[string]string { - a.certificatesMutex.RLock() - cache := a.certificatesCache - a.certificatesMutex.RUnlock() - - // 90% of the time certificates do not change - if rand.Intn(100) < 90 && len(cache) > 0 { - return cache - } - - // between 2 and 10 certificates (probably impossible to have 0, quick check - // on dogfood gives between 4-7) - count := rand.Intn(9) + 2 - - sources := []string{"system", "user"} - users := a.hostUsers() - const day = 24 * time.Hour - - results := make([]map[string]string, count) - for i := range count { - m := make(map[string]string, 12) - m["ca"] = fmt.Sprint(rand.Intn(2)) - m["common_name"] = uuid.NewString() - m["issuer"] = fmt.Sprintf("/C=US/O=Issuer %d Inc./CN=Issuer %d Common Name", i, i) - m["subject"] = fmt.Sprintf("/C=US/O=Subject %d Inc./OU=Subject %d Org Unit/CN=Subject %d Common Name", i, i, i) - m["key_algorithm"] = "rsaEncryption" - m["key_strength"] = "2048" - m["key_usage"] = "Data Encipherment, Key Encipherment, Digital Signature" - m["serial"] = uuid.NewString() - m["signing_algorithm"] = "sha256WithRSAEncryption" - // generate so that it may be expired - m["not_valid_after"] = fmt.Sprint(time.Now().Add(-1 * day).Add(time.Duration(rand.Intn(100)) * day).Unix()) - // notBefore is always in the past (1-10 days in the past) - m["not_valid_before"] = fmt.Sprint(time.Now().Add(-time.Duration(rand.Intn(10)+1) * day).Unix()) - rawHash := sha1.Sum([]byte(m["serial"])) //nolint: gosec - hash := hex.EncodeToString(rawHash[:]) - m["sha1"] = hash - m["source"] = sources[rand.Intn(2)] - - if m["source"] == "user" { - // Set username for user keychain certificates - user := users[rand.Intn(len(users))] - m["path"] = fmt.Sprintf(`/Users/%s/Library/Keychains/login.keychain-db`, user["username"]) - } - - results[i] = m - } - - a.certificatesMutex.Lock() - a.certificatesCache = results - a.certificatesMutex.Unlock() - return results -} - -func (a *agent) certificatesWindows() []map[string]string { - a.certificatesMutex.RLock() - cache := a.certificatesCache - a.certificatesMutex.RUnlock() - - // 90% of the time certificates do not change - if rand.Intn(100) < 90 && len(cache) > 0 { - return cache - } - - const day = 24 * time.Hour - - // custom SCEP profile ID used for certs issued via custom SCEP profiles (inserted by - // FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID) - // - // TODO: make this configurable as a loadtest agent parameter? for now, just hardcode it and try - // manipulating it in loadtest DB directly if needed. - profileIDCustomSCEP := "w2a6fd2c4-0018-4bdc-8046-c7342962b576" - - // when windows hosts enroll to Fleet MDM, we issue them a unique cert during the WSTEP/SCEP process - uuidFleetSCEP := uuid.NewString() - - // uuids that we'll use in serials and hashes to ensure uniqueness - serial1 := uuid.NewString() - s1 := sha1.Sum([]byte(serial1)) //nolint: gosec - - serial2 := uuid.NewString() - s2 := sha1.Sum([]byte(serial2)) //nolint: gosec - - // Fleet SCEP cert example based on data from a real Windows host - c1 := map[string]string{ - "ca": "-1", - "common_name": uuidFleetSCEP, - "subject": "Fleet, " + uuidFleetSCEP, - "issuer": "\"\", scep-ca, SCEP CA, FleetDM", - "key_algorithm": "RSA", - "key_strength": "2160", - "key_usage": "CERT_KEY_ENCIPHERMENT_KEY_USAGE,CERT_DIGITAL_SIGNATURE_KEY_USAGE", - "signing_algorithm": "sha256RSA", - // generate so that it may be expired - "not_valid_after": fmt.Sprint(time.Now().Add(-1 * day).Add(time.Duration(rand.Intn(100)) * day).Unix()), - // notBefore is always in the past (1-10 days in the past) - "not_valid_before": fmt.Sprint(time.Now().Add(-time.Duration(rand.Intn(10)+1) * day).Unix()), - "serial": serial1, - "sha1": hex.EncodeToString(s1[:]), - "username": "Admin", - "path": "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000\\Personal", - } - // Custom SCEP cert example based on data from a real Windows host - c2 := map[string]string{ - "ca": "-1", - "common_name": fmt.Sprintf("%s User\n CN", profileIDCustomSCEP), - "subject": fmt.Sprintf("fleet-%s, \"%s User\n CN\"", profileIDCustomSCEP, profileIDCustomSCEP), - "issuer": "US, scep-ca, SCEP CA, MICROMDM SCEP CA", - "key_algorithm": "RSA", - "key_strength": "1120", - "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", - "signing_algorithm": "sha256RSA", - // generate so that it may be expired - "not_valid_after": fmt.Sprint(time.Now().Add(-1 * day).Add(time.Duration(rand.Intn(100)) * day).Unix()), - // notBefore is always in the past (1-10 days in the past) - "not_valid_before": fmt.Sprint(time.Now().Add(-time.Duration(rand.Intn(10)+1) * day).Unix()), - "serial": serial2, - "sha1": hex.EncodeToString(s2[:]), - "username": "Admin", - "path": "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000\\Personal", - } - - // We'll use the examples above to create rows with minor variations, similar to what - // we would get from a real Windows host. - c3 := maps.Clone(c1) - c3["username"] = "SYSTEM" - c3["path"] = "Users\\S-1-5-18\\Personal" - - c4 := maps.Clone(c1) - c4["username"] = "SYSTEM" - c4["path"] = "CurrentUser\\Personal" - - c5 := maps.Clone(c1) - c5["username"] = "SYSTEM" - c5["path"] = "Users\\S-1-5-18\\Personal" - - c6 := maps.Clone(c1) - c6["path"] = "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000_Classes\\Personal" - - c7 := maps.Clone(c2) - c7["path"] = "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000_Classes\\Personal" - - rows := []map[string]string{c1, c2, c3, c4, c5, c6, c7} - - a.certificatesMutex.Lock() - a.certificatesCache = rows - a.certificatesMutex.Unlock() - return rows -} - func (a *agent) orbitInfo() []map[string]string { version := "1.22.0" desktopVersion := version diff --git a/cmd/osquery-perf/certificates.go b/cmd/osquery-perf/certificates.go new file mode 100644 index 00000000000..7d92163d693 --- /dev/null +++ b/cmd/osquery-perf/certificates.go @@ -0,0 +1,392 @@ +package main + +import ( + "crypto/sha1" //nolint:gosec + "encoding/hex" + "fmt" + "maps" + "math/rand/v2" + "strings" + "time" + + "github.com/google/uuid" +) + +// simulatedCert is a platform-neutral description of a certificate that +// osquery-perf reports for the `certificates` detail query. The platform +// renderers (darwinRow / windowsRows) translate it into the column shape +// osquery produces on that platform. +type simulatedCert struct { + ca bool + commonName string + subjectCommonName string + subjectOrg string + subjectOrgUnit string + subjectCountry string + issuerCommonName string + issuerOrg string + issuerCountry string + keyAlgorithm string + keyStrength string + keyUsage string + signingAlgorithm string + serial string + notValidAfterUnix string + notValidBeforeUnix string + // user reports whether the certificate lives in a user's store (true) or in + // the machine/system store (false). username is the owning user when user is + // true. + user bool + username string +} + +// sha1Hex returns the hex-encoded SHA1 osquery would report for this cert. It +// is derived from the serial so that shared certs (fixed serial) dedupe to a +// single host_certificates row across all hosts, while per-host certs (uuid +// serial) stay unique per host. +func (c simulatedCert) sha1Hex() string { + sum := sha1.Sum([]byte(c.serial)) //nolint: gosec + return hex.EncodeToString(sum[:]) +} + +// sharedCerts are reported by every simulated host (common root and +// intermediate CAs). Their serials are fixed, so the Fleet server dedupes them +// into a single host_certificates row referenced by every host. They are +// machine-scoped and long-lived. +var sharedCerts = []simulatedCert{ + { + ca: true, commonName: "Fleet Root CA", + subjectCommonName: "Fleet Root CA", subjectOrg: "Fleet Device Management Inc.", subjectCountry: "US", + issuerCommonName: "Fleet Root CA", issuerOrg: "Fleet Device Management Inc.", issuerCountry: "US", + keyAlgorithm: "rsaEncryption", keyStrength: "4096", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-fleet-root-ca", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", // 2020-01-01 .. 2030-01-01 + }, + { + ca: true, commonName: "Fleet Intermediate CA", + subjectCommonName: "Fleet Intermediate CA", subjectOrg: "Fleet Device Management Inc.", subjectOrgUnit: "Issuing", subjectCountry: "US", + issuerCommonName: "Fleet Root CA", issuerOrg: "Fleet Device Management Inc.", issuerCountry: "US", + keyAlgorithm: "rsaEncryption", keyStrength: "2048", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-fleet-intermediate-ca", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, + { + ca: true, commonName: "DigiCert Global Root CA", + subjectCommonName: "DigiCert Global Root CA", subjectOrg: "DigiCert Inc", subjectCountry: "US", + issuerCommonName: "DigiCert Global Root CA", issuerOrg: "DigiCert Inc", issuerCountry: "US", + keyAlgorithm: "rsaEncryption", keyStrength: "2048", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-digicert-global-root-ca", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, + { + ca: true, commonName: "Microsoft Root Certificate Authority 2011", + subjectCommonName: "Microsoft Root Certificate Authority 2011", subjectOrg: "Microsoft Corporation", subjectCountry: "US", + issuerCommonName: "Microsoft Root Certificate Authority 2011", issuerOrg: "Microsoft Corporation", issuerCountry: "US", + keyAlgorithm: "rsaEncryption", keyStrength: "4096", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-microsoft-root-ca-2011", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, + { + ca: true, commonName: "GlobalSign Root CA", + subjectCommonName: "GlobalSign Root CA", subjectOrg: "GlobalSign nv-sa", subjectOrgUnit: "Root CA", subjectCountry: "BE", + issuerCommonName: "GlobalSign Root CA", issuerOrg: "GlobalSign nv-sa", issuerCountry: "BE", + keyAlgorithm: "rsaEncryption", keyStrength: "2048", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-globalsign-root-ca", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, + { + ca: true, commonName: "USERTrust RSA Certification Authority", + subjectCommonName: "USERTrust RSA Certification Authority", subjectOrg: "The USERTRUST Network", subjectCountry: "US", + issuerCommonName: "USERTrust RSA Certification Authority", issuerOrg: "The USERTRUST Network", issuerCountry: "US", + keyAlgorithm: "rsaEncryption", keyStrength: "4096", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha384WithRSAEncryption", serial: "osquery-perf-shared-usertrust-rsa-ca", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, + { + ca: true, commonName: "ISRG Root X1", + subjectCommonName: "ISRG Root X1", subjectOrg: "Internet Security Research Group", subjectCountry: "US", + issuerCommonName: "ISRG Root X1", issuerOrg: "Internet Security Research Group", issuerCountry: "US", + keyAlgorithm: "rsaEncryption", keyStrength: "4096", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-isrg-root-x1", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, + { + ca: true, commonName: "Amazon Root CA 1", + subjectCommonName: "Amazon Root CA 1", subjectOrg: "Amazon", subjectCountry: "US", + issuerCommonName: "Amazon Root CA 1", issuerOrg: "Amazon", issuerCountry: "US", + keyAlgorithm: "rsaEncryption", keyStrength: "2048", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-amazon-root-ca-1", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, + { + ca: true, commonName: "Baltimore CyberTrust Root", + subjectCommonName: "Baltimore CyberTrust Root", subjectOrg: "Baltimore", subjectOrgUnit: "CyberTrust", subjectCountry: "IE", + issuerCommonName: "Baltimore CyberTrust Root", issuerOrg: "Baltimore", issuerCountry: "IE", + keyAlgorithm: "rsaEncryption", keyStrength: "2048", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-baltimore-cybertrust-root", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, + { + ca: true, commonName: "Entrust Root Certification Authority - G2", + subjectCommonName: "Entrust Root Certification Authority - G2", subjectOrg: "Entrust, Inc.", subjectOrgUnit: "See www.entrust.net/legal-terms", subjectCountry: "US", + issuerCommonName: "Entrust Root Certification Authority - G2", issuerOrg: "Entrust, Inc.", issuerCountry: "US", + keyAlgorithm: "rsaEncryption", keyStrength: "2048", keyUsage: "Certificate Signing, CRL Signing", + signingAlgorithm: "sha256WithRSAEncryption", serial: "osquery-perf-shared-entrust-root-ca-g2", + notValidBeforeUnix: "1577836800", notValidAfterUnix: "1893456000", + }, +} + +const certDay = 24 * time.Hour + +// generateCertSpecs returns the certs this host reports: the constant shared +// certs plus this host's per-host certs. Per-host certs are generated once and +// cached so they're stable across polls, then occasionally churned to simulate +// certificate rotation/installs. Shared certs are never churned. +func (a *agent) generateCertSpecs() []simulatedCert { + a.certificatesMutex.Lock() + defer a.certificatesMutex.Unlock() + + switch { + case a.hostCertSpecs == nil: + a.hostCertSpecs = a.newPerHostCertSpecs() + case rand.IntN(100) < 5: + // 5% chance for some of this host's certs to change between polls. + a.churnPerHostCertSpecs() + } + + specs := make([]simulatedCert, 0, len(sharedCerts)+len(a.hostCertSpecs)) + specs = append(specs, sharedCerts...) + specs = append(specs, a.hostCertSpecs...) + return specs +} + +// newPerHostCertSpecs generates 0-10 certificates unique to this host, with +// random (uuid) serials so each host's certs are distinct in the Fleet server. +func (a *agent) newPerHostCertSpecs() []simulatedCert { + count := rand.IntN(11) // 0..10 + users := a.hostUsers() + specs := make([]simulatedCert, 0, count+1) + for i := range count { + specs = append(specs, a.newPerHostCertSpec(i, users)) + } + // Model a device certificate present in both the machine store and a user's + // store (same SHA1, two scopes), exercising the server's cross-scope + // handling (one host_certificates row, two host_certificate_sources rows). + if count > 0 && len(users) > 0 { + dup := specs[0] + dup.user = !specs[0].user + if dup.user { + dup.username = users[rand.IntN(len(users))]["username"] + } else { + dup.username = "" + } + specs = append(specs, dup) + } + return specs +} + +func (a *agent) newPerHostCertSpec(i int, users []map[string]string) simulatedCert { + user := rand.IntN(2) == 0 && len(users) > 0 + username := "" + if user { + username = users[rand.IntN(len(users))]["username"] + } + return simulatedCert{ + commonName: uuid.NewString(), + subjectCommonName: fmt.Sprintf("Subject %d Common Name", i), + subjectOrg: fmt.Sprintf("Subject %d Inc.", i), + subjectOrgUnit: fmt.Sprintf("Subject %d Org Unit", i), + subjectCountry: "US", + issuerCommonName: fmt.Sprintf("Issuer %d Common Name", i), + issuerOrg: fmt.Sprintf("Issuer %d Inc.", i), + issuerCountry: "US", + keyAlgorithm: "rsaEncryption", + keyStrength: "2048", + keyUsage: "Data Encipherment, Key Encipherment, Digital Signature", + signingAlgorithm: "sha256WithRSAEncryption", + serial: uuid.NewString(), + // generate so that it may be expired (notAfter in [-1d, +99d]) + notValidAfterUnix: fmt.Sprint(time.Now().Add(-1 * certDay).Add(time.Duration(rand.IntN(100)) * certDay).Unix()), + // notBefore is always in the past (1-10 days) + notValidBeforeUnix: fmt.Sprint(time.Now().Add(-time.Duration(rand.IntN(10)+1) * certDay).Unix()), + user: user, + username: username, + } +} + +// churnPerHostCertSpecs rotates 1..N of this host's per-host certs by assigning +// new serials (and thus new SHA1s), simulating certificate renewal/reinstall. +func (a *agent) churnPerHostCertSpecs() { + if len(a.hostCertSpecs) == 0 { + return + } + n := rand.IntN(min(10, len(a.hostCertSpecs))) + 1 + for range n { + idx := rand.IntN(len(a.hostCertSpecs)) + a.hostCertSpecs[idx].serial = uuid.NewString() + a.hostCertSpecs[idx].commonName = uuid.NewString() + a.hostCertSpecs[idx].notValidAfterUnix = fmt.Sprint(time.Now().Add(-1 * certDay).Add(time.Duration(rand.IntN(100)) * certDay).Unix()) + } +} + +func boolStr(b bool) string { + if b { + return "1" + } + return "0" +} + +// darwinDN renders a slash-delimited distinguished name (e.g. +// /C=US/O=Org/OU=Unit/CN=Name) as osquery returns on macOS. Empty fields are +// omitted. +func darwinDN(country, org, orgUnit, commonName string) string { + var b strings.Builder + if country != "" { + b.WriteString("/C=" + country) + } + if org != "" { + b.WriteString("/O=" + org) + } + if orgUnit != "" { + b.WriteString("/OU=" + orgUnit) + } + if commonName != "" { + b.WriteString("/CN=" + commonName) + } + return b.String() +} + +// windowsDN renders an X.500 (RFC 1779) distinguished name (e.g. +// "CN=Name, O=Org, OU=Unit, C=US") as osquery returns in subject2/issuer2 on +// Windows starting with osquery 5.23.1. +func windowsDN(country, org, orgUnit, commonName string) string { + var parts []string + if commonName != "" { + parts = append(parts, "CN="+commonName) + } + if org != "" { + parts = append(parts, "O="+org) + } + if orgUnit != "" { + parts = append(parts, "OU="+orgUnit) + } + if country != "" { + parts = append(parts, "C="+country) + } + return strings.Join(parts, ", ") +} + +// windowsLegacyDN renders the simple, values-only distinguished name that +// pre-5.23.1 osquery returned in subject/issuer. Kept so the generator also +// works against older Fleet servers that read those columns. +func windowsLegacyDN(country, org, orgUnit, commonName string) string { + var parts []string + for _, v := range []string{commonName, orgUnit, org, country} { + if v != "" { + parts = append(parts, v) + } + } + return strings.Join(parts, ", ") +} + +// windowsUserSID returns a stable per-(host, user) security identifier so a +// user's certs classify as User scope and stay consistent across polls. +func (a *agent) windowsUserSID(username string) string { + var h uint32 = 2166136261 + for i := 0; i < len(username); i++ { + h = (h ^ uint32(username[i])) * 16777619 + } + rid := 1000 + int(h%5000) + return fmt.Sprintf("S-1-5-21-%d-%d-%d-%d", 1000000000+a.agentIndex, 2000000000, 3000000000, rid) +} + +func (a *agent) certificatesDarwin() []map[string]string { + specs := a.generateCertSpecs() + rows := make([]map[string]string, 0, len(specs)) + for _, c := range specs { + rows = append(rows, c.darwinRow()) + } + return rows +} + +func (c simulatedCert) darwinRow() map[string]string { + source := "system" + path := "/Library/Keychains/System.keychain" + if c.user { + source = "user" + path = fmt.Sprintf("/Users/%s/Library/Keychains/login.keychain-db", c.username) + } + return map[string]string{ + "ca": boolStr(c.ca), + "common_name": c.commonName, + "subject": darwinDN(c.subjectCountry, c.subjectOrg, c.subjectOrgUnit, c.subjectCommonName), + "issuer": darwinDN(c.issuerCountry, c.issuerOrg, "", c.issuerCommonName), + "key_algorithm": c.keyAlgorithm, + "key_strength": c.keyStrength, + "key_usage": c.keyUsage, + "signing_algorithm": c.signingAlgorithm, + "not_valid_after": c.notValidAfterUnix, + "not_valid_before": c.notValidBeforeUnix, + "serial": c.serial, + "sha1": c.sha1Hex(), + "source": source, + "path": path, + } +} + +func (a *agent) certificatesWindows() []map[string]string { + specs := a.generateCertSpecs() + // User certs are enumerated from more than one hive, so allocate room for ~2 + // rows per spec. + rows := make([]map[string]string, 0, len(specs)*2) + for _, c := range specs { + rows = append(rows, a.windowsRows(c)...) + } + return rows +} + +// windowsRows renders the osquery `certificates` rows for a cert on Windows. +// Machine-scoped certs produce one row. User-scoped certs produce the redundant +// rows osquery returns from the user's Personal hive and its companion _Classes +// hive (the Fleet server dedupes them by SHA1 + scope + username). +func (a *agent) windowsRows(c simulatedCert) []map[string]string { + base := map[string]string{ + "ca": boolStr(c.ca), + "common_name": c.commonName, + // subject2/issuer2 are read by Fleet servers with Windows certificate + // support (osquery 5.23.1+); subject/issuer are kept for older servers. + "subject2": windowsDN(c.subjectCountry, c.subjectOrg, c.subjectOrgUnit, c.subjectCommonName), + "issuer2": windowsDN(c.issuerCountry, c.issuerOrg, "", c.issuerCommonName), + "subject": windowsLegacyDN(c.subjectCountry, c.subjectOrg, c.subjectOrgUnit, c.subjectCommonName), + "issuer": windowsLegacyDN(c.issuerCountry, c.issuerOrg, "", c.issuerCommonName), + "key_algorithm": c.keyAlgorithm, + "key_strength": c.keyStrength, + "key_usage": c.keyUsage, + "signing_algorithm": c.signingAlgorithm, + "not_valid_after": c.notValidAfterUnix, + "not_valid_before": c.notValidBeforeUnix, + "serial": c.serial, + "sha1": c.sha1Hex(), + } + + if !c.user { + row := maps.Clone(base) + row["sid"] = "" + row["username"] = "" + row["store_location"] = "LocalMachine" + row["path"] = "LocalMachine\\Personal" + return []map[string]string{row} + } + + sid := a.windowsUserSID(c.username) + personal := maps.Clone(base) + personal["sid"] = sid + personal["username"] = c.username + personal["store_location"] = "Users" + personal["path"] = fmt.Sprintf("Users\\%s\\Personal", sid) + + classes := maps.Clone(personal) + classes["path"] = fmt.Sprintf("Users\\%s_Classes\\Personal", sid) + + return []map[string]string{personal, classes} +} From 5a17bb80596af96bb7c5b20bd82720d3e4c58d19 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:04:36 +0000 Subject: [PATCH 12/17] Review fixes --- cmd/osquery-perf/certificates.go | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/cmd/osquery-perf/certificates.go b/cmd/osquery-perf/certificates.go index 7d92163d693..8a60fb5bcee 100644 --- a/cmd/osquery-perf/certificates.go +++ b/cmd/osquery-perf/certificates.go @@ -5,7 +5,10 @@ import ( "encoding/hex" "fmt" "maps" - "math/rand/v2" + // osquery-perf shares one global math/rand RNG seeded from the --seed flag + // (see rand.Seed in agent.go) so load-test runs are reproducible; this file + // uses the same source rather than math/rand/v2's unseedable globals. + "math/rand" //nolint:depguard "strings" "time" @@ -149,7 +152,7 @@ func (a *agent) generateCertSpecs() []simulatedCert { switch { case a.hostCertSpecs == nil: a.hostCertSpecs = a.newPerHostCertSpecs() - case rand.IntN(100) < 5: + case rand.Intn(100) < 5: // 5% chance for some of this host's certs to change between polls. a.churnPerHostCertSpecs() } @@ -163,7 +166,7 @@ func (a *agent) generateCertSpecs() []simulatedCert { // newPerHostCertSpecs generates 0-10 certificates unique to this host, with // random (uuid) serials so each host's certs are distinct in the Fleet server. func (a *agent) newPerHostCertSpecs() []simulatedCert { - count := rand.IntN(11) // 0..10 + count := rand.Intn(11) // 0..10 users := a.hostUsers() specs := make([]simulatedCert, 0, count+1) for i := range count { @@ -176,7 +179,7 @@ func (a *agent) newPerHostCertSpecs() []simulatedCert { dup := specs[0] dup.user = !specs[0].user if dup.user { - dup.username = users[rand.IntN(len(users))]["username"] + dup.username = users[rand.Intn(len(users))]["username"] } else { dup.username = "" } @@ -186,10 +189,10 @@ func (a *agent) newPerHostCertSpecs() []simulatedCert { } func (a *agent) newPerHostCertSpec(i int, users []map[string]string) simulatedCert { - user := rand.IntN(2) == 0 && len(users) > 0 + user := rand.Intn(2) == 0 && len(users) > 0 username := "" if user { - username = users[rand.IntN(len(users))]["username"] + username = users[rand.Intn(len(users))]["username"] } return simulatedCert{ commonName: uuid.NewString(), @@ -206,9 +209,9 @@ func (a *agent) newPerHostCertSpec(i int, users []map[string]string) simulatedCe signingAlgorithm: "sha256WithRSAEncryption", serial: uuid.NewString(), // generate so that it may be expired (notAfter in [-1d, +99d]) - notValidAfterUnix: fmt.Sprint(time.Now().Add(-1 * certDay).Add(time.Duration(rand.IntN(100)) * certDay).Unix()), + notValidAfterUnix: fmt.Sprint(time.Now().Add(-1 * certDay).Add(time.Duration(rand.Intn(100)) * certDay).Unix()), // notBefore is always in the past (1-10 days) - notValidBeforeUnix: fmt.Sprint(time.Now().Add(-time.Duration(rand.IntN(10)+1) * certDay).Unix()), + notValidBeforeUnix: fmt.Sprint(time.Now().Add(-time.Duration(rand.Intn(10)+1) * certDay).Unix()), user: user, username: username, } @@ -220,12 +223,12 @@ func (a *agent) churnPerHostCertSpecs() { if len(a.hostCertSpecs) == 0 { return } - n := rand.IntN(min(10, len(a.hostCertSpecs))) + 1 + n := rand.Intn(min(10, len(a.hostCertSpecs))) + 1 for range n { - idx := rand.IntN(len(a.hostCertSpecs)) + idx := rand.Intn(len(a.hostCertSpecs)) a.hostCertSpecs[idx].serial = uuid.NewString() a.hostCertSpecs[idx].commonName = uuid.NewString() - a.hostCertSpecs[idx].notValidAfterUnix = fmt.Sprint(time.Now().Add(-1 * certDay).Add(time.Duration(rand.IntN(100)) * certDay).Unix()) + a.hostCertSpecs[idx].notValidAfterUnix = fmt.Sprint(time.Now().Add(-1 * certDay).Add(time.Duration(rand.Intn(100)) * certDay).Unix()) } } From cae54a8c5580c6cfdd131d949fc952656464b423 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:00:35 +0000 Subject: [PATCH 13/17] Review fixes --- .../understanding-host-vitals.md | 2 +- server/datastore/mysql/host_certificates.go | 2 +- .../datastore/mysql/host_certificates_test.go | 111 ++++++++++++++++-- server/service/osquery_utils/queries.go | 2 +- server/service/osquery_utils/queries_test.go | 78 ++++++------ 5 files changed, 137 insertions(+), 58 deletions(-) diff --git a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md index 271c236e942..c19001718e1 100644 --- a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md +++ b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md @@ -61,7 +61,7 @@ SELECT ca, common_name, subject2, issuer2, key_algorithm, key_strength, key_usage, signing_algorithm, not_valid_after, not_valid_before, - serial, sha1, username, sid, store_location, + serial, sha1, username, sid, path FROM certificates diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 3dcc7d651cd..9bb4638aee2 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -475,7 +475,7 @@ func loadHostCertIDsForSHA1DB(ctx context.Context, tx sqlx.QueryerContext, hostI FROM host_certificates hc WHERE - hc.sha1_sum IN (?) AND hc.host_id = ?` + hc.sha1_sum IN (?) AND hc.host_id = ? AND hc.deleted_at IS NULL` var certs []*fleet.HostCertificateRecord stmt, args, err := sqlx.In(stmt, binarySHA1s, hostID) diff --git a/server/datastore/mysql/host_certificates_test.go b/server/datastore/mysql/host_certificates_test.go index a94b84337de..971f9f27713 100644 --- a/server/datastore/mysql/host_certificates_test.go +++ b/server/datastore/mysql/host_certificates_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha1" // nolint:gosec // test-only unique sha1 generator "crypto/x509" "crypto/x509/pkix" + "encoding/hex" "encoding/pem" "fmt" "math/big" @@ -17,6 +18,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,6 +36,7 @@ func TestHostCertificates(t *testing.T) { {"Matcher recovers stuck hmmc rows", testMatcherRecoversStuckHMMCRows}, {"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation}, {"Windows scope-aware reconciliation", testUpdateHostCertificatesWindowsScopeReconciliation}, + {"Re-ingest after soft delete attaches sources to live row", testUpdateHostCertificatesReingestAfterSoftDelete}, {"Origin-scoped delete", testUpdateHostCertificatesOriginScopedDelete}, {"Origin downgrade on osquery rediscovery", testUpdateHostCertificatesOriginDowngrade}, {"Create certificates with long country code", testHostCertificateWithInvalidCountryCode}, @@ -871,6 +874,101 @@ func testInsertingHostMDMManagedCertificatesFromIngestion(t *testing.T, ds *Data assert.Equal(t, fleet.CAConfigAssetType(""), nonProxiedRow2.Type, "Type should still be NULL/empty after update") } +// testUpdateHostCertificatesReingestAfterSoftDelete covers the soft-delete-then-re-report cycle +func testUpdateHostCertificatesReingestAfterSoftDelete(t *testing.T, ds *Datastore) { + ctx := t.Context() + const ( + hostID = uint(77) + hostUUID = "windows-reingest-host-uuid" + ) + + cert := mkTestCertRecord(t, hostID, "reingest.example.com", fleet.UserHostCertificate, "alice") + sha1Hex := strings.ToUpper(hex.EncodeToString(cert.SHA1Sum)) + scopes := []fleet.HostCertificateScope{{Source: fleet.UserHostCertificate, Username: "alice"}} + + ingest := func() { + reported := *cert // fresh copy each report; UpdateHostCertificates mutates the record + require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, + []*fleet.HostCertificateRecord{&reported}, fleet.HostCertificateOriginOsquery, scopes)) + } + softDeleteAll := func() { + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE host_certificates SET deleted_at = NOW(6) WHERE host_id = ?`, hostID) + return err + }) + } + + ingest() + // Soft-delete the row, then re-report + softDeleteAll() + ingest() + + // Also clone the live row as a soft-deleted duplicate with a HIGHER id. + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, ` + INSERT INTO host_certificates + (host_id, sha1_sum, not_valid_after, not_valid_before, 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, origin, deleted_at) + SELECT host_id, sha1_sum, not_valid_after, not_valid_before, 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, origin, NOW(6) + FROM host_certificates WHERE host_id = ? AND deleted_at IS NULL`, hostID) + return err + }) + + var rowStates []struct { + ID uint `db:"id"` + Deleted bool `db:"deleted"` + } + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.SelectContext(ctx, q, &rowStates, + `SELECT id, deleted_at IS NOT NULL AS deleted FROM host_certificates WHERE host_id = ? ORDER BY id`, hostID) + }) + require.Len(t, rowStates, 3) + require.True(t, rowStates[0].Deleted, "expected the original row to be the soft-deleted one") + require.False(t, rowStates[1].Deleted, "expected the re-ingested row to be live") + require.True(t, rowStates[2].Deleted, "expected the cloned row to be soft-deleted") + liveID := rowStates[1].ID + + // The sha1 -> id lookup used to attach sources must resolve to the live row only. + got, err := loadHostCertIDsForSHA1DB(ctx, ds.reader(ctx), hostID, []string{sha1Hex}) + require.NoError(t, err) + require.Equal(t, map[string]uint{sha1Hex: liveID}, got) + + // End to end: the certificate lists with its user source intact. + listed, _, err := ds.ListHostCertificates(ctx, hostID, fleet.ListOptions{}) + require.NoError(t, err) + require.Len(t, listed, 1) + require.Equal(t, fleet.UserHostCertificate, listed[0].Source) + require.Equal(t, "alice", listed[0].Username) + + // With every row soft-deleted, the lookup returns nothing. + softDeleteAll() + got, err = loadHostCertIDsForSHA1DB(ctx, ds.reader(ctx), hostID, []string{sha1Hex}) + require.NoError(t, err) + require.Empty(t, got) +} + +// mkTestCertRecord builds a HostCertificateRecord for hostID with a random serial, valid from an hour ago to 24 hours +// from now, scoped to the given source and username. +func mkTestCertRecord(t *testing.T, hostID uint, commonName string, source fleet.HostCertificateSource, username string) *fleet.HostCertificateRecord { + tmpl := x509.Certificate{ + Subject: pkix.Name{CommonName: commonName, Organization: []string{"Org"}}, + Issuer: pkix.Name{CommonName: "issuer.example.com"}, + SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec + NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(), + NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(), + BasicConstraintsValid: true, + } + rec := generateTestHostCertificateRecord(t, hostID, &tmpl) + rec.Source = source + rec.Username = username + return rec +} + func generateTestHostCertificateRecord(t *testing.T, hostID uint, template *x509.Certificate) *fleet.HostCertificateRecord { b, _, err := GenerateTestCertBytes(template) require.NoError(t, err) @@ -926,18 +1024,7 @@ func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Data ) mkCert := func(commonName string, source fleet.HostCertificateSource, username string) *fleet.HostCertificateRecord { - tmpl := x509.Certificate{ - Subject: pkix.Name{CommonName: commonName, Organization: []string{"Org"}}, - Issuer: pkix.Name{CommonName: "issuer.example.com"}, - SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec - NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(), - NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(), - BasicConstraintsValid: true, - } - rec := generateTestHostCertificateRecord(t, hostID, &tmpl) - rec.Source = source - rec.Username = username - return rec + return mkTestCertRecord(t, hostID, commonName, source, username) } listKeys := func() []string { diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 017ce202977..dd08f94efc8 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -867,7 +867,7 @@ var extraDetailQueries = map[string]DetailQuery{ ca, common_name, subject2, issuer2, key_algorithm, key_strength, key_usage, signing_algorithm, not_valid_after, not_valid_before, - serial, sha1, username, sid, store_location, + serial, sha1, username, sid, path FROM certificates diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 6f06e3b314e..586b27e2654 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -3084,50 +3084,44 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { // Machine-wide LocalMachine store: empty sid and empty username. machine := windowsCertRow(map[string]string{ - "common_name": "Fleet Root CA", - "subject2": "CN=Fleet Root CA, O=Fleet Device Management Inc., OU=Engineering, C=US", - "issuer2": "CN=Fleet Root CA, O=Fleet Device Management Inc., C=US", - "sha1": machineSHA1, - "username": "", - "sid": "", - "store_location": "LocalMachine", - "path": "LocalMachine\\Personal", + "common_name": "Fleet Root CA", + "subject2": "CN=Fleet Root CA, O=Fleet Device Management Inc., OU=Engineering, C=US", + "issuer2": "CN=Fleet Root CA, O=Fleet Device Management Inc., C=US", + "sha1": machineSHA1, + "username": "", + "sid": "", + "path": "LocalMachine\\Personal", }) // LocalSystem account (S-1-5-18) store, enumerated three times across redundant hive views. These must collapse into // a single System entry and be retained (a distinct cert from LocalMachine, often a device/enrollment cert). sysAcctCurrentUser := windowsCertRow(map[string]string{ - "common_name": "Device Enrollment", - "subject2": "CN=Device Enrollment, C=US", - "issuer2": "CN=Fleet SCEP CA, C=US", - "sha1": sysAcctSHA1, - "username": "SYSTEM", - "sid": "S-1-5-18", - "store_location": "CurrentUser", - "path": "CurrentUser\\Personal", + "common_name": "Device Enrollment", + "subject2": "CN=Device Enrollment, C=US", + "issuer2": "CN=Fleet SCEP CA, C=US", + "sha1": sysAcctSHA1, + "username": "SYSTEM", + "sid": "S-1-5-18", + "path": "CurrentUser\\Personal", }) sysAcctServices := maps.Clone(sysAcctCurrentUser) - sysAcctServices["store_location"] = "Services" sysAcctServices["path"] = "Services\\S-1-5-18\\Personal" sysAcctUsersHive := maps.Clone(sysAcctCurrentUser) - sysAcctUsersHive["store_location"] = "Users" sysAcctUsersHive["path"] = "Users\\S-1-5-18\\Personal" // Real interactive user (S-1-5-21-*), present in the Personal hive and the redundant _Classes sub-hive (same base // SID). These collapse into one User/Admin entry. The issuer carries a quoted comma to exercise the parser. userAdmin := windowsCertRow(map[string]string{ - "common_name": "admin@example.com", - "subject2": "CN=admin@example.com, OU=fleet-abc, OU=People, O=Example", - "issuer2": `CN=SCEP CA, O="Example, Inc.", C=US`, - "sha1": userSHA1, - "username": "Admin", - "sid": userSID, - "store_location": "Users", - "path": "Users\\" + userSID + "\\Personal", + "common_name": "admin@example.com", + "subject2": "CN=admin@example.com, OU=fleet-abc, OU=People, O=Example", + "issuer2": `CN=SCEP CA, O="Example, Inc.", C=US`, + "sha1": userSHA1, + "username": "Admin", + "sid": userSID, + "path": "Users\\" + userSID + "\\Personal", }) userAdminClasses := maps.Clone(userAdmin) userAdminClasses["sid"] = userSID + "_Classes" - userAdminClasses["store_location"] = "Users" userAdminClasses["path"] = "Users\\" + userSID + "_Classes\\Personal" // The same certificate (same SHA1) also installed in a second user's store @@ -3138,14 +3132,13 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) { // An Entra ID (Azure AD) user, whose hive SID uses the S-1-12-1 prefix entraUser := windowsCertRow(map[string]string{ - "common_name": "entra@example.com", - "subject2": "CN=entra@example.com, O=Example", - "issuer2": "CN=SCEP CA, C=US", - "sha1": entraSHA1, - "username": "AzureAD\\entrauser", - "sid": entraSID, - "store_location": "Users", - "path": "Users\\" + entraSID + "\\Personal", + "common_name": "entra@example.com", + "subject2": "CN=entra@example.com, O=Example", + "issuer2": "CN=SCEP CA, C=US", + "sha1": entraSHA1, + "username": "AzureAD\\entrauser", + "sid": entraSID, + "path": "Users\\" + entraSID + "\\Personal", }) rows := []map[string]string{ @@ -3251,14 +3244,13 @@ func TestDirectIngestHostCertificatesWindowsMalformedDN(t *testing.T) { // subject2 contains a non-empty fragment with no '=' (malformed osquery output). // The certificate must still be ingested best-effort, not dropped. row := windowsCertRow(map[string]string{ - "common_name": "malformed.example.com", - "subject2": "CN=malformed.example.com, garbage-no-equals, C=US", - "issuer2": "CN=Issuer CA, C=US", - "sha1": "1234123412341234123412341234123412341234", - "username": "", - "sid": "", - "store_location": "LocalMachine", - "path": "LocalMachine\\Personal", + "common_name": "malformed.example.com", + "subject2": "CN=malformed.example.com, garbage-no-equals, C=US", + "issuer2": "CN=Issuer CA, C=US", + "sha1": "1234123412341234123412341234123412341234", + "username": "", + "sid": "", + "path": "LocalMachine\\Personal", }) var got []*fleet.HostCertificateRecord From 222c855a839e12d46a8c234dd49d67790071c41f Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:13:46 +0000 Subject: [PATCH 14/17] Merge with main and bump migration --- ....go => 20260703090248_ReparseWindowsHostCertificates.go} | 6 +++--- ...> 20260703090248_ReparseWindowsHostCertificates_test.go} | 2 +- server/datastore/mysql/schema.sql | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename server/datastore/mysql/migrations/tables/{20260630100331_ReparseWindowsHostCertificates.go => 20260703090248_ReparseWindowsHostCertificates.go} (93%) rename server/datastore/mysql/migrations/tables/{20260630100331_ReparseWindowsHostCertificates_test.go => 20260703090248_ReparseWindowsHostCertificates_test.go} (98%) diff --git a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go b/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates.go similarity index 93% rename from server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go rename to server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates.go index 2c5102c060f..bab8bdfcc94 100644 --- a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates.go +++ b/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates.go @@ -9,12 +9,12 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260630100331, Down_20260630100331) + MigrationClient.AddMigration(Up_20260703090248, Down_20260703090248) } // Up_20260630100331 soft-deletes existing osquery-origin Windows host certificate rows so they are re-ingested with // their distinguished name (subject/issuer) parsed from osquery's keyed subject2/issuer2 columns. -func Up_20260630100331(tx *sql.Tx) error { +func Up_20260703090248(tx *sql.Tx) error { step := incrementalMigrationStep(countWindowsHostCertsToReparse, softDeleteWindowsHostCertsForReparse) if err := step(tx); err != nil { return fmt.Errorf("soft-deleting windows host certificates for re-parse: %w", err) @@ -69,6 +69,6 @@ func softDeleteWindowsHostCertsForReparse(tx *sql.Tx, increment incrementCountFn } } -func Down_20260630100331(tx *sql.Tx) error { +func Down_20260703090248(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go b/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates_test.go similarity index 98% rename from server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go rename to server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates_test.go index d8b0df456cb..f72eff9057b 100644 --- a/server/datastore/mysql/migrations/tables/20260630100331_ReparseWindowsHostCertificates_test.go +++ b/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestUp_20260630100331(t *testing.T) { +func TestUp_20260703090248(t *testing.T) { db := applyUpToPrev(t) insertHost := func(platform, uuid string) uint { diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 68fd80aa309..819d6b4a1a4 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -2089,9 +2089,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=560 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=561 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260703090248,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( From b89ac5a5bbbe6794aec095f83975bc4c5bc4f192 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:41:02 +0000 Subject: [PATCH 15/17] Fixes --- cmd/osquery-perf/certificates.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/cmd/osquery-perf/certificates.go b/cmd/osquery-perf/certificates.go index 8a60fb5bcee..7b95272da1e 100644 --- a/cmd/osquery-perf/certificates.go +++ b/cmd/osquery-perf/certificates.go @@ -265,20 +265,29 @@ func darwinDN(country, org, orgUnit, commonName string) string { func windowsDN(country, org, orgUnit, commonName string) string { var parts []string if commonName != "" { - parts = append(parts, "CN="+commonName) + parts = append(parts, "CN="+quoteX500Value(commonName)) } if org != "" { - parts = append(parts, "O="+org) + parts = append(parts, "O="+quoteX500Value(org)) } if orgUnit != "" { - parts = append(parts, "OU="+orgUnit) + parts = append(parts, "OU="+quoteX500Value(orgUnit)) } if country != "" { - parts = append(parts, "C="+country) + parts = append(parts, "C="+quoteX500Value(country)) } return strings.Join(parts, ", ") } +// quoteX500Value double-quotes an attribute value that contains a comma (doubling any embedded quotes), as +// CERT_X500_NAME_STR does, e.g. O="Entrust, Inc.". +func quoteX500Value(v string) string { + if !strings.ContainsAny(v, `,"`) { + return v + } + return `"` + strings.ReplaceAll(v, `"`, `""`) + `"` +} + // windowsLegacyDN renders the simple, values-only distinguished name that // pre-5.23.1 osquery returned in subject/issuer. Kept so the generator also // works against older Fleet servers that read those columns. From 00f64e854ab820a4f3c9df07186e9e7b0a2b1628 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:54:40 +0000 Subject: [PATCH 16/17] Restore non-osquery-perf files to main to decouple from feature PR #48469 --- changes/31294-windows-host-certificates | 2 - .../understanding-host-vitals.md | 9 +- .../HostDetailsPage/HostDetailsPage.tsx | 3 +- .../cards/Certificates/Certificates.tsx | 14 +- .../CertificatesTable.tests.tsx | 111 ------- .../CertificatesTable/CertificatesTable.tsx | 14 +- .../CertificatesTableConfig.tsx | 2 +- server/datastore/mysql/apple_mdm_test.go | 4 +- server/datastore/mysql/host_certificates.go | 74 +---- .../datastore/mysql/host_certificates_test.go | 277 ++-------------- server/datastore/mysql/hosts_test.go | 2 +- ...03090248_ReparseWindowsHostCertificates.go | 74 ----- ...248_ReparseWindowsHostCertificates_test.go | 68 ---- server/datastore/mysql/schema.sql | 4 +- server/fleet/datastore.go | 10 +- server/fleet/host_certificates.go | 143 +++----- server/fleet/host_certificates_test.go | 115 ------- server/mock/datastore_mock.go | 6 +- server/service/apple_mdm.go | 2 +- server/service/integration_core_test.go | 2 +- server/service/osquery_test.go | 1 - server/service/osquery_utils/queries.go | 89 ++--- server/service/osquery_utils/queries_test.go | 306 +++++++----------- 23 files changed, 245 insertions(+), 1087 deletions(-) delete mode 100644 changes/31294-windows-host-certificates delete mode 100644 frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tests.tsx delete mode 100644 server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates.go delete mode 100644 server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates_test.go diff --git a/changes/31294-windows-host-certificates b/changes/31294-windows-host-certificates deleted file mode 100644 index f4acefcc733..00000000000 --- a/changes/31294-windows-host-certificates +++ /dev/null @@ -1,2 +0,0 @@ -- Added the certificates list to the host details page for Windows hosts, showing each certificate's scope (System or - User). This requires osquery 5.23.1 or higher on the host. diff --git a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md index c19001718e1..3396b7ad62f 100644 --- a/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md +++ b/docs/Contributing/product-groups/orchestration/understanding-host-vitals.md @@ -50,18 +50,13 @@ SELECT - Platforms: windows -- Discovery query: -```sql -SELECT 1 FROM pragma_table_info('certificates') WHERE name = 'subject2' -``` - - Query: ```sql SELECT - ca, common_name, subject2, issuer2, + ca, common_name, subject, issuer, key_algorithm, key_strength, key_usage, signing_algorithm, not_valid_after, not_valid_before, - serial, sha1, username, sid, + serial, sha1, username, path FROM certificates diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 8931264c3e6..a6e9e290e7f 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -1315,8 +1315,7 @@ const HostDetailsPage = ({ const showAgentOptionsCard = !isIosOrIpadosHost && !isAndroidHost; const showLocalUserAccountsCard = !isIosOrIpadosHost && !isAndroidHost; const showCertificatesCard = - (isAppleDeviceHost || isWindowsHost) && - (isErrorHostCertificates || !!hostCertificates?.certificates.length); + isAppleDeviceHost && !!hostCertificates?.certificates.length; const renderSoftwareCard = () => { return ( diff --git a/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx b/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx index 174ff2085b5..be6156069ed 100644 --- a/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/Certificates.tsx @@ -17,9 +17,7 @@ import CertificatesTable from "./CertificatesTable"; const baseClass = "certificates-card"; interface ICertificatesProps { - // data may be undefined while the fetch is in flight or has errored; in the - // error case the card renders DataError below without reading it. - data?: IGetHostCertificatesResponse; + data: IGetHostCertificatesResponse; hostPlatform: HostPlatform; page: number; pageSize: number; @@ -58,18 +56,10 @@ const CertificatesCard = ({ ); } - if (!data) { - return null; - } - return ( - createCustomRenderer()( - - ); - -describe("CertificatesTable", () => { - it("renders the platform-agnostic 'Scope' column header (replacing 'Keychain')", () => { - renderTable(); - - expect(screen.getByText("Scope")).toBeInTheDocument(); - expect(screen.queryByText("Keychain")).not.toBeInTheDocument(); - }); - - it("shows macOS keychain help text on a darwin host", () => { - renderTable({ hostPlatform: "darwin", showHelpText: true }); - - expect(screen.getByText(/login \(user\) keychain/i)).toBeInTheDocument(); - }); - - it("shows Personal certificate store help text on a windows host", () => { - renderTable({ hostPlatform: "windows", showHelpText: true }); - - expect(screen.getByText(/Personal certificate store/i)).toBeInTheDocument(); - }); - - it("renders a user-scoped certificate as 'User' with its owning username", async () => { - const { user } = renderTable({ - data: createMockGetHostCertificatesResponse({ - certificates: [ - createMockHostCertificate({ source: "user", username: "alice" }), - ], - count: 1, - }), - hostPlatform: "windows", - }); - - expect(screen.getByText("User")).toBeInTheDocument(); - // the owning username is surfaced in the scope cell's tooltip on hover - await user.hover(screen.getByText("User")); - expect(await screen.findByText("alice")).toBeInTheDocument(); - }); - - it("renders a certificate present in two scopes as two distinct rows (shared id must not collapse)", () => { - // Same certificate (same id) installed in both the System store and a user's - // store comes back as two rows sharing host_certificates.id. They must each - // render rather than collapsing on the shared id. - renderTable({ - data: createMockGetHostCertificatesResponse({ - certificates: [ - createMockHostCertificate({ - id: 1, - common_name: "shared.example.com", - source: "system", - username: "", - }), - createMockHostCertificate({ - id: 1, - common_name: "shared.example.com", - source: "user", - username: "alice", - }), - ], - count: 2, - }), - hostPlatform: "windows", - }); - - // Both scope cells render — without a per-scope row id the two same-id rows - // collapse and only one scope would be shown. - expect(screen.getByText("System")).toBeInTheDocument(); - expect(screen.getByText("User")).toBeInTheDocument(); - }); -}); diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx index d5cda6c88f2..559470f714a 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTable.tsx @@ -4,7 +4,6 @@ import { Row } from "react-table"; import { IHostCertificate } from "interfaces/certificates"; import { IGetHostCertificatesResponse } from "services/entities/hosts"; import { IListSort } from "interfaces/list_options"; -import { HostPlatform } from "interfaces/platform"; import TableContainer from "components/TableContainer"; import CustomLink from "components/CustomLink"; @@ -17,7 +16,6 @@ const baseClass = "certificates-table"; interface ICertificatesTableProps { data: IGetHostCertificatesResponse; - hostPlatform: HostPlatform; showHelpText: boolean; page: number; pageSize: number; @@ -31,7 +29,6 @@ interface ICertificatesTableProps { const CertificatesTable = ({ data, - hostPlatform, showHelpText, page, pageSize, @@ -70,9 +67,8 @@ const CertificatesTable = ({ const helpText = showHelpText ? (

- {hostPlatform === "windows" - ? "Showing certificates in the Personal certificate store. To get all certificates, you can query the certificates table. " - : "Showing certificates in the system and login (user) keychain. To get all certificates, you can query the certificates table. "} + Showing certificates in the system and login (user) keychain. To get all + certificates, you can query the certificates table.{" "} - `${row.id}-${row.source}-${row.username}` - } emptyComponent={() => null} isAllPagesSelected={false} showMarkAllPages={false} diff --git a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTableConfig.tsx b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTableConfig.tsx index d2172f62cbc..c597b06fd63 100644 --- a/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Certificates/CertificatesTable/CertificatesTableConfig.tsx @@ -43,7 +43,7 @@ const generateTableConfig = (): IHostCertificatesTableConfig[] => { { accessor: "source", disableSortBy: true, - Header: "Scope", + Header: "Keychain", Cell: (cellProps) => { if (cellProps.cell.value === "system") { return ; diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index 711eeffb578..cf8650094a7 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -12859,9 +12859,9 @@ func testMDMTurnOffSoftDeletesMDMCertificates(t *testing.T, ds *Datastore) { require.NoError(t, ds.SetOrUpdateMDMData(ctx, host.ID, false, true, "https://mdm.example.com", false, "Fleet", "", false)) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{mkCert(host.ID, "osquery.example.com")}, fleet.HostCertificateOriginOsquery, nil)) + []*fleet.HostCertificateRecord{mkCert(host.ID, "osquery.example.com")}, fleet.HostCertificateOriginOsquery)) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{mkCert(host.ID, "mdm-acme.example.com")}, fleet.HostCertificateOriginMDM, nil)) + []*fleet.HostCertificateRecord{mkCert(host.ID, "mdm-acme.example.com")}, fleet.HostCertificateOriginMDM)) certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{OrderKey: "common_name"}) require.NoError(t, err) diff --git a/server/datastore/mysql/host_certificates.go b/server/datastore/mysql/host_certificates.go index 9bb4638aee2..f8b7144255f 100644 --- a/server/datastore/mysql/host_certificates.go +++ b/server/datastore/mysql/host_certificates.go @@ -41,50 +41,12 @@ func (ds *Datastore) ListHostCertificates(ctx context.Context, hostID uint, opts return listHostCertsDB(ctx, ds.reader(ctx), hostID, opts) } -func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { +func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { type certSourceToSet struct { Source fleet.HostCertificateSource Username string } - // observedScopes restricts which (source, username) scopes reconciliation may soft-delete. A nil slice means every - // scope was observed this run (the macOS keychain model, where all keychains are always readable, and the MDM path). A non-nil slice (the Windows path) preserves certificates whose scope - // is not listed, because osquery can only enumerate a user's certificates while that user is logged in. - var observedSet map[certSourceToSet]struct{} - if observedScopes != nil { - observedSet = make(map[certSourceToSet]struct{}, len(observedScopes)) - for _, s := range observedScopes { - observedSet[certSourceToSet{Source: s.Source, Username: s.Username}] = struct{}{} - } - } - isObserved := func(s certSourceToSet) bool { - if observedScopes == nil { - return true - } - _, ok := observedSet[s] - return ok - } - // desiredSources returns the source set to persist for a certificate: every source reported in the incoming batch, - // plus any existing source whose scope was NOT observed this run. For the macOS/MDM path (nil observedScopes) every - // scope is observed. - desiredSources := func(incoming, existing []certSourceToSet) []certSourceToSet { - result := append([]certSourceToSet(nil), incoming...) - have := make(map[certSourceToSet]struct{}, len(incoming)) - for _, s := range incoming { - have[s] = struct{}{} - } - for _, s := range existing { - if isObserved(s) { - continue - } - if _, ok := have[s]; ok { - continue - } - result = append(result, s) - } - return result - } - incomingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(certs)) incomingSourcesBySHA1 := make(map[string][]certSourceToSet, len(certs)) for _, cert := range certs { @@ -156,14 +118,11 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho } return strings.Compare(a.Username, b.Username) } - // Persist the reported sources plus any existing source in a scope we did not observe (preserving a logged-off - // user's source). For the macOS/MDM path this is exactly incomingSources. - newSources := desiredSources(incomingSources, existingSources) - slices.SortFunc(newSources, sliceSortFunc) + slices.SortFunc(incomingSources, sliceSortFunc) slices.SortFunc(existingSources, sliceSortFunc) - if !slices.Equal(newSources, existingSources) { - toSetSourcesBySHA1[sha1] = newSources + if !slices.Equal(incomingSources, existingSources) { + toSetSourcesBySHA1[sha1] = incomingSources } existing, hasExisting := existingBySHA1[sha1] @@ -347,23 +306,14 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho toDelete := make([]uint, 0, len(existingBySHA1)) for sha1, existing := range existingBySHA1 { - if _, ok := incomingBySHA1[sha1]; ok { - // present in the incoming batch, reconciled above - continue - } - // Source-scoped delete: only remove rows whose origin matches the calling ingestion source. - if existing.Origin != origin { - continue - } - // Preserve sources in scopes we did not observe this run (e.g. a logged-off Windows user) and drop the - // observed-but-no-longer-reported ones. - existingSources := existingSourcesBySHA1[sha1] - preserved := desiredSources(nil, existingSources) - switch { - case len(preserved) == 0: + if _, ok := incomingBySHA1[sha1]; !ok { + // Source-scoped delete: only remove rows whose origin matches the + // calling ingestion source. An osquery sync omitting an MDM-only cert + // must not delete that cert, and vice versa. + if existing.Origin != origin { + continue + } toDelete = append(toDelete, existing.ID) - case len(preserved) != len(existingSources): - toSetSourcesBySHA1[sha1] = preserved } } @@ -475,7 +425,7 @@ func loadHostCertIDsForSHA1DB(ctx context.Context, tx sqlx.QueryerContext, hostI FROM host_certificates hc WHERE - hc.sha1_sum IN (?) AND hc.host_id = ? AND hc.deleted_at IS NULL` + hc.sha1_sum IN (?) AND hc.host_id = ?` var certs []*fleet.HostCertificateRecord stmt, args, err := sqlx.In(stmt, binarySHA1s, hostID) diff --git a/server/datastore/mysql/host_certificates_test.go b/server/datastore/mysql/host_certificates_test.go index 971f9f27713..109d28d8044 100644 --- a/server/datastore/mysql/host_certificates_test.go +++ b/server/datastore/mysql/host_certificates_test.go @@ -7,7 +7,6 @@ import ( "crypto/sha1" // nolint:gosec // test-only unique sha1 generator "crypto/x509" "crypto/x509/pkix" - "encoding/hex" "encoding/pem" "fmt" "math/big" @@ -18,7 +17,6 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" - "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -35,8 +33,6 @@ func TestHostCertificates(t *testing.T) { {"Insert host_mdm_managed_certificates from non-proxied ingestion", testInsertingHostMDMManagedCertificatesFromIngestion}, {"Matcher recovers stuck hmmc rows", testMatcherRecoversStuckHMMCRows}, {"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation}, - {"Windows scope-aware reconciliation", testUpdateHostCertificatesWindowsScopeReconciliation}, - {"Re-ingest after soft delete attaches sources to live row", testUpdateHostCertificatesReingestAfterSoftDelete}, {"Origin-scoped delete", testUpdateHostCertificatesOriginScopedDelete}, {"Origin downgrade on osquery rediscovery", testUpdateHostCertificatesOriginDowngrade}, {"Create certificates with long country code", testHostCertificateWithInvalidCountryCode}, @@ -87,7 +83,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { generateTestHostCertificateRecord(t, 1, &expected2), } - require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery)) // verify that we saved the records correctly certs, meta, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", IncludeMetadata: true}) @@ -111,7 +107,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { require.Equal(t, expected2.Subject.CommonName, certs[1].SubjectCommonName) // simulate removal of a certificate - require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1]}, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1]}, fleet.HostCertificateOriginOsquery)) certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"}) require.NoError(t, err) require.Len(t, certs, 1) @@ -121,7 +117,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { // re-add first certificate but as a "user" source payload[0].Source = fleet.UserHostCertificate payload[0].Username = "A" - require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[0], payload[1]}, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[0], payload[1]}, fleet.HostCertificateOriginOsquery)) certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"}) require.NoError(t, err) require.Len(t, certs, 2) @@ -165,7 +161,7 @@ func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) { for _, c := range cases { t.Log(c.desc) - err := ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", c.ingest, fleet.HostCertificateOriginOsquery, nil) + err := ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", c.ingest, fleet.HostCertificateOriginOsquery) require.NoError(t, err) certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", TestSecondaryOrderKey: "username"}) require.NoError(t, err) @@ -311,7 +307,7 @@ func testUpdatingHostMDMManagedCertificates(t *testing.T, ds *Datastore) { generateTestHostCertificateRecord(t, host.ID, &expected3), } - require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery)) // verify that we saved the records correctly certs, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{OrderKey: "common_name"}) @@ -358,7 +354,7 @@ func testUpdatingHostMDMManagedCertificates(t *testing.T, ds *Datastore) { assert.Equal(t, "step-ca", profile2.CAName) // simulate removal of a certificate - require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1], payload[2]}, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1], payload[2]}, fleet.HostCertificateOriginOsquery)) certs3, _, err := ds.ListHostCertificates(context.Background(), host.ID, fleet.ListOptions{OrderKey: "common_name"}) require.NoError(t, err) require.Len(t, certs3, 2) @@ -501,7 +497,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) { for _, c := range certs { payload = append(payload, generateTestHostCertificateRecord(t, host.ID, c)) } - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery)) return payload } @@ -512,7 +508,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) { payload = append(payload, existingRecs...) unrelated := unrelatedCertTemplate(fmt.Sprintf("unrelated-%d", unrelatedSerial), 24*time.Hour, unrelatedSerial) payload = append(payload, generateTestHostCertificateRecord(t, host.ID, unrelated)) - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery)) } t.Run("MissedIngestRecovered", func(t *testing.T) { @@ -625,7 +621,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) { backdateHMMC(t, profileUUID, 5*time.Hour) // Re-pass the same records — toInsert will be empty, but recovery still runs. - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, recs, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, recs, fleet.HostCertificateOriginOsquery)) got := getApple(t, profileUUID, "ca-stable") require.NotNil(t, got.NotValidAfter) @@ -648,7 +644,7 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) { olderCert := renewalCertTemplate(profileUUID, "-old", time.Now().Add(-48*time.Hour).Truncate(time.Second).UTC(), time.Now().Add(48*time.Hour).Truncate(time.Second).UTC(), 4501) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{ generateTestHostCertificateRecord(t, host.ID, olderCert), - }, fleet.HostCertificateOriginOsquery, nil)) + }, fleet.HostCertificateOriginOsquery)) got := getApple(t, profileUUID, "ca-mono") require.NotNil(t, got.NotValidAfter) @@ -811,7 +807,7 @@ func testInsertingHostMDMManagedCertificatesFromIngestion(t *testing.T, ds *Data generateTestHostCertificateRecord(t, host.ID, &certUnrelated), } - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery)) // nonProxiedProfileUUID — row was inserted with NULL Type, matching cert's metadata. all, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID) @@ -856,7 +852,7 @@ func testInsertingHostMDMManagedCertificatesFromIngestion(t *testing.T, ds *Data generateTestHostCertificateRecord(t, host.ID, &certProxied), generateTestHostCertificateRecord(t, host.ID, &certUnrelated), } - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, renewedPayload, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, renewedPayload, fleet.HostCertificateOriginOsquery)) all2, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID) require.NoError(t, err) @@ -874,101 +870,6 @@ func testInsertingHostMDMManagedCertificatesFromIngestion(t *testing.T, ds *Data assert.Equal(t, fleet.CAConfigAssetType(""), nonProxiedRow2.Type, "Type should still be NULL/empty after update") } -// testUpdateHostCertificatesReingestAfterSoftDelete covers the soft-delete-then-re-report cycle -func testUpdateHostCertificatesReingestAfterSoftDelete(t *testing.T, ds *Datastore) { - ctx := t.Context() - const ( - hostID = uint(77) - hostUUID = "windows-reingest-host-uuid" - ) - - cert := mkTestCertRecord(t, hostID, "reingest.example.com", fleet.UserHostCertificate, "alice") - sha1Hex := strings.ToUpper(hex.EncodeToString(cert.SHA1Sum)) - scopes := []fleet.HostCertificateScope{{Source: fleet.UserHostCertificate, Username: "alice"}} - - ingest := func() { - reported := *cert // fresh copy each report; UpdateHostCertificates mutates the record - require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{&reported}, fleet.HostCertificateOriginOsquery, scopes)) - } - softDeleteAll := func() { - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, `UPDATE host_certificates SET deleted_at = NOW(6) WHERE host_id = ?`, hostID) - return err - }) - } - - ingest() - // Soft-delete the row, then re-report - softDeleteAll() - ingest() - - // Also clone the live row as a soft-deleted duplicate with a HIGHER id. - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, ` - INSERT INTO host_certificates - (host_id, sha1_sum, not_valid_after, not_valid_before, 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, origin, deleted_at) - SELECT host_id, sha1_sum, not_valid_after, not_valid_before, 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, origin, NOW(6) - FROM host_certificates WHERE host_id = ? AND deleted_at IS NULL`, hostID) - return err - }) - - var rowStates []struct { - ID uint `db:"id"` - Deleted bool `db:"deleted"` - } - ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - return sqlx.SelectContext(ctx, q, &rowStates, - `SELECT id, deleted_at IS NOT NULL AS deleted FROM host_certificates WHERE host_id = ? ORDER BY id`, hostID) - }) - require.Len(t, rowStates, 3) - require.True(t, rowStates[0].Deleted, "expected the original row to be the soft-deleted one") - require.False(t, rowStates[1].Deleted, "expected the re-ingested row to be live") - require.True(t, rowStates[2].Deleted, "expected the cloned row to be soft-deleted") - liveID := rowStates[1].ID - - // The sha1 -> id lookup used to attach sources must resolve to the live row only. - got, err := loadHostCertIDsForSHA1DB(ctx, ds.reader(ctx), hostID, []string{sha1Hex}) - require.NoError(t, err) - require.Equal(t, map[string]uint{sha1Hex: liveID}, got) - - // End to end: the certificate lists with its user source intact. - listed, _, err := ds.ListHostCertificates(ctx, hostID, fleet.ListOptions{}) - require.NoError(t, err) - require.Len(t, listed, 1) - require.Equal(t, fleet.UserHostCertificate, listed[0].Source) - require.Equal(t, "alice", listed[0].Username) - - // With every row soft-deleted, the lookup returns nothing. - softDeleteAll() - got, err = loadHostCertIDsForSHA1DB(ctx, ds.reader(ctx), hostID, []string{sha1Hex}) - require.NoError(t, err) - require.Empty(t, got) -} - -// mkTestCertRecord builds a HostCertificateRecord for hostID with a random serial, valid from an hour ago to 24 hours -// from now, scoped to the given source and username. -func mkTestCertRecord(t *testing.T, hostID uint, commonName string, source fleet.HostCertificateSource, username string) *fleet.HostCertificateRecord { - tmpl := x509.Certificate{ - Subject: pkix.Name{CommonName: commonName, Organization: []string{"Org"}}, - Issuer: pkix.Name{CommonName: "issuer.example.com"}, - SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec - NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(), - NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(), - BasicConstraintsValid: true, - } - rec := generateTestHostCertificateRecord(t, hostID, &tmpl) - rec.Source = source - rec.Username = username - return rec -} - func generateTestHostCertificateRecord(t *testing.T, hostID uint, template *x509.Certificate) *fleet.HostCertificateRecord { b, _, err := GenerateTestCertBytes(template) require.NoError(t, err) @@ -1013,120 +914,6 @@ func generateTestHostCertificateRecordWithParent(t *testing.T, hostID uint, cert return fleet.NewHostCertificateRecord(hostID, parsed) } -// testUpdateHostCertificatesWindowsScopeReconciliation exercises the observed-scopes reconciliation used by the Windows -// ingestion path: osquery can only enumerate a user's certificates while that user is logged in, so a logged-off user's -// certificates must be preserved rather than soft-deleted. -func testUpdateHostCertificatesWindowsScopeReconciliation(t *testing.T, ds *Datastore) { - ctx := t.Context() - const ( - hostID = uint(42) - hostUUID = "windows-scope-host-uuid" - ) - - mkCert := func(commonName string, source fleet.HostCertificateSource, username string) *fleet.HostCertificateRecord { - return mkTestCertRecord(t, hostID, commonName, source, username) - } - - listKeys := func() []string { - certs, _, err := ds.ListHostCertificates(ctx, hostID, fleet.ListOptions{OrderKey: "common_name"}) - require.NoError(t, err) - keys := make([]string, 0, len(certs)) - for _, c := range certs { - keys = append(keys, fmt.Sprintf("%s|%s|%s", c.CommonName, c.Source, c.Username)) - } - return keys - } - - sysScope := fleet.HostCertificateScope{Source: fleet.SystemHostCertificate} - aliceScope := fleet.HostCertificateScope{Source: fleet.UserHostCertificate, Username: "alice"} - bobScope := fleet.HostCertificateScope{Source: fleet.UserHostCertificate, Username: "bob"} - - certSys := mkCert("sys.example.com", fleet.SystemHostCertificate, "") - certAlice := mkCert("alice-old.example.com", fleet.UserHostCertificate, "alice") - certBob := mkCert("bob.example.com", fleet.UserHostCertificate, "bob") - // shared cert: present in the System store and in alice's store (same SHA1, two sources). - sharedSys := mkCert("shared.example.com", fleet.SystemHostCertificate, "") - sharedAliceClone := *sharedSys - sharedAlice := &sharedAliceClone - sharedAlice.Source = fleet.UserHostCertificate - sharedAlice.Username = "alice" - - // 1. Initial report: alice and bob both logged in. - require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{certSys, certAlice, certBob, sharedSys, sharedAlice}, - fleet.HostCertificateOriginOsquery, - []fleet.HostCertificateScope{sysScope, aliceScope, bobScope})) - require.ElementsMatch(t, []string{ - "sys.example.com|system|", - "alice-old.example.com|user|alice", - "bob.example.com|user|bob", - "shared.example.com|system|", - "shared.example.com|user|alice", - }, listKeys()) - - // 2. Alice logs off: her hive is not loaded so her certs are simply absent. - require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{certSys, certBob, sharedSys}, - fleet.HostCertificateOriginOsquery, - []fleet.HostCertificateScope{sysScope, bobScope})) - require.ElementsMatch(t, []string{ - "sys.example.com|system|", - "alice-old.example.com|user|alice", // preserved (alice not observed) - "bob.example.com|user|bob", - "shared.example.com|system|", - "shared.example.com|user|alice", // preserved - }, listKeys()) - - // 3. Alice logs back in but has removed her old cert and her copy of the shared cert, and installed a new one. - certAlice2 := mkCert("alice-new.example.com", fleet.UserHostCertificate, "alice") - require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{certSys, certBob, sharedSys, certAlice2}, - fleet.HostCertificateOriginOsquery, - []fleet.HostCertificateScope{sysScope, aliceScope, bobScope})) - require.ElementsMatch(t, []string{ - "sys.example.com|system|", - "bob.example.com|user|bob", - "shared.example.com|system|", // alice's shared source dropped, system kept - "alice-new.example.com|user|alice", - }, listKeys()) - - // 4. A System certificate removed while present in the report → deleted, since System scope is always observed. - require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{certBob, sharedSys, certAlice2}, - fleet.HostCertificateOriginOsquery, - []fleet.HostCertificateScope{sysScope, aliceScope, bobScope})) - require.ElementsMatch(t, []string{ - "bob.example.com|user|bob", - "shared.example.com|system|", - "alice-new.example.com|user|alice", - }, listKeys()) - - // 5. Alice logs back in and re-installs the shared cert, so it is again in both the System store and her store. - require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{certBob, sharedSys, sharedAlice, certAlice2}, - fleet.HostCertificateOriginOsquery, - []fleet.HostCertificateScope{sysScope, aliceScope, bobScope})) - require.ElementsMatch(t, []string{ - "bob.example.com|user|bob", - "shared.example.com|system|", - "shared.example.com|user|alice", - "alice-new.example.com|user|alice", - }, listKeys()) - - // 6. The shared cert is removed from the machine store while alice is logged off, so it is absent from the report - // entirely. Its (observed) System source is dropped, but her (unobserved) source keeps the cert alive (it survives - // showing only her scope). alice's other cert is likewise preserved. - require.NoError(t, ds.UpdateHostCertificates(ctx, hostID, hostUUID, - []*fleet.HostCertificateRecord{certBob}, - fleet.HostCertificateOriginOsquery, - []fleet.HostCertificateScope{sysScope, bobScope})) - require.ElementsMatch(t, []string{ - "bob.example.com|user|bob", - "shared.example.com|user|alice", // System source dropped, alice's preserved - "alice-new.example.com|user|alice", - }, listKeys()) -} - func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { // regression test for #30574 ctx := context.Background() @@ -1198,8 +985,8 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { host2Cert.Username = "jsmith" // Add the same certificate to both hosts - require.NoError(t, ds.UpdateHostCertificates(ctx, host1.ID, host1.UUID, []*fleet.HostCertificateRecord{host1Cert}, fleet.HostCertificateOriginOsquery, nil)) - require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2Cert}, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host1.ID, host1.UUID, []*fleet.HostCertificateRecord{host1Cert}, fleet.HostCertificateOriginOsquery)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2Cert}, fleet.HostCertificateOriginOsquery)) // Verify both hosts have the correct certs, with the correct sources host1Certs, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{}) @@ -1220,7 +1007,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { host2CertUpdated.Source = fleet.UserHostCertificate host2CertUpdated.Username = "janesmith" - require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery)) // Verify host1's certificate source was *not* updated host1CertsAfter, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{}) @@ -1235,7 +1022,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { require.Equal(t, "janesmith", host2CertsAfter[0].Username) // Verify no-op case - err = ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery, nil) + err = ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery) require.NoError(t, err) // Verify host2's certificate source was updated @@ -1249,7 +1036,7 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) { systemCertOnHost2 := fleet.NewHostCertificateRecord(host2.ID, parsed) systemCertOnHost2.Source = fleet.SystemHostCertificate - require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated, systemCertOnHost2}, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated, systemCertOnHost2}, fleet.HostCertificateOriginOsquery)) // Verify host2 now has the certificate with both sources host2CertsMultiSource, _, err := ds.ListHostCertificates(ctx, host2.ID, fleet.ListOptions{}) @@ -1321,9 +1108,9 @@ func testUpdateHostCertificatesOriginScopedDelete(t *testing.T, ds *Datastore) { // Initial state: osquery reports osqueryOnly; MDM reports mdmOnly. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery, nil)) + []*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery)) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{mdmOnly}, fleet.HostCertificateOriginMDM, nil)) + []*fleet.HostCertificateRecord{mdmOnly}, fleet.HostCertificateOriginMDM)) certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -1344,7 +1131,7 @@ func testUpdateHostCertificatesOriginScopedDelete(t *testing.T, ds *Datastore) { // Osquery sync runs again with an EMPTY cert list. The osquery-only cert // should be soft-deleted, but the mdm-only cert must survive. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginOsquery, nil)) + []*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginOsquery)) certs, _, err = ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -1355,9 +1142,9 @@ func testUpdateHostCertificatesOriginScopedDelete(t *testing.T, ds *Datastore) { // Now the symmetric case: osquery re-reports its cert, MDM sync runs with an // empty list. The mdm-only cert should be soft-deleted, osquery-only survives. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery, nil)) + []*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery)) require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginMDM, nil)) + []*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginMDM)) certs, _, err = ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{}) require.NoError(t, err) @@ -1410,7 +1197,7 @@ func testUpdateHostCertificatesOriginDowngrade(t *testing.T, ds *Datastore) { // MDM ingestion sees both certs first. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{rootCA, mdmDelivered}, fleet.HostCertificateOriginMDM, nil)) + []*fleet.HostCertificateRecord{rootCA, mdmDelivered}, fleet.HostCertificateOriginMDM)) originByCN := func() map[string]fleet.HostCertificateOrigin { certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{}) @@ -1428,7 +1215,7 @@ func testUpdateHostCertificatesOriginDowngrade(t *testing.T, ds *Datastore) { // Osquery rediscovers the Root CA; row downgrades. MDM-only cert unchanged. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{rootCA}, fleet.HostCertificateOriginOsquery, nil)) + []*fleet.HostCertificateRecord{rootCA}, fleet.HostCertificateOriginOsquery)) require.Equal(t, map[string]fleet.HostCertificateOrigin{ "user-installed-root-ca": fleet.HostCertificateOriginOsquery, "mdm-delivered-only": fleet.HostCertificateOriginMDM, @@ -1436,7 +1223,7 @@ func testUpdateHostCertificatesOriginDowngrade(t *testing.T, ds *Datastore) { // Downgrade is sticky across repeated osquery syncs. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{rootCA}, fleet.HostCertificateOriginOsquery, nil)) + []*fleet.HostCertificateRecord{rootCA}, fleet.HostCertificateOriginOsquery)) require.Equal(t, fleet.HostCertificateOriginOsquery, originByCN()["user-installed-root-ca"]) // Source-scoped delete preserved: osquery omitting the MDM-only cert does not soft-delete it. @@ -1446,7 +1233,7 @@ func testUpdateHostCertificatesOriginDowngrade(t *testing.T, ds *Datastore) { // One-way: MDM rediscovery does not re-upgrade the downgraded row. require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, - []*fleet.HostCertificateRecord{rootCA, mdmDelivered}, fleet.HostCertificateOriginMDM, nil)) + []*fleet.HostCertificateRecord{rootCA, mdmDelivered}, fleet.HostCertificateOriginMDM)) require.Equal(t, map[string]fleet.HostCertificateOrigin{ "user-installed-root-ca": fleet.HostCertificateOriginOsquery, "mdm-delivered-only": fleet.HostCertificateOriginMDM, @@ -1534,7 +1321,7 @@ func testHostCertificateWithInvalidCountryCode(t *testing.T, ds *Datastore) { payload[1].SubjectCountry = certWithNormalCountryTemplate.Subject.Country[0] payload[1].IssuerCountry = parentWithLongIssuerCountryTemplate.Subject.Country[0] - require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload, fleet.HostCertificateOriginOsquery)) // verify that we saved the records correctly certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"}) @@ -1643,7 +1430,7 @@ func testTruncateLongCertificateFields(t *testing.T, ds *Datastore) { require.NoError(t, err) // Update certificates - this should trigger truncation - err = ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{cert}, fleet.HostCertificateOriginOsquery, nil) + err = ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{cert}, fleet.HostCertificateOriginOsquery) require.NoError(t, err) // Retrieve the certificate and verify all fields were truncated @@ -1726,7 +1513,7 @@ func testListHostCertificatesCountMatches(t *testing.T, ds *Datastore) { certUser.Source = fleet.UserHostCertificate certUser.Username = "alice" - require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser}, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser}, fleet.HostCertificateOriginOsquery)) // Now list with metadata certs, meta, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{IncludeMetadata: true}) @@ -1783,15 +1570,15 @@ func testSoftDeleteMDMHostCertificatesForUnenrolledHosts(t *testing.T, ds *Datas // Unenrolled host with both an osquery-origin and an mdm-origin cert. unenrolled := newHost("unenrolled") require.NoError(t, ds.UpdateHostCertificates(ctx, unenrolled.ID, unenrolled.UUID, - []*fleet.HostCertificateRecord{mkCert(unenrolled.ID, "u-osquery")}, fleet.HostCertificateOriginOsquery, nil)) + []*fleet.HostCertificateRecord{mkCert(unenrolled.ID, "u-osquery")}, fleet.HostCertificateOriginOsquery)) require.NoError(t, ds.UpdateHostCertificates(ctx, unenrolled.ID, unenrolled.UUID, - []*fleet.HostCertificateRecord{mkCert(unenrolled.ID, "u-mdm")}, fleet.HostCertificateOriginMDM, nil)) + []*fleet.HostCertificateRecord{mkCert(unenrolled.ID, "u-mdm")}, fleet.HostCertificateOriginMDM)) require.NoError(t, ds.SetOrUpdateMDMData(ctx, unenrolled.ID, false, false, "https://mdm.example.com", false, "Fleet", "", false)) // Enrolled host with an mdm-origin cert that must NOT be swept. enrolled := newHost("enrolled") require.NoError(t, ds.UpdateHostCertificates(ctx, enrolled.ID, enrolled.UUID, - []*fleet.HostCertificateRecord{mkCert(enrolled.ID, "e-mdm")}, fleet.HostCertificateOriginMDM, nil)) + []*fleet.HostCertificateRecord{mkCert(enrolled.ID, "e-mdm")}, fleet.HostCertificateOriginMDM)) require.NoError(t, ds.SetOrUpdateMDMData(ctx, enrolled.ID, false, true, "https://mdm.example.com", false, "Fleet", "", false)) count, err := ds.SoftDeleteMDMHostCertificatesForUnenrolledHosts(ctx) diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 2db6e2844de..9f6d451902a 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -9548,7 +9548,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) { NotValidAfter: now.Add(365 * 24 * time.Hour), Source: fleet.SystemHostCertificate, Username: "test-user", - }}, fleet.HostCertificateOriginOsquery, nil)) + }}, fleet.HostCertificateOriginOsquery)) // create an android device from this host deviceID := strings.ReplaceAll(uuid.NewString(), "-", "") diff --git a/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates.go b/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates.go deleted file mode 100644 index bab8bdfcc94..00000000000 --- a/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates.go +++ /dev/null @@ -1,74 +0,0 @@ -package tables - -import ( - "database/sql" - "fmt" - - "github.com/jmoiron/sqlx" - "github.com/jmoiron/sqlx/reflectx" -) - -func init() { - MigrationClient.AddMigration(Up_20260703090248, Down_20260703090248) -} - -// Up_20260630100331 soft-deletes existing osquery-origin Windows host certificate rows so they are re-ingested with -// their distinguished name (subject/issuer) parsed from osquery's keyed subject2/issuer2 columns. -func Up_20260703090248(tx *sql.Tx) error { - step := incrementalMigrationStep(countWindowsHostCertsToReparse, softDeleteWindowsHostCertsForReparse) - if err := step(tx); err != nil { - return fmt.Errorf("soft-deleting windows host certificates for re-parse: %w", err) - } - return nil -} - -func countWindowsHostCertsToReparse(tx *sql.Tx) (uint64, error) { - var total uint64 - err := tx.QueryRow(` - SELECT COUNT(*) - FROM host_certificates hc - JOIN hosts h ON h.id = hc.host_id - WHERE h.platform = 'windows' AND hc.origin = 'osquery' AND hc.deleted_at IS NULL`).Scan(&total) - return total, err -} - -// softDeleteWindowsHostCertsForReparse walks the osquery-origin Windows host certificates in id-keyed batches and -// soft-deletes each one, calling increment per row so progress is reported. -func softDeleteWindowsHostCertsForReparse(tx *sql.Tx, increment incrementCountFn) error { - txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} - - const batchSize = 1000 - var lastID uint64 - for { - var ids []uint64 - if err := txx.Select(&ids, ` - SELECT hc.id - FROM host_certificates hc - JOIN hosts h ON h.id = hc.host_id - WHERE h.platform = 'windows' AND hc.origin = 'osquery' AND hc.deleted_at IS NULL AND hc.id > ? - ORDER BY hc.id - LIMIT ?`, lastID, batchSize); err != nil { - return fmt.Errorf("selecting windows host certs batch after id %d: %w", lastID, err) - } - if len(ids) == 0 { - return nil - } - - query, args, err := sqlx.In(`UPDATE host_certificates SET deleted_at = NOW(6) WHERE id IN (?)`, ids) - if err != nil { - return fmt.Errorf("building soft-delete query: %w", err) - } - if _, err := txx.Exec(query, args...); err != nil { - return fmt.Errorf("soft-deleting windows host certs batch after id %d: %w", lastID, err) - } - - for range ids { - increment() - } - lastID = ids[len(ids)-1] - } -} - -func Down_20260703090248(tx *sql.Tx) error { - return nil -} diff --git a/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates_test.go b/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates_test.go deleted file mode 100644 index f72eff9057b..00000000000 --- a/server/datastore/mysql/migrations/tables/20260703090248_ReparseWindowsHostCertificates_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package tables - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestUp_20260703090248(t *testing.T) { - db := applyUpToPrev(t) - - insertHost := func(platform, uuid string) uint { - execNoErr(t, db, `INSERT INTO hosts (osquery_host_id, node_key, hostname, uuid, platform) VALUES (?, ?, ?, ?, ?);`, - uuid, uuid, uuid, uuid, platform) - var id uint - require.NoError(t, db.Get(&id, `SELECT id FROM hosts WHERE uuid = ?`, uuid)) - return id - } - - // insertCert inserts a host_certificates row (deletedAt nil => live) and returns its id. - insertCert := func(hostID uint, serial, origin string, sha1 []byte, deletedAt any) uint { - execNoErr(t, db, ` - INSERT INTO host_certificates ( - host_id, not_valid_after, not_valid_before, 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, - sha1_sum, origin, deleted_at - ) VALUES (?, '2027-01-01', '2026-01-01', 0, 'cn', 'rsa', 2048, 'digitalSignature', - ?, 'sha256WithRSAEncryption', '', '', '', '', '', '', '', '', - ?, ?, ?)`, - hostID, serial, sha1, origin, deletedAt) - var id uint - require.NoError(t, db.Get(&id, `SELECT id FROM host_certificates WHERE sha1_sum = ?`, sha1)) - return id - } - - winHost := insertHost("windows", "win-uuid") - macHost := insertHost("darwin", "mac-uuid") - - // Windows osquery-origin live certs: must be soft-deleted by the migration so they re-parse on the next ingestion. - winOsq1 := insertCert(winHost, "1", "osquery", []byte("aaaaaaaaaaaaaaaaaaaa"), nil) - winOsq2 := insertCert(winHost, "2", "osquery", []byte("bbbbbbbbbbbbbbbbbbbb"), nil) - // Windows mdm-origin cert: parsed directly from the cert, must be left untouched. - winMDM := insertCert(winHost, "3", "mdm", []byte("cccccccccccccccccccc"), nil) - // Windows osquery cert already soft-deleted: must stay deleted (not re-touched). - winDeleted := insertCert(winHost, "4", "osquery", []byte("dddddddddddddddddddd"), "2026-01-01 00:00:00.000000") - // macOS osquery cert: unaffected by the Windows DN gap, must be left untouched. - macOsq := insertCert(macHost, "5", "osquery", []byte("eeeeeeeeeeeeeeeeeeee"), nil) - - applyNext(t, db) - - isDeleted := func(id uint) bool { - var deleted bool - require.NoError(t, db.Get(&deleted, `SELECT deleted_at IS NOT NULL FROM host_certificates WHERE id = ?`, id)) - return deleted - } - - // Windows osquery-origin live certs are now soft-deleted. - require.True(t, isDeleted(winOsq1)) - require.True(t, isDeleted(winOsq2)) - // Windows MDM-origin and macOS certs are untouched. - require.False(t, isDeleted(winMDM)) - require.False(t, isDeleted(macOsq)) - // Already-deleted Windows cert is still deleted. - require.True(t, isDeleted(winDeleted)) -} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 819d6b4a1a4..68fd80aa309 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -2089,9 +2089,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=561 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=560 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260703090248,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index cb5833fd955..55d1f35bb28 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -478,12 +478,10 @@ type Datastore interface { IsHostConnectedToFleetMDM(ctx context.Context, host *Host) (bool, error) ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificateRecord, *PaginationMetadata, error) - // UpdateHostCertificates ingests certs reported by `origin`. Each call only soft-deletes existing rows whose origin - // matches, so osquery and MDM ingestion don't clobber each other's view. observedScopes further limits - // reconciliation to the (source, username) scopes the agent could authoritatively enumerate this run; a nil slice - // means every scope was observed (macOS keychains and the MDM path), while a non-nil slice (Windows) preserves - // certificates for scopes it could not see, e.g. a logged-off user. - UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*HostCertificateRecord, origin HostCertificateOrigin, observedScopes []HostCertificateScope) error + // UpdateHostCertificates ingests certs reported by `origin`. Each call only + // soft-deletes existing rows whose origin matches, so osquery and MDM + // ingestion don't clobber each other's view. + UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*HostCertificateRecord, origin HostCertificateOrigin) error // SoftDeleteMDMHostCertificatesForUnenrolledHosts soft-deletes MDM-origin // cert rows for hosts reporting host_mdm.enrolled=0 — the cron complement to diff --git a/server/fleet/host_certificates.go b/server/fleet/host_certificates.go index 82944fe782d..aa7b7431ef7 100644 --- a/server/fleet/host_certificates.go +++ b/server/fleet/host_certificates.go @@ -41,19 +41,6 @@ const ( HostCertificateOriginMDM HostCertificateOrigin = "mdm" ) -// HostCertificateScope identifies a single (source, username) certificate scope. It is used to tell -// UpdateHostCertificates which scopes the agent could authoritatively enumerate during a collection cycle, so -// reconciliation does not soft-delete certificates for a scope it could not observe. -// -// A user's Windows certificates are only visible to osquery while that user is logged in (their registry hive is -// loaded), so the Windows ingestion path passes the set of observed scopes and absent users' certificates are preserved. -// The macOS path reads every keychain from disk on every run, so it passes a nil slice, meaning "all scopes observed" -// and any absent certificate may be deleted. -type HostCertificateScope struct { - Source HostCertificateSource - Username string -} - // HostCertificateRecord is the database model for a host certificate. type HostCertificateRecord struct { ID uint `json:"-" db:"id"` @@ -288,108 +275,50 @@ func parseDarwinDN(dn string) (*HostCertificateNameDetails, error) { value = strings.ReplaceAll(strings.Trim(value, " "), `<>`, `/`) // Replace our "safe" sequence with forward slash - applyDNAttribute(&details, &ouParts, key, value) + switch strings.ToUpper(key) { + case "C": + details.Country = strings.Trim(value, " ") + case "O": + details.Organization = strings.Trim(value, " ") + case "OU": + // osquery is inconsistent in how it reports certs with multiple OUs; sometimes it + // concatenates them all joined by `+OU=` separator within the same `/` delimited + // string, other times it provides multiple `/` delimited strings that each contain + // distinct OU values. For example, compare the following two lines: + // /OU=SomeValue/OU=fleet-a3d5d6f4c-819e-4159-9a42-0d6243a80ff8/CN=SomeName + // /OU=SomeValue+OU=fleet-a0c039413-d0c7-4b1f-9488-b93c865351ac/CN=SomeName + // + // To handle both cases, we collect all OU values and join them with `+OU=` below. + // We should probably reconsider our approaches for normalization of cert data + // across the board. + // FIXME: How should this work with the edge case covered by PR 33152 (e.g., "+" separator above)? + ouParts = append(ouParts, strings.Trim(value, " ")) + case "CN": + details.CommonName = strings.Trim(value, " ") + } } details.OrganizationalUnit = strings.Join(ouParts, "+OU=") return &details, nil } -// applyDNAttribute assigns a single distinguished-name attribute (key/value pair) to the matching field of details. -// Attributes Fleet does not display (state, locality, bare dotted-decimal OIDs, ...) are ignored. -func applyDNAttribute(details *HostCertificateNameDetails, ouParts *[]string, key, value string) { - switch strings.ToUpper(strings.TrimSpace(key)) { - case "C": - details.Country = value - case "O": - details.Organization = value - case "OU": - // osquery is inconsistent in how it reports certs with multiple OUs; sometimes it - // concatenates them all joined by `+OU=` separator within the same `/` delimited - // string, other times it provides multiple `/` delimited strings that each contain - // distinct OU values. For example, compare the following two lines: - // /OU=SomeValue/OU=fleet-a3d5d6f4c-819e-4159-9a42-0d6243a80ff8/CN=SomeName - // /OU=SomeValue+OU=fleet-a0c039413-d0c7-4b1f-9488-b93c865351ac/CN=SomeName - // - // To handle both cases, we collect all OU values and join them with `+OU=` (done by - // the caller). We should probably reconsider our approaches for normalization of cert - // data across the board. - *ouParts = append(*ouParts, value) - case "CN": - details.CommonName = value - } -} - -// parseWindowsDN parses a distinguished name in the X.500 string form that osquery emits in the `subject2` / `issuer2` -// columns on Windows starting with osquery 5.23.1, for example: +// FIXME: parseWindowsDN takes a distinguished name string from a Windows host but does not parse it +// because the format of the distinguished name as reported by osquery on Windows hosts is not +// well-ordered. For now, it simply sets the provided string as the CommonName and leaves other fields +// empty. // -// CN=Example, O="Example, Inc.", OU=A + OU=B, C=US -// -// Relative distinguished names (RDNs) are comma-separated; a multi-valued RDN joins its attributes with `+`; a value -// containing a separator (`,`, `+`, `=`, ...) is wrapped in double quotes with any embedded quote doubled. Unlike the -// macOS form parsed by parseDarwinDN (a slash-delimited openSSL style with the attribute keys preserved), this form is -// comma-delimited and quoted, so it needs its own tokenizer. -// -// It always returns best-effort details (a single odd attribute must not drop the whole certificate). If it skips any -// non-empty fragment that is not a valid `key=value` RDN, it also returns a non-fatal error naming those fragments, so -// the caller can log them +// To address this, we will likely need to modify the osquery certificates table. The issue is that +// osquery on Windows reports only the values in a comma-separated list without corresponding keys +// (instead of key-value pairs as on macOS, e.g., /C=US/O=Org/OU=Unit/CN=Name), When a value is missing +// (country, for example), the list shifts left such that the position of the values is not +// consistent, making it very difficult to map which value is which. func parseWindowsDN(dn string) (*HostCertificateNameDetails, error) { - var details HostCertificateNameDetails - var ouParts []string - var malformed []string - for _, attr := range splitX500Attributes(dn) { - key, value, found := strings.Cut(attr, "=") - if !found { - if trimmed := strings.TrimSpace(attr); trimmed != "" { - malformed = append(malformed, trimmed) - } - continue - } - applyDNAttribute(&details, &ouParts, key, unquoteX500Value(strings.TrimSpace(value))) - } - details.OrganizationalUnit = strings.Join(ouParts, "+OU=") - - var err error - if len(malformed) > 0 { - err = fmt.Errorf("skipped %d malformed RDN fragment(s) in windows distinguished name: %q", len(malformed), malformed) - } - return &details, err -} - -// splitX500Attributes splits an X.500 distinguished name into its individual `key=value` attributes, treating both `,` -// (RDN separator) and `+` (multi-valued RDN separator) as delimiters but ignoring any delimiter that appears inside a -// double-quoted value. -func splitX500Attributes(dn string) []string { - var attrs []string - var buf strings.Builder - inQuotes := false - for i := 0; i < len(dn); i++ { - c := dn[i] - switch { - case c == '"': - inQuotes = !inQuotes - buf.WriteByte(c) - case (c == ',' || c == '+') && !inQuotes: - attrs = append(attrs, buf.String()) - buf.Reset() - default: - buf.WriteByte(c) - } - } - if buf.Len() > 0 { - attrs = append(attrs, buf.String()) - } - return attrs -} - -// unquoteX500Value removes the surrounding double quotes that CERT_X500_NAME_STR adds to a value containing special -// characters, and un-doubles any escaped quote inside it. A value without surrounding quotes is returned unchanged. -func unquoteX500Value(v string) string { - if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { - v = v[1 : len(v)-1] - v = strings.ReplaceAll(v, `""`, `"`) - } - return v + return &HostCertificateNameDetails{ + CommonName: dn, + Country: "", + Organization: "", + OrganizationalUnit: "", + }, nil } // DecodeHexEscapes replaces literal \xHH escape sequences with the actual byte values. diff --git a/server/fleet/host_certificates_test.go b/server/fleet/host_certificates_test.go index bbfbb5331ac..36385d06a56 100644 --- a/server/fleet/host_certificates_test.go +++ b/server/fleet/host_certificates_test.go @@ -173,121 +173,6 @@ func TestExtractHostCertificateNameDetails(t *testing.T) { } } -func TestExtractWindowsCertificateNameDetails(t *testing.T) { - cases := []struct { - name string - input string - expected *HostCertificateNameDetails - errContains string // if set, parsing must return an error containing this substring - }{ - { - name: "basic", - input: "CN=Example, O=Example Inc, C=US", - expected: &HostCertificateNameDetails{ - CommonName: "Example", - Organization: "Example Inc", - Country: "US", - }, - }, - { - name: "with organizational unit", - input: "CN=device.example.com, OU=Engineering, O=Example Inc, C=US", - expected: &HostCertificateNameDetails{ - CommonName: "device.example.com", - OrganizationalUnit: "Engineering", - Organization: "Example Inc", - Country: "US", - }, - }, - { - name: "quoted value containing a comma", - input: `CN=Issuing CA, O="Example, Inc.", C=US`, - expected: &HostCertificateNameDetails{ - CommonName: "Issuing CA", - Organization: "Example, Inc.", - Country: "US", - }, - }, - { - name: "multiple organizational units", - input: "OU=Engineering, OU=fleet-a3ffb5cfa-3c69-433f-88af-d982ef9c3f67, CN=Multi OU, C=US", - expected: &HostCertificateNameDetails{ - OrganizationalUnit: "Engineering+OU=fleet-a3ffb5cfa-3c69-433f-88af-d982ef9c3f67", - CommonName: "Multi OU", - Country: "US", - }, - }, - { - name: "multi-valued RDN joined by plus", - input: "CN=Name + OU=A + OU=B, C=US", - expected: &HostCertificateNameDetails{ - CommonName: "Name", - OrganizationalUnit: "A+OU=B", - Country: "US", - }, - }, - { - name: "plus inside a quoted value is not a multi-valued RDN separator", - input: `CN=Name, O="A + B", C=US`, - expected: &HostCertificateNameDetails{ - CommonName: "Name", - Organization: "A + B", - Country: "US", - }, - }, - { - name: "doubled quotes inside a quoted value", - input: `CN="A ""quoted"" name", C=US`, - expected: &HostCertificateNameDetails{ - CommonName: `A "quoted" name`, - Country: "US", - }, - }, - { - name: "unmapped attributes are ignored", - input: "CN=Name, S=California, L=San Francisco, 2.5.4.5=ABC123, C=US", - expected: &HostCertificateNameDetails{ - CommonName: "Name", - Country: "US", - }, - }, - { - name: "empty input returns empty details without error", - input: "", - expected: &HostCertificateNameDetails{}, - }, - { - name: "empty fragment between separators is skipped silently", - input: "CN=X,,C=US", - expected: &HostCertificateNameDetails{ - CommonName: "X", - Country: "US", - }, - }, - { - name: "malformed fragment is skipped but reported, details still parsed", - input: "CN=Good Cert, garbage-no-equals, C=US", - expected: &HostCertificateNameDetails{ - CommonName: "Good Cert", - Country: "US", - }, - errContains: "garbage-no-equals", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - actual, err := ExtractDetailsFromOsqueryDistinguishedName("windows", tc.input) - if tc.errContains != "" { - require.ErrorContains(t, err, tc.errContains) - } else { - require.NoError(t, err) - } - // details are always best-effort, even when a malformed fragment is reported - require.Equal(t, tc.expected, actual) - }) - } -} - func TestExtractHostCertificateFromMDMAppleCertificateList(t *testing.T) { privateKey, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 0268a9b5728..c50a2baedbc 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -358,7 +358,7 @@ type IsHostConnectedToFleetMDMFunc func(ctx context.Context, host *fleet.Host) ( type ListHostCertificatesFunc func(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificateRecord, *fleet.PaginationMetadata, error) -type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error +type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error type SoftDeleteMDMHostCertificatesForUnenrolledHostsFunc func(ctx context.Context) (int64, error) @@ -6487,11 +6487,11 @@ func (s *DataStore) ListHostCertificates(ctx context.Context, hostID uint, opts return s.ListHostCertificatesFunc(ctx, hostID, opts) } -func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { +func (s *DataStore) UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { s.mu.Lock() s.UpdateHostCertificatesFuncInvoked = true s.mu.Unlock() - return s.UpdateHostCertificatesFunc(ctx, hostID, hostUUID, certs, origin, observedScopes) + return s.UpdateHostCertificatesFunc(ctx, hostID, hostUUID, certs, origin) } func (s *DataStore) SoftDeleteMDMHostCertificatesForUnenrolledHosts(ctx context.Context) (int64, error) { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 0b27a0a5e9e..078ba480dfb 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -5211,7 +5211,7 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx conte payload = append(payload, parsed) } - if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginMDM, nil); err != nil { + if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginMDM); err != nil { return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates") } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 4ed6674525c..8328a7225ca 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -15676,7 +15676,7 @@ func (s *integrationTestSuite) TestHostCertificates() { Source: fleet.SystemHostCertificate, }) } - require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery, nil)) + require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery)) // list all certs certResp = listHostCertificatesResponse{} diff --git a/server/service/osquery_test.go b/server/service/osquery_test.go index b1752015b44..3469f56f720 100644 --- a/server/service/osquery_test.go +++ b/server/service/osquery_test.go @@ -1251,7 +1251,6 @@ func verifyDiscovery(t *testing.T, queries, discovery map[string]string) { hostDetailQueryPrefix + "software_deb_last_opened_at": {}, hostDetailQueryPrefix + "disk_space_darwin": {}, hostDetailQueryPrefix + "disk_space_darwin_legacy": {}, - hostDetailQueryPrefix + "certificates_windows": {}, } for name := range queries { require.NotEmpty(t, discovery[name]) diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 2b9339a52a2..c1aec190325 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -864,18 +864,15 @@ var extraDetailQueries = map[string]DetailQuery{ "certificates_windows": { Query: ` SELECT - ca, common_name, subject2, issuer2, + ca, common_name, subject, issuer, key_algorithm, key_strength, key_usage, signing_algorithm, not_valid_after, not_valid_before, - serial, sha1, username, sid, + serial, sha1, username, path FROM certificates WHERE store = 'Personal';`, - // subject2/issuer2 preserve the distinguished name attribute keys (CN, O, OU, C). They are only populated on - // Windows starting with osquery 5.23.1 - Discovery: `SELECT 1 FROM pragma_table_info('certificates') WHERE name = 'subject2'`, Platforms: []string{"windows"}, DirectIngestFunc: directIngestHostCertificatesWindows, }, @@ -3612,7 +3609,7 @@ func directIngestHostCertificatesDarwin( return nil } - return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery, nil) + return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery) } func directIngestHostCertificatesWindows( @@ -3629,41 +3626,38 @@ func directIngestHostCertificatesWindows( } certs := make([]*fleet.HostCertificateRecord, 0, len(rows)) - // On Windows, osquery enumerates the same certificate from multiple redundant registry hives (the LocalSystem - // account's CurrentUser/Services views, per-user `_Classes` sub-hives, etc.), so we deduplicate by SHA1 + scope + - // username. - seen := make(map[string]struct{}, len(rows)) + // on windows, the osquery certificates table returns duplicate + // entries for the same certificate if it is present in multiple + // certificate stores so we deduplicate them here based on the + // SHA1 sum + username + existsSha1User := make(map[string]bool, len(rows)) for _, row := range rows { // Unescape \xHH sequences in fields that may contain non-ASCII // characters (e.g. Cyrillic) in the certificate's distinguished name. row["common_name"] = fleet.DecodeHexEscapes(row["common_name"]) - row["subject2"] = fleet.DecodeHexEscapes(row["subject2"]) - row["issuer2"] = fleet.DecodeHexEscapes(row["issuer2"]) + row["subject"] = fleet.DecodeHexEscapes(row["subject"]) + row["issuer"] = fleet.DecodeHexEscapes(row["issuer"]) csum, err := hex.DecodeString(row["sha1"]) if err != nil { logger.ErrorContext(ctx, "decoding sha1", "component", "service", "method", "directIngestHostCertificates", "err", err) continue } - subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["subject2"]) + subject, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["subject"]) if err != nil { - logger.ErrorContext(ctx, "malformed certificate subject distinguished name", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, "err", err) - ctxerr.Handle(ctx, err) + logger.ErrorContext(ctx, "extracting subject details", "component", "service", "method", "directIngestHostCertificates", "err", err) + continue } - issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["issuer2"]) + issuer, err := fleet.ExtractDetailsFromOsqueryDistinguishedName(host.Platform, row["issuer"]) if err != nil { - logger.ErrorContext(ctx, "malformed certificate issuer distinguished name", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, "err", err) - ctxerr.Handle(ctx, err) + logger.ErrorContext(ctx, "extracting issuer details", "component", "service", "method", "directIngestHostCertificates", "err", err) + continue } - // Classify scope from the registry hive security identifier (sid), not the owner name. - // S-1-5-21-... is local or AD account - // S-1-12-1-... is Entra ID account - source := fleet.SystemHostCertificate - username := "" - if sid := row["sid"]; strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-") { - source = fleet.UserHostCertificate - username = row["username"] + username := row["username"] + source := fleet.UserHostCertificate + if username == "SYSTEM" { + source = fleet.SystemHostCertificate } cert := &fleet.HostCertificateRecord{ @@ -3690,20 +3684,21 @@ func directIngestHostCertificatesWindows( Username: username, } - // Deduplicate by SHA1 + scope + username. System rows all collapse (username forced to ""), and a user's - // redundant hive views collapse into one entry per username. - key := fmt.Sprintf("%x|%s|%s", csum, source, username) - if _, ok := seen[key]; ok { - // Don't log user/cert identifiers here (PII). - logger.DebugContext(ctx, "skipping duplicate certificate for sha1+scope+user", + // deduplicate by SHA1 + Username + sha1UserKey := fmt.Sprintf("%x|%s", csum, username) + if exists := existsSha1User[sha1UserKey]; exists { + logger.DebugContext(ctx, "skipping duplicate certificate for sha1+user", "component", "service", "method", "directIngestHostCertificates", "host_id", host.ID, - "source", source, - "sha1", fmt.Sprintf("%x", csum)) + "username", username, + "sha1", fmt.Sprintf("%x", csum), + "issuer", cert.IssuerCommonName, + "subject", cert.SubjectCommonName, + "path", row["path"]) continue } - seen[key] = struct{}{} + existsSha1User[sha1UserKey] = true certs = append(certs, cert) } @@ -3712,29 +3707,7 @@ func directIngestHostCertificatesWindows( return nil } - // Tell the datastore which scopes we actually observed this run so it does not soft-delete a logged-off user's - // certificates. - return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery, windowsObservedCertScopes(certs)) -} - -// windowsObservedCertScopes returns the set of (source, username) scopes that osquery could authoritatively enumerate in -// this report. System scope is always included because the LocalMachine store is always readable; each user that -// reported at least one certificate is included as its own scope. -func windowsObservedCertScopes(certs []*fleet.HostCertificateRecord) []fleet.HostCertificateScope { - scopes := []fleet.HostCertificateScope{{Source: fleet.SystemHostCertificate}} - seen := map[string]struct{}{string(fleet.SystemHostCertificate) + "|": {}} - for _, c := range certs { - if c.Source != fleet.UserHostCertificate { - continue - } - key := string(c.Source) + "|" + c.Username - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - scopes = append(scopes, fleet.HostCertificateScope{Source: c.Source, Username: c.Username}) - } - return scopes + return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery) } func maybeUpdateLastRestartedAt(now time.Time, host *fleet.Host) { diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 8e006fd1077..31c2f95d6ca 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -3008,7 +3008,7 @@ func TestDirectIngestHostCertificates(t *testing.T) { "path": "/Library/Keychains/System.keychain", } - ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { + ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { require.Equal(t, host.ID, hostID) require.Equal(t, host.UUID, hostUUID) require.Equal(t, fleet.HostCertificateOriginOsquery, origin) @@ -3088,7 +3088,7 @@ func TestDirectIngestHostCertificatesDarwinHexEscapes(t *testing.T) { "path": "/Library/Keychains/System.keychain", } - ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { + ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { require.Equal(t, fleet.HostCertificateOriginOsquery, origin) require.Len(t, certs, 1) cert := certs[0] @@ -3109,228 +3109,146 @@ func TestDirectIngestHostCertificatesDarwinHexEscapes(t *testing.T) { require.True(t, ds.UpdateHostCertificatesFuncInvoked) } -// windowsCertRow builds an osquery Windows `certificates` table row for tests, -// starting from a common set of base fields and applying the given overrides. -func windowsCertRow(overrides map[string]string) map[string]string { - r := map[string]string{ - "ca": "0", +func TestDirectIngestHostCertificatesWindows(t *testing.T) { + ds := new(mock.Store) + ctx := t.Context() + logger := slog.New(slog.DiscardHandler) + host := &fleet.Host{ID: 1, UUID: "host-uuid", Platform: "windows"} + + // Fleet SCEP cert example based on data from a real Windows host + c1 := map[string]string{ + "ca": "-1", + "common_name": "494FE0F794940E21C757B790494B0FAFD97CFA4D5E9CC75856DB00DE78F3958D", + "subject": "Fleet, 494FE0F794940E21C757B790494B0FAFD97CFA4D5E9CC75856DB00DE78F3958D", + "issuer": "\"\", scep-ca, SCEP CA, FleetDM", "key_algorithm": "RSA", - "key_strength": "2048", - "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", + "key_strength": "2160", + "key_usage": "CERT_KEY_ENCIPHERMENT_KEY_USAGE,CERT_DIGITAL_SIGNATURE_KEY_USAGE", "signing_algorithm": "sha256RSA", "not_valid_after": "1780784467", "not_valid_before": "1749248467", "serial": "05", + "sha1": "1A395245953C61AE12657704FF45F31A1E7BC1E8", + "username": "Admin", + "path": "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000\\Personal", + } + // Custom SCEP cert example based on data from a real Windows host + c2 := map[string]string{ + "ca": "-1", + "common_name": "wc215384b-5a6e-4ca5-a2a3-1289734a5a71 User\n CN", + "subject": "fleet-w2a6fd2c4-0018-4bdc-8046-c7342962b576, \"wc215384b-5a6e-4ca5-a2a3-1289734a5a71 User\n CN\"", + "issuer": "US, scep-ca, SCEP CA, MICROMDM SCEP CA", + "key_algorithm": "RSA", + "key_strength": "1120", + "key_usage": "CERT_DIGITAL_SIGNATURE_KEY_USAGE", + "signing_algorithm": "sha256RSA", + "not_valid_after": "1796430423", + "not_valid_before": "1764893823", + "serial": "23", + "sha1": "EE5E756CC1A0782078C7C45180A4544A37D0F6D7", + "username": "Admin", + "path": "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000\\Personal", } - maps.Copy(r, overrides) - return r -} - -func TestDirectIngestHostCertificatesWindows(t *testing.T) { - ds := new(mock.Store) - ctx := t.Context() - logger := slog.New(slog.DiscardHandler) - host := &fleet.Host{ID: 1, UUID: "host-uuid", Platform: "windows"} - const ( - userSID = "S-1-5-21-1043593016-4249271388-1765263865-1000" - secondUSID = "S-1-5-21-1043593016-4249271388-1765263865-1500" - // Microsoft Entra ID (Azure AD) accounts on Entra-joined devices use the - // S-1-12-1 SID prefix rather than S-1-5-21. - entraSID = "S-1-12-1-1234567890-1234567890-1234567890-1234567890" - ) + // We'll use the examples above to create rows with minor variations, similar to what + // we would get from a real Windows host. + c3 := maps.Clone(c1) + c3["username"] = "SYSTEM" + c3["path"] = "Users\\S-1-5-18\\Personal" - const ( - machineSHA1 = "AAAA1111BBBB2222CCCC3333DDDD4444EEEE5555" - sysAcctSHA1 = "1111AAAA2222BBBB3333CCCC4444DDDD5555EEEE" - userSHA1 = "EE5E756CC1A0782078C7C45180A4544A37D0F6D7" - entraSHA1 = "FACE1234FACE1234FACE1234FACE1234FACE1234" - ) + c4 := maps.Clone(c1) + c4["username"] = "SYSTEM" + c4["path"] = "CurrentUser\\Personal" - // Machine-wide LocalMachine store: empty sid and empty username. - machine := windowsCertRow(map[string]string{ - "common_name": "Fleet Root CA", - "subject2": "CN=Fleet Root CA, O=Fleet Device Management Inc., OU=Engineering, C=US", - "issuer2": "CN=Fleet Root CA, O=Fleet Device Management Inc., C=US", - "sha1": machineSHA1, - "username": "", - "sid": "", - "path": "LocalMachine\\Personal", - }) + c5 := maps.Clone(c1) + c5["username"] = "SYSTEM" + c5["path"] = "Users\\S-1-5-18\\Personal" - // LocalSystem account (S-1-5-18) store, enumerated three times across redundant hive views. These must collapse into - // a single System entry and be retained (a distinct cert from LocalMachine, often a device/enrollment cert). - sysAcctCurrentUser := windowsCertRow(map[string]string{ - "common_name": "Device Enrollment", - "subject2": "CN=Device Enrollment, C=US", - "issuer2": "CN=Fleet SCEP CA, C=US", - "sha1": sysAcctSHA1, - "username": "SYSTEM", - "sid": "S-1-5-18", - "path": "CurrentUser\\Personal", - }) - sysAcctServices := maps.Clone(sysAcctCurrentUser) - sysAcctServices["path"] = "Services\\S-1-5-18\\Personal" - sysAcctUsersHive := maps.Clone(sysAcctCurrentUser) - sysAcctUsersHive["path"] = "Users\\S-1-5-18\\Personal" - - // Real interactive user (S-1-5-21-*), present in the Personal hive and the redundant _Classes sub-hive (same base - // SID). These collapse into one User/Admin entry. The issuer carries a quoted comma to exercise the parser. - userAdmin := windowsCertRow(map[string]string{ - "common_name": "admin@example.com", - "subject2": "CN=admin@example.com, OU=fleet-abc, OU=People, O=Example", - "issuer2": `CN=SCEP CA, O="Example, Inc.", C=US`, - "sha1": userSHA1, - "username": "Admin", - "sid": userSID, - "path": "Users\\" + userSID + "\\Personal", - }) - userAdminClasses := maps.Clone(userAdmin) - userAdminClasses["sid"] = userSID + "_Classes" - userAdminClasses["path"] = "Users\\" + userSID + "_Classes\\Personal" - - // The same certificate (same SHA1) also installed in a second user's store - userBob := maps.Clone(userAdmin) - userBob["username"] = "Bob" - userBob["sid"] = secondUSID - userBob["path"] = "Users\\" + secondUSID + "\\Personal" - - // An Entra ID (Azure AD) user, whose hive SID uses the S-1-12-1 prefix - entraUser := windowsCertRow(map[string]string{ - "common_name": "entra@example.com", - "subject2": "CN=entra@example.com, O=Example", - "issuer2": "CN=SCEP CA, C=US", - "sha1": entraSHA1, - "username": "AzureAD\\entrauser", - "sid": entraSID, - "path": "Users\\" + entraSID + "\\Personal", - }) + c6 := maps.Clone(c1) + c6["path"] = "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000_Classes\\Personal" - rows := []map[string]string{ - machine, - sysAcctCurrentUser, sysAcctServices, sysAcctUsersHive, - userAdmin, userAdminClasses, - userBob, - entraUser, - } + c7 := maps.Clone(c2) + c7["path"] = "Users\\S-1-5-21-1043593016-4249271388-1765263865-1000_Classes\\Personal" - type scopeKey struct { - sha1 string - source fleet.HostCertificateSource - username string - } + rows := []map[string]string{c1, c2, c3, c4, c5, c6, c7} - ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { + ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error { require.Equal(t, host.ID, hostID) require.Equal(t, host.UUID, hostUUID) require.Equal(t, fleet.HostCertificateOriginOsquery, origin) - - // 8 rows collapse to 5 distinct (SHA1, scope, username) entries. - require.Len(t, certs, 5) - - expected := map[scopeKey]bool{ - {machineSHA1, fleet.SystemHostCertificate, ""}: true, - {sysAcctSHA1, fleet.SystemHostCertificate, ""}: true, - {userSHA1, fleet.UserHostCertificate, "Admin"}: true, - {userSHA1, fleet.UserHostCertificate, "Bob"}: true, - {entraSHA1, fleet.UserHostCertificate, "AzureAD\\entrauser"}: true, + require.Len(t, certs, 3) + + // We expect that the ingest function will deduplicate certs based on SHA1+username + // so we should see only 3 unique combinations from the 7 rows above. + expectSha1Users := map[string]bool{ + "1A395245953C61AE12657704FF45F31A1E7BC1E8" + "Admin": true, // c1, c6 + "1A395245953C61AE12657704FF45F31A1E7BC1E8" + "SYSTEM": true, // c3, c4, c5 + "EE5E756CC1A0782078C7C45180A4544A37D0F6D7" + "Admin": true, // c2, c7 } - seen := map[scopeKey]bool{} + seenSha1Users := map[string]bool{} for _, cert := range certs { - sha1 := strings.ToUpper(hex.EncodeToString(cert.SHA1Sum)) - k := scopeKey{sha1, cert.Source, cert.Username} - require.True(t, expected[k], "unexpected (sha1, scope, username): %+v", k) - seen[k] = true - - require.Equal(t, "RSA", cert.KeyAlgorithm) - require.Equal(t, "sha256RSA", cert.SigningAlgorithm) - - switch k { - case scopeKey{machineSHA1, fleet.SystemHostCertificate, ""}: - // non-DN fields are mapped straight from the osquery row - require.Equal(t, "Fleet Root CA", cert.CommonName) + s := strings.ToUpper(hex.EncodeToString(cert.SHA1Sum)) + _, ok := expectSha1Users[s+cert.Username] + require.True(t, ok, "unexpected cert SHA1+username combination: %s + %s", s, cert.Username) + seenSha1Users[s+cert.Username] = true + + // Validate fields that differ between the cert examples + switch s { + case "1A395245953C61AE12657704FF45F31A1E7BC1E8": + require.Equal(t, "CERT_KEY_ENCIPHERMENT_KEY_USAGE,CERT_DIGITAL_SIGNATURE_KEY_USAGE", cert.KeyUsage) + require.Equal(t, "05", cert.Serial) require.Equal(t, int64(1780784467), cert.NotValidAfter.Unix()) require.Equal(t, int64(1749248467), cert.NotValidBefore.Unix()) - require.Equal(t, "05", cert.Serial) - require.Equal(t, 2048, cert.KeyStrength) + require.Equal(t, 2160, cert.KeyStrength) + require.Equal(t, "494FE0F794940E21C757B790494B0FAFD97CFA4D5E9CC75856DB00DE78F3958D", cert.CommonName) + require.Equal(t, "Fleet, 494FE0F794940E21C757B790494B0FAFD97CFA4D5E9CC75856DB00DE78F3958D", cert.SubjectCommonName) + require.Equal(t, "\"\", scep-ca, SCEP CA, FleetDM", cert.IssuerCommonName) + require.Contains(t, []string{"Admin", "SYSTEM"}, cert.Username) + + case "EE5E756CC1A0782078C7C45180A4544A37D0F6D7": require.Equal(t, "CERT_DIGITAL_SIGNATURE_KEY_USAGE", cert.KeyUsage) - require.False(t, cert.CertificateAuthority) - // distinguished name fields are parsed from subject2 / issuer2 - require.Equal(t, "Fleet Root CA", cert.SubjectCommonName) - require.Equal(t, "Fleet Device Management Inc.", cert.SubjectOrganization) - require.Equal(t, "Engineering", cert.SubjectOrganizationalUnit) - require.Equal(t, "US", cert.SubjectCountry) - require.Equal(t, "Fleet Root CA", cert.IssuerCommonName) - require.Equal(t, "US", cert.IssuerCountry) - case scopeKey{sysAcctSHA1, fleet.SystemHostCertificate, ""}: - require.Equal(t, "Device Enrollment", cert.SubjectCommonName) - require.Equal(t, "US", cert.SubjectCountry) - require.Equal(t, "Fleet SCEP CA", cert.IssuerCommonName) - case scopeKey{userSHA1, fleet.UserHostCertificate, "Admin"}, scopeKey{userSHA1, fleet.UserHostCertificate, "Bob"}: - require.Equal(t, "admin@example.com", cert.SubjectCommonName) - require.Equal(t, "Example", cert.SubjectOrganization) - require.Equal(t, "fleet-abc+OU=People", cert.SubjectOrganizationalUnit) - // quoted comma inside the issuer organization must be preserved - require.Equal(t, "Example, Inc.", cert.IssuerOrganization) - require.Equal(t, "SCEP CA", cert.IssuerCommonName) - require.Equal(t, "US", cert.IssuerCountry) - case scopeKey{entraSHA1, fleet.UserHostCertificate, "AzureAD\\entrauser"}: - require.Equal(t, "entra@example.com", cert.SubjectCommonName) - require.Equal(t, "Example", cert.SubjectOrganization) - require.Equal(t, "SCEP CA", cert.IssuerCommonName) - require.Equal(t, "US", cert.IssuerCountry) + require.Equal(t, "23", cert.Serial) + require.Equal(t, int64(1796430423), cert.NotValidAfter.Unix()) + require.Equal(t, int64(1764893823), cert.NotValidBefore.Unix()) + require.Equal(t, 1120, cert.KeyStrength) + require.Equal(t, "wc215384b-5a6e-4ca5-a2a3-1289734a5a71 User\n CN", cert.CommonName) + require.Equal(t, "fleet-w2a6fd2c4-0018-4bdc-8046-c7342962b576, \"wc215384b-5a6e-4ca5-a2a3-1289734a5a71 User\n CN\"", cert.SubjectCommonName) + require.Equal(t, "US, scep-ca, SCEP CA, MICROMDM SCEP CA", cert.IssuerCommonName) + require.Equal(t, "Admin", cert.Username) + + default: + t.Fatalf("unexpected cert SHA1: %s", s) } - } - require.Equal(t, expected, seen) - // Observed scopes: System is always observed, plus each user that reported - // a cert. This is what lets reconciliation preserve logged-off users. - require.ElementsMatch(t, []fleet.HostCertificateScope{ - {Source: fleet.SystemHostCertificate}, - {Source: fleet.UserHostCertificate, Username: "Admin"}, - {Source: fleet.UserHostCertificate, Username: "Bob"}, - {Source: fleet.UserHostCertificate, Username: "AzureAD\\entrauser"}, - }, observedScopes) - - return nil - } - - err := directIngestHostCertificatesWindows(ctx, logger, host, ds, rows) - require.NoError(t, err) - require.True(t, ds.UpdateHostCertificatesFuncInvoked) -} + // Validate fields common across all Windows certs in this test + require.Equal(t, "RSA", cert.KeyAlgorithm) + require.Equal(t, "sha256RSA", cert.SigningAlgorithm) + require.False(t, cert.CertificateAuthority) + if cert.Username == "SYSTEM" { + require.Equal(t, fleet.SystemHostCertificate, cert.Source) + } else { + require.Equal(t, fleet.UserHostCertificate, cert.Source) + } -func TestDirectIngestHostCertificatesWindowsMalformedDN(t *testing.T) { - ds := new(mock.Store) - ctx := t.Context() - logger := slog.New(slog.DiscardHandler) - host := &fleet.Host{ID: 1, UUID: "host-uuid", Platform: "windows"} + // For Windows certs, osquery squeezes all distinguished name fields into + // the comma-separated list that we store as Issuer/SubjectCommonName and + // we leave all other fields empty for now (see fleet.ExtractDetailsFromOsqueryDistinguishedName) + require.Empty(t, cert.SubjectOrganization) + require.Empty(t, cert.SubjectOrganizationalUnit) + require.Empty(t, cert.SubjectCountry) + require.Empty(t, cert.IssuerOrganization) + require.Empty(t, cert.IssuerOrganizationalUnit) + require.Empty(t, cert.IssuerCountry) - // subject2 contains a non-empty fragment with no '=' (malformed osquery output). - // The certificate must still be ingested best-effort, not dropped. - row := windowsCertRow(map[string]string{ - "common_name": "malformed.example.com", - "subject2": "CN=malformed.example.com, garbage-no-equals, C=US", - "issuer2": "CN=Issuer CA, C=US", - "sha1": "1234123412341234123412341234123412341234", - "username": "", - "sid": "", - "path": "LocalMachine\\Personal", - }) + } + require.Equal(t, expectSha1Users, seenSha1Users) - var got []*fleet.HostCertificateRecord - ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin, observedScopes []fleet.HostCertificateScope) error { - got = certs return nil } - require.NoError(t, directIngestHostCertificatesWindows(ctx, logger, host, ds, []map[string]string{row})) + err := directIngestHostCertificatesWindows(ctx, logger, host, ds, rows) + require.NoError(t, err) require.True(t, ds.UpdateHostCertificatesFuncInvoked) - // The cert is kept, with the parseable fields populated (the malformed fragment is dropped). - require.Len(t, got, 1) - require.Equal(t, "malformed.example.com", got[0].SubjectCommonName) - require.Equal(t, "US", got[0].SubjectCountry) - require.Equal(t, fleet.SystemHostCertificate, got[0].Source) } func TestGenerateSQLForAllExists(t *testing.T) { From 0b51a17ae410d6e1c560bbd45197b26d3cce4def Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:46:28 +0000 Subject: [PATCH 17/17] Code review fixes --- cmd/osquery-perf/agent.go | 8 +-- cmd/osquery-perf/certificates.go | 105 +++++++++++++------------------ 2 files changed, 45 insertions(+), 68 deletions(-) diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index d77f2ac37a8..710d225ee18 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -544,11 +544,9 @@ type agent struct { // a-ok for osquery-perf and load testing). bufferedResults map[resultLog]int - // cache of this host's per-host certificates (the certs unique to this - // host, excluding the shared certs every host reports). Note that this - // requires a mutex even though only used in a.processQuery, that's because - // both the runLoop and the live query goroutines may call DistributedWrite - // (which calls processQuery). + // cache of this host's per-host certificates (the certs unique to this host, excluding the shared certs every host + // reports). Note that this requires a mutex even though only used in a.processQuery, that's because both the runLoop + // and the live query goroutines may call DistributedWrite (which calls processQuery). certificatesMutex sync.RWMutex hostCertSpecs []simulatedCert commonSoftwareNameSuffix string diff --git a/cmd/osquery-perf/certificates.go b/cmd/osquery-perf/certificates.go index 7b95272da1e..8509f29c20a 100644 --- a/cmd/osquery-perf/certificates.go +++ b/cmd/osquery-perf/certificates.go @@ -5,9 +5,7 @@ import ( "encoding/hex" "fmt" "maps" - // osquery-perf shares one global math/rand RNG seeded from the --seed flag - // (see rand.Seed in agent.go) so load-test runs are reproducible; this file - // uses the same source rather than math/rand/v2's unseedable globals. + // osquery-perf shares one global math/rand RNG seeded from the --seed flag so load-test runs are reproducible "math/rand" //nolint:depguard "strings" "time" @@ -15,10 +13,8 @@ import ( "github.com/google/uuid" ) -// simulatedCert is a platform-neutral description of a certificate that -// osquery-perf reports for the `certificates` detail query. The platform -// renderers (darwinRow / windowsRows) translate it into the column shape -// osquery produces on that platform. +// simulatedCert is a platform-neutral description of a certificate that osquery-perf reports for the `certificates` +// detail query. type simulatedCert struct { ca bool commonName string @@ -36,26 +32,20 @@ type simulatedCert struct { serial string notValidAfterUnix string notValidBeforeUnix string - // user reports whether the certificate lives in a user's store (true) or in - // the machine/system store (false). username is the owning user when user is - // true. + // user reports whether the certificate lives in a user's store (true) or in the machine/system store (false). user bool username string } -// sha1Hex returns the hex-encoded SHA1 osquery would report for this cert. It -// is derived from the serial so that shared certs (fixed serial) dedupe to a -// single host_certificates row across all hosts, while per-host certs (uuid -// serial) stay unique per host. +// sha1Hex returns the hex-encoded SHA1 osquery would report for this cert. It is derived from the serial so that shared +// certs (fixed serial) dedupe to a single host_certificates row across all hosts, while per-host certs (uuid serial) +// stay unique per host. func (c simulatedCert) sha1Hex() string { sum := sha1.Sum([]byte(c.serial)) //nolint: gosec return hex.EncodeToString(sum[:]) } -// sharedCerts are reported by every simulated host (common root and -// intermediate CAs). Their serials are fixed, so the Fleet server dedupes them -// into a single host_certificates row referenced by every host. They are -// machine-scoped and long-lived. +// sharedCerts are reported by every simulated host (common root and intermediate CAs). var sharedCerts = []simulatedCert{ { ca: true, commonName: "Fleet Root CA", @@ -141,10 +131,15 @@ var sharedCerts = []simulatedCert{ const certDay = 24 * time.Hour -// generateCertSpecs returns the certs this host reports: the constant shared -// certs plus this host's per-host certs. Per-host certs are generated once and -// cached so they're stable across polls, then occasionally churned to simulate -// certificate rotation/installs. Shared certs are never churned. +// certChurnPercent is the percent chance that some of a host's per-host certificates rotate (new serial and SHA1), +// simulating certificate renewal/reinstall. It is rolled each time the host answers the certificates detail query, +// i.e. on the periodic detail refresh (osquery.detail_update_interval, 1h by default) or a forced refetch. Shared +// certs never churn. +const certChurnPercent = 5 + +// generateCertSpecs returns the certs this host reports: the constant shared certs plus this host's per-host certs. +// Per-host certs are generated once and cached so they're stable across detail-query refreshes, then occasionally +// churned to simulate certificate rotation/installs. Shared certs are never churned. func (a *agent) generateCertSpecs() []simulatedCert { a.certificatesMutex.Lock() defer a.certificatesMutex.Unlock() @@ -152,8 +147,7 @@ func (a *agent) generateCertSpecs() []simulatedCert { switch { case a.hostCertSpecs == nil: a.hostCertSpecs = a.newPerHostCertSpecs() - case rand.Intn(100) < 5: - // 5% chance for some of this host's certs to change between polls. + case rand.Intn(100) < certChurnPercent: a.churnPerHostCertSpecs() } @@ -163,8 +157,7 @@ func (a *agent) generateCertSpecs() []simulatedCert { return specs } -// newPerHostCertSpecs generates 0-10 certificates unique to this host, with -// random (uuid) serials so each host's certs are distinct in the Fleet server. +// newPerHostCertSpecs generates 0-10 certificates unique to this host func (a *agent) newPerHostCertSpecs() []simulatedCert { count := rand.Intn(11) // 0..10 users := a.hostUsers() @@ -172,9 +165,8 @@ func (a *agent) newPerHostCertSpecs() []simulatedCert { for i := range count { specs = append(specs, a.newPerHostCertSpec(i, users)) } - // Model a device certificate present in both the machine store and a user's - // store (same SHA1, two scopes), exercising the server's cross-scope - // handling (one host_certificates row, two host_certificate_sources rows). + // Model a device certificate present in both the machine store and a user's store (same SHA1, two scopes), + // exercising the server's cross-scope handling (one host_certificates row, two host_certificate_sources rows). if count > 0 && len(users) > 0 { dup := specs[0] dup.user = !specs[0].user @@ -217,8 +209,8 @@ func (a *agent) newPerHostCertSpec(i int, users []map[string]string) simulatedCe } } -// churnPerHostCertSpecs rotates 1..N of this host's per-host certs by assigning -// new serials (and thus new SHA1s), simulating certificate renewal/reinstall. +// churnPerHostCertSpecs rotates 1..N of this host's per-host certs by assigning new serials (and thus new SHA1s), +// simulating certificate renewal/reinstall. func (a *agent) churnPerHostCertSpecs() { if len(a.hostCertSpecs) == 0 { return @@ -239,29 +231,32 @@ func boolStr(b bool) string { return "0" } -// darwinDN renders a slash-delimited distinguished name (e.g. -// /C=US/O=Org/OU=Unit/CN=Name) as osquery returns on macOS. Empty fields are -// omitted. +// darwinDN renders a slash-delimited distinguished name (e.g. /C=US/O=Org/OU=Unit/CN=Name) as osquery returns on macOS. +// Empty fields are omitted. func darwinDN(country, org, orgUnit, commonName string) string { var b strings.Builder if country != "" { - b.WriteString("/C=" + country) + b.WriteString("/C=" + escapeDarwinDNValue(country)) } if org != "" { - b.WriteString("/O=" + org) + b.WriteString("/O=" + escapeDarwinDNValue(org)) } if orgUnit != "" { - b.WriteString("/OU=" + orgUnit) + b.WriteString("/OU=" + escapeDarwinDNValue(orgUnit)) } if commonName != "" { - b.WriteString("/CN=" + commonName) + b.WriteString("/CN=" + escapeDarwinDNValue(commonName)) } return b.String() } -// windowsDN renders an X.500 (RFC 1779) distinguished name (e.g. -// "CN=Name, O=Org, OU=Unit, C=US") as osquery returns in subject2/issuer2 on -// Windows starting with osquery 5.23.1. +// escapeDarwinDNValue backslash-escapes slashes inside an attribute value, as osquery does on macOS +func escapeDarwinDNValue(v string) string { + return strings.ReplaceAll(v, "/", `\/`) +} + +// windowsDN renders an X.500 (RFC 1779) distinguished name (e.g. "CN=Name, O=Org, OU=Unit, C=US") as osquery returns in +// subject2/issuer2 on Windows starting with osquery 5.23.1. func windowsDN(country, org, orgUnit, commonName string) string { var parts []string if commonName != "" { @@ -288,21 +283,8 @@ func quoteX500Value(v string) string { return `"` + strings.ReplaceAll(v, `"`, `""`) + `"` } -// windowsLegacyDN renders the simple, values-only distinguished name that -// pre-5.23.1 osquery returned in subject/issuer. Kept so the generator also -// works against older Fleet servers that read those columns. -func windowsLegacyDN(country, org, orgUnit, commonName string) string { - var parts []string - for _, v := range []string{commonName, orgUnit, org, country} { - if v != "" { - parts = append(parts, v) - } - } - return strings.Join(parts, ", ") -} - -// windowsUserSID returns a stable per-(host, user) security identifier so a -// user's certs classify as User scope and stay consistent across polls. +// windowsUserSID returns a stable per-(host, user) security identifier so a user's certs classify as User scope and stay +// consistent across detail-query refreshes. func (a *agent) windowsUserSID(username string) string { var h uint32 = 2166136261 for i := 0; i < len(username); i++ { @@ -357,20 +339,17 @@ func (a *agent) certificatesWindows() []map[string]string { return rows } -// windowsRows renders the osquery `certificates` rows for a cert on Windows. -// Machine-scoped certs produce one row. User-scoped certs produce the redundant -// rows osquery returns from the user's Personal hive and its companion _Classes +// windowsRows renders the osquery `certificates` rows for a cert on Windows. Machine-scoped certs produce one row. +// User-scoped certs produce the redundant rows osquery returns from the user's Personal hive and its companion _Classes // hive (the Fleet server dedupes them by SHA1 + scope + username). func (a *agent) windowsRows(c simulatedCert) []map[string]string { base := map[string]string{ "ca": boolStr(c.ca), "common_name": c.commonName, - // subject2/issuer2 are read by Fleet servers with Windows certificate - // support (osquery 5.23.1+); subject/issuer are kept for older servers. + // subject2/issuer2 are the X.500 distinguished name columns Fleet's Windows certificates query selects, + // populated on Windows starting with osquery 5.23.1. "subject2": windowsDN(c.subjectCountry, c.subjectOrg, c.subjectOrgUnit, c.subjectCommonName), "issuer2": windowsDN(c.issuerCountry, c.issuerOrg, "", c.issuerCommonName), - "subject": windowsLegacyDN(c.subjectCountry, c.subjectOrg, c.subjectOrgUnit, c.subjectCommonName), - "issuer": windowsLegacyDN(c.issuerCountry, c.issuerOrg, "", c.issuerCommonName), "key_algorithm": c.keyAlgorithm, "key_strength": c.keyStrength, "key_usage": c.keyUsage,