Cert renewal for non-proxied SCEP and ACME (Phase 1 + Phase 2) (#45696)

This commit is contained in:
Tim Lee
2026-05-18 11:41:02 -06:00
committed by GitHub
parent 9e7781a004
commit bbfbea8de2
36 changed files with 1540 additions and 105 deletions
@@ -19,6 +19,8 @@ Fleet API users can access host certificate information via the "Get host's cert
For macOS hosts, Fleet retrieves certificate information using osquery's `certificates` [table](https://fleetdm.com/learn-more-about/certificates-query). For iOS and iPadOS hosts, Fleet retrieves certificates via MDM using the `CertificateList` [command](https://developer.apple.com/documentation/devicemanagement/certificate-list-command).
When a macOS host installs a configuration profile containing an ACME payload, Fleet also retrieves the resulting certificate via the MDM `CertificateList` command. This surfaces hardware-bound ACME certificates that don't appear in osquery's `certificates` table. Ingestion runs per-host on each ACME profile install and re-install — there is no recurring cadence — so certificates from a given profile become visible the first time the profile is installed or re-deployed on a host.
## Conclusion
The certificates section in host vitals provides you with a quick overview of the certificates installed on your macOS, iOS, and iPadOS devices. This feature helps you identify and troubleshoot certificate-related issues that may prevent your end users from connecting to the corporate network.
@@ -0,0 +1 @@
* Surface hardware-bound ACME certificates on macOS host vitals by retrieving them via the MDM `CertificateList` command when an ACME-bearing configuration profile is installed or re-installed.
+2
View File
@@ -5244,6 +5244,8 @@ Available for macOS, iOS, iPadOS, and Windows hosts only. Requires Fleet's MDM t
Retrieves the certificates installed on a host.
For macOS hosts, certificates from MDM-delivered profiles containing an ACME payload are retrieved via the MDM `CertificateList` command on each profile install and re-install (not on a recurring cadence). Hardware-bound ACME certificates that aren't visible to osquery first appear in the response after the host installs or re-installs the delivering profile.
`GET /api/v1/fleet/hosts/:id/certificates`
#### Parameters
+124 -6
View File
@@ -41,7 +41,7 @@ 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) 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
@@ -50,6 +50,9 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho
incomingBySHA1 := make(map[string]*fleet.HostCertificateRecord, len(certs))
incomingSourcesBySHA1 := make(map[string][]certSourceToSet, len(certs))
for _, cert := range certs {
// Tag every incoming cert with the calling ingestion source. We trust the
// caller for this — origin scopes deletion semantics, not data integrity.
cert.Origin = origin
if cert.HostID != hostID {
// caller should ensure this does not happen
ds.logger.DebugContext(ctx, fmt.Sprintf("host certificates: host ID does not match provided certificate: %d %d", hostID, cert.HostID))
@@ -163,8 +166,11 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho
now := time.Now()
for _, row := range hostMDMManagedCerts {
hostMDMManagedCert := &row.MDMManagedCertificate
// DigiCert is populated server-side at issuance, not via osquery/MDM.
if !hostMDMManagedCert.Type.SupportsRenewalID() {
// Skip CA types that don't carry a renewal-ID marker — today only
// DigiCert, which is server-issued and managed without matching
// against ingested certs. Empty/NULL `Type` (rows created by the
// non-proxied insert path below) IS eligible.
if hostMDMManagedCert.Type != "" && !hostMDMManagedCert.Type.SupportsRenewalID() {
continue
}
@@ -220,9 +226,84 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho
}
}
// Non-proxied insert path: for each profile installed on this host
// without an existing host_mdm_managed_certificates row, see if any
// incoming cert's Subject carries the `fleet-<profile_uuid>` marker.
// If so, create the row from the cert's metadata. This activates
// renewal for ACME / non-proxied SCEP flows where Fleet isn't in the
// issuance path so no row gets created at issuance time.
hostMDMManagedCertsToInsert := make([]*fleet.MDMManagedCertificate, 0, len(incomingBySHA1))
if len(incomingBySHA1) > 0 {
existingProfileUUIDs := make(map[string]struct{}, len(hostMDMManagedCerts))
for _, row := range hostMDMManagedCerts {
existingProfileUUIDs[row.ProfileUUID] = struct{}{}
}
var candidateProfileUUIDs []string
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &candidateProfileUUIDs, `
SELECT profile_uuid FROM host_mdm_apple_profiles
WHERE host_uuid = ? AND operation_type = ?
UNION
SELECT profile_uuid FROM host_mdm_windows_profiles
WHERE host_uuid = ? AND operation_type = ?`,
hostUUID, fleet.MDMOperationTypeInstall,
hostUUID, fleet.MDMOperationTypeInstall,
); err != nil {
return ctxerr.Wrap(ctx, err, "list candidate profile UUIDs for managed cert insert")
}
for _, profileUUID := range candidateProfileUUIDs {
if _, exists := existingProfileUUIDs[profileUUID]; exists {
continue
}
renewalIDString := "fleet-" + profileUUID
var bestMatch *fleet.HostCertificateRecord
for _, cert := range incomingBySHA1 {
if !strings.Contains(cert.SubjectCommonName, renewalIDString) &&
!strings.Contains(cert.SubjectOrganizationalUnit, renewalIDString) {
continue
}
// Skip certs outside their validity window: a device may
// still be reporting a just-expired cert alongside its
// renewal, and latching onto it would seed the row with
// backward-pointing dates.
if cert.NotValidBefore.After(now) || cert.NotValidAfter.Before(now) {
continue
}
if bestMatch == nil || cert.NotValidBefore.After(bestMatch.NotValidBefore) {
bestMatch = cert
}
}
if bestMatch == nil {
continue
}
// Use a fixed sentinel for ca_name on non-proxied rows.
// Proxied flows set ca_name from Fleet-controlled CA
// registration (stable across renewals); deriving it from
// the cert's Issuer CN would drift if the upstream CA ever
// renames. The cert's actual issuer is available in
// host_certificates for support visibility.
// Type is written as NULL by insertHostMDMManagedCertDB —
// Fleet wasn't in the issuance path so it doesn't know the
// CA type. The struct's Type field is left unset.
hostMDMManagedCertsToInsert = append(hostMDMManagedCertsToInsert, &fleet.MDMManagedCertificate{
HostUUID: hostUUID,
ProfileUUID: profileUUID,
NotValidBefore: &bestMatch.NotValidBefore,
NotValidAfter: &bestMatch.NotValidAfter,
CAName: "non_proxied",
Serial: ptr.String(fmt.Sprintf("%040s", bestMatch.Serial)),
})
}
}
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
}
toDelete = append(toDelete, existing.ID)
}
}
@@ -261,6 +342,10 @@ func (ds *Datastore) UpdateHostCertificates(ctx context.Context, hostID uint, ho
if err := updateHostMDMManagedCertDetailsDB(ctx, tx, hostMDMManagedCertsToUpdate); err != nil {
return ctxerr.Wrap(ctx, err, "update host mdm managed cert details")
}
if err := insertHostMDMManagedCertDB(ctx, tx, hostMDMManagedCertsToInsert); err != nil {
return ctxerr.Wrap(ctx, err, "insert host mdm managed cert rows")
}
return nil
})
}
@@ -380,6 +465,7 @@ SELECT
hc.issuer_org,
hc.issuer_org_unit,
hc.issuer_common_name,
hc.origin,
hcs.source,
hcs.username
%s`, fromWhereClause)
@@ -527,19 +613,25 @@ INSERT INTO host_certificates (
issuer_country,
issuer_org,
issuer_org_unit,
issuer_common_name
issuer_common_name,
origin
) VALUES %s`
placeholders := make([]string, 0, len(certs))
const singleRowPlaceholderCount = 19
const singleRowPlaceholderCount = 20
args := make([]interface{}, 0, len(certs)*singleRowPlaceholderCount)
for _, cert := range certs {
placeholders = append(placeholders, "("+strings.Repeat("?,", singleRowPlaceholderCount-1)+"?)")
origin := cert.Origin
if origin == "" {
origin = fleet.HostCertificateOriginOsquery
}
args = append(args,
cert.HostID, cert.SHA1Sum, cert.NotValidBefore, cert.NotValidAfter, cert.CertificateAuthority, cert.CommonName,
cert.KeyAlgorithm, cert.KeyStrength, cert.KeyUsage, cert.Serial, cert.SigningAlgorithm,
cert.SubjectCountry, cert.SubjectOrganization, cert.SubjectOrganizationalUnit, cert.SubjectCommonName,
cert.IssuerCountry, cert.IssuerOrganization, cert.IssuerOrganizationalUnit, cert.IssuerCommonName)
cert.IssuerCountry, cert.IssuerOrganization, cert.IssuerOrganizationalUnit, cert.IssuerCommonName,
origin)
}
stmt = fmt.Sprintf(stmt, strings.Join(placeholders, ","))
@@ -592,3 +684,29 @@ func updateHostMDMManagedCertDetailsDB(ctx context.Context, tx sqlx.ExtContext,
}
return nil
}
// insertHostMDMManagedCertDB creates host_mdm_managed_certificates rows for
// non-proxied SCEP/ACME flows discovered via cert ingestion. type is always
// written as NULL because Fleet wasn't in the issuance path and doesn't know
// the CA type. Uses INSERT IGNORE so a row created concurrently by another
// transaction (e.g., a SCEP proxy issuance) doesn't cause a duplicate-key
// error here — the matcher's UPDATE pass picks up that row on the next
// ingestion call.
func insertHostMDMManagedCertDB(ctx context.Context, tx sqlx.ExtContext, certs []*fleet.MDMManagedCertificate) error {
if len(certs) == 0 {
return nil
}
for _, c := range certs {
_, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO host_mdm_managed_certificates
(host_uuid, profile_uuid, ca_name, type,
not_valid_before, not_valid_after, serial)
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
c.HostUUID, c.ProfileUUID, c.CAName,
c.NotValidBefore, c.NotValidAfter, c.Serial)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert host mdm managed certificate")
}
}
return nil
}
+327 -18
View File
@@ -30,8 +30,10 @@ func TestHostCertificates(t *testing.T) {
}{
{"UpdateAndList", testUpdateAndListHostCertificates},
{"Update with host_mdm_managed_certificates to update", testUpdatingHostMDMManagedCertificates},
{"Insert host_mdm_managed_certificates from non-proxied ingestion", testInsertingHostMDMManagedCertificatesFromIngestion},
{"Matcher recovers stuck hmmc rows", testMatcherRecoversStuckHMMCRows},
{"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation},
{"Origin-scoped delete", testUpdateHostCertificatesOriginScopedDelete},
{"Create certificates with long country code", testHostCertificateWithInvalidCountryCode},
{"Truncate long certificate fields", testTruncateLongCertificateFields},
{"Count matches main query", testListHostCertificatesCountMatches},
@@ -79,7 +81,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))
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})
@@ -103,7 +105,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]}))
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)
@@ -113,7 +115,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]}))
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)
@@ -157,7 +159,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)
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)
@@ -303,7 +305,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))
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"})
@@ -350,7 +352,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]}))
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)
@@ -493,7 +495,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))
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery))
return payload
}
@@ -504,7 +506,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))
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload, fleet.HostCertificateOriginOsquery))
}
t.Run("MissedIngestRecovered", func(t *testing.T) {
@@ -617,7 +619,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))
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, recs, fleet.HostCertificateOriginOsquery))
got := getApple(t, profileUUID, "ca-stable")
require.NotNil(t, got.NotValidAfter)
@@ -640,7 +642,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))
got := getApple(t, profileUUID, "ca-mono")
require.NotNil(t, got.NotValidAfter)
@@ -648,6 +650,224 @@ func testMatcherRecoversStuckHMMCRows(t *testing.T, ds *Datastore) {
})
}
// testInsertingHostMDMManagedCertificatesFromIngestion exercises the
// non-proxied insert path: when a profile is installed on a host without an
// existing host_mdm_managed_certificates row, an ingested cert whose Subject
// carries the `fleet-<profile_uuid>` marker creates the row. Also validates
// that the matcher's SupportsRenewalID() guard does NOT skip empty/NULL
// Type rows on subsequent ingestion (Decision 2.2 knock-on).
func testInsertingHostMDMManagedCertificatesFromIngestion(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Three profiles installed on the host:
// nonProxied — no existing hmmc row; ingestion will create one (NULL Type).
// proxied — existing hmmc row (custom_scep_proxy); matcher updates it.
// noMatch — no existing hmmc row; no incoming cert carries its marker.
cps := storeDummyConfigProfilesForTest(t, ds, 3)
nonProxiedProfileUUID := cps[0].ProfileUUID
proxiedProfileUUID := cps[1].ProfileUUID
noMatchProfileUUID := cps[2].ProfileUUID
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("ingest-host-osq"),
NodeKey: ptr.String("ingest-host-nk"),
UUID: "ingest-host-uuid",
Hostname: "ingest-host",
})
require.NoError(t, err)
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{
{
ProfileUUID: nonProxiedProfileUUID,
ProfileIdentifier: cps[0].Identifier,
ProfileName: cps[0].Name,
HostUUID: host.UUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
CommandUUID: "cmd-non-proxied",
Checksum: []byte("0123456789abcdef"),
Scope: fleet.PayloadScopeSystem,
},
{
ProfileUUID: proxiedProfileUUID,
ProfileIdentifier: cps[1].Identifier,
ProfileName: cps[1].Name,
HostUUID: host.UUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
CommandUUID: "cmd-proxied",
Checksum: []byte("0123456789abcdef"),
Scope: fleet.PayloadScopeSystem,
},
{
ProfileUUID: noMatchProfileUUID,
ProfileIdentifier: cps[2].Identifier,
ProfileName: cps[2].Name,
HostUUID: host.UUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
CommandUUID: "cmd-no-match",
Checksum: []byte("0123456789abcdef"),
Scope: fleet.PayloadScopeSystem,
},
}))
// Pre-existing proxied hmmc row for proxiedProfileUUID.
require.NoError(t, ds.BulkUpsertMDMManagedCertificates(ctx, []*fleet.MDMManagedCertificate{
{
HostUUID: host.UUID,
ProfileUUID: proxiedProfileUUID,
Type: fleet.CAConfigCustomSCEPProxy,
CAName: "custom-ca",
},
}))
// Build incoming certs:
// certNonProxied — Subject CN carries marker for nonProxiedProfileUUID,
// issued by a parent so IssuerCommonName is preserved
// certProxied — Subject OU carries marker for proxiedProfileUUID
// certUnrelated — no Fleet marker
notBefore := time.Now().Add(-time.Hour).Truncate(time.Second).UTC()
notAfter := time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC()
customerCAParent := x509.Certificate{
Subject: pkix.Name{
CommonName: "Customer Hydrant ACME",
Country: []string{"US"},
Organization: []string{"Customer"},
},
SerialNumber: big.NewInt(9000),
BasicConstraintsValid: true,
IsCA: true,
NotBefore: notBefore.Add(-time.Hour),
NotAfter: notAfter.Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign,
}
certNonProxied := x509.Certificate{
Subject: pkix.Name{
CommonName: "MAC-SERIAL fleet-" + nonProxiedProfileUUID,
Country: []string{"US"},
Organization: []string{"Org Non-Proxied"},
},
SerialNumber: big.NewInt(7001),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
}
certProxied := x509.Certificate{
Subject: pkix.Name{
CommonName: "MAC-SERIAL Proxied",
Country: []string{"US"},
Organization: []string{"Org Proxied"},
OrganizationalUnit: []string{"fleet-" + proxiedProfileUUID},
},
Issuer: pkix.Name{
CommonName: "Custom SCEP Issuer",
Country: []string{"US"},
Organization: []string{"Custom"},
},
SerialNumber: big.NewInt(7002),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
}
certUnrelated := x509.Certificate{
Subject: pkix.Name{
CommonName: "Some Other Cert",
Country: []string{"US"},
Organization: []string{"Unrelated"},
},
Issuer: pkix.Name{
CommonName: "Other Issuer",
Country: []string{"US"},
Organization: []string{"Other"},
},
SerialNumber: big.NewInt(7003),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
}
payload := []*fleet.HostCertificateRecord{
generateTestHostCertificateRecordWithParent(t, host.ID, &certNonProxied, &customerCAParent),
generateTestHostCertificateRecord(t, host.ID, &certProxied),
generateTestHostCertificateRecord(t, host.ID, &certUnrelated),
}
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)
require.NoError(t, err)
var nonProxiedRow, proxiedRow *fleet.MDMManagedCertificate
for _, r := range all {
switch r.ProfileUUID {
case nonProxiedProfileUUID:
nonProxiedRow = r
case proxiedProfileUUID:
proxiedRow = r
case noMatchProfileUUID:
t.Fatalf("noMatchProfileUUID should not have an hmmc row but does: %+v", r)
}
}
require.NotNil(t, nonProxiedRow, "non-proxied profile should have a created hmmc row")
assert.Equal(t, fleet.CAConfigAssetType(""), nonProxiedRow.Type, "Type should be NULL/empty for non-proxied row")
assert.Equal(t, "non_proxied", nonProxiedRow.CAName, "CAName should be the fixed non-proxied sentinel, not derived from the cert")
require.NotNil(t, nonProxiedRow.Serial)
assert.Equal(t, fmt.Sprintf("%040s", certNonProxied.SerialNumber.Text(16)), *nonProxiedRow.Serial)
require.NotNil(t, nonProxiedRow.NotValidAfter)
assert.Equal(t, notAfter, *nonProxiedRow.NotValidAfter)
// proxiedProfileUUID — existing row updated with cert's serial / dates by the matcher.
require.NotNil(t, proxiedRow)
assert.Equal(t, fleet.CAConfigCustomSCEPProxy, proxiedRow.Type, "Existing proxied Type preserved")
require.NotNil(t, proxiedRow.Serial)
assert.Equal(t, fmt.Sprintf("%040s", certProxied.SerialNumber.Text(16)), *proxiedRow.Serial)
// Subsequent ingestion of a renewed cert for the non-proxied profile must
// advance not_valid_after — validates the matcher guard fix (Decision 2.2):
// without it, the SupportsRenewalID() skip silently excludes NULL-Type rows.
notAfter2 := notAfter.Add(48 * time.Hour)
certRenewed := certNonProxied
certRenewed.SerialNumber = big.NewInt(7011)
// NotBefore must remain in the past (matcher filters out future-valid certs)
// but later than the original so best-match-wins picks the renewed cert.
certRenewed.NotBefore = notBefore.Add(30 * time.Minute)
certRenewed.NotAfter = notAfter2
renewedPayload := []*fleet.HostCertificateRecord{
generateTestHostCertificateRecordWithParent(t, host.ID, &certRenewed, &customerCAParent),
generateTestHostCertificateRecord(t, host.ID, &certProxied),
generateTestHostCertificateRecord(t, host.ID, &certUnrelated),
}
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, renewedPayload, fleet.HostCertificateOriginOsquery))
all2, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID)
require.NoError(t, err)
var nonProxiedRow2 *fleet.MDMManagedCertificate
for _, r := range all2 {
if r.ProfileUUID == nonProxiedProfileUUID {
nonProxiedRow2 = r
}
}
require.NotNil(t, nonProxiedRow2)
require.NotNil(t, nonProxiedRow2.NotValidAfter)
assert.Equal(t, notAfter2, *nonProxiedRow2.NotValidAfter, "matcher must advance not_valid_after on NULL-Type rows")
require.NotNil(t, nonProxiedRow2.Serial)
assert.Equal(t, fmt.Sprintf("%040s", certRenewed.SerialNumber.Text(16)), *nonProxiedRow2.Serial)
assert.Equal(t, fleet.CAConfigAssetType(""), nonProxiedRow2.Type, "Type should still be NULL/empty after update")
}
func generateTestHostCertificateRecord(t *testing.T, hostID uint, template *x509.Certificate) *fleet.HostCertificateRecord {
b, _, err := GenerateTestCertBytes(template)
require.NoError(t, err)
@@ -763,8 +983,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}))
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2Cert}))
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{})
@@ -785,7 +1005,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}))
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{})
@@ -800,7 +1020,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})
err = ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}, fleet.HostCertificateOriginOsquery)
require.NoError(t, err)
// Verify host2's certificate source was updated
@@ -814,7 +1034,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}))
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{})
@@ -842,6 +1062,95 @@ func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) {
require.Equal(t, "jdoe", host1CertsMultiSource[0].Username)
}
// testUpdateHostCertificatesOriginScopedDelete verifies that each ingestion
// source only soft-deletes rows it owns: an osquery sync that omits an
// MDM-only cert must not remove that cert, and vice versa.
func testUpdateHostCertificatesOriginScopedDelete(t *testing.T, ds *Datastore) {
ctx := t.Context()
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("origin-host-osquery-id"),
NodeKey: ptr.String("origin-host-node-key"),
UUID: "origin-host-uuid",
Hostname: "origin-host",
})
require.NoError(t, err)
mkCert := func(commonName string) *fleet.HostCertificateRecord {
template := x509.Certificate{
Subject: pkix.Name{CommonName: commonName, Organization: []string{"Org"}},
Issuer: pkix.Name{CommonName: "issuer", Organization: []string{"Issuer"}},
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
KeyUsage: x509.KeyUsageDigitalSignature,
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
certBytes, _, err := GenerateTestCertBytes(&template)
require.NoError(t, err)
block, _ := pem.Decode(certBytes)
parsed, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err)
rec := fleet.NewHostCertificateRecord(host.ID, parsed)
rec.Source = fleet.SystemHostCertificate
return rec
}
osqueryOnly := mkCert("osquery-only")
mdmOnly := mkCert("mdm-only")
// Initial state: osquery reports osqueryOnly; MDM reports mdmOnly.
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID,
[]*fleet.HostCertificateRecord{osqueryOnly}, fleet.HostCertificateOriginOsquery))
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID,
[]*fleet.HostCertificateRecord{mdmOnly}, fleet.HostCertificateOriginMDM))
certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, certs, 2)
originByCN := func(certs []*fleet.HostCertificateRecord) map[string]fleet.HostCertificateOrigin {
m := make(map[string]fleet.HostCertificateOrigin, len(certs))
for _, c := range certs {
m[c.CommonName] = c.Origin
}
return m
}
require.Equal(t, map[string]fleet.HostCertificateOrigin{
"osquery-only": fleet.HostCertificateOriginOsquery,
"mdm-only": fleet.HostCertificateOriginMDM,
}, originByCN(certs))
// 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))
certs, _, err = ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, certs, 1, "mdm-only cert should survive an osquery sync that omits it")
require.Equal(t, "mdm-only", certs[0].CommonName)
require.Equal(t, fleet.HostCertificateOriginMDM, certs[0].Origin)
// 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))
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID,
[]*fleet.HostCertificateRecord{}, fleet.HostCertificateOriginMDM))
certs, _, err = ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, certs, 1, "osquery-only cert should survive an MDM sync that omits it")
require.Equal(t, "osquery-only", certs[0].CommonName)
require.Equal(t, fleet.HostCertificateOriginOsquery, certs[0].Origin)
}
// testHostCertificateWithInvalidCountryCode tests that a certificate with a country code longer than the standard 2 letters works
func testHostCertificateWithInvalidCountryCode(t *testing.T, ds *Datastore) {
ctx := t.Context()
@@ -923,7 +1232,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))
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"})
@@ -1032,7 +1341,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})
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
@@ -1115,7 +1424,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}))
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})
+1 -1
View File
@@ -9300,7 +9300,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
NotValidAfter: now.Add(365 * 24 * time.Hour),
Source: fleet.SystemHostCertificate,
Username: "test-user",
}}))
}}, fleet.HostCertificateOriginOsquery))
// create an android device from this host
deviceID := strings.ReplaceAll(uuid.NewString(), "-", "")
+46 -6
View File
@@ -1682,6 +1682,32 @@ WHERE
return dest, nil
}
func (ds *Datastore) ProfileHasACMEPayloadForCommand(ctx context.Context, hostUUID, commandUUID string) (fleet.ProfileACMECommandResult, error) {
const stmt = `
SELECT
h.id AS host_id,
h.platform AS platform,
hmap.profile_uuid AS profile_uuid,
LOCATE('com.apple.security.acme', mac.mobileconfig) > 0 AS has_acme_payload
FROM host_mdm_apple_profiles hmap
JOIN hosts h
ON h.uuid = hmap.host_uuid
JOIN mdm_apple_configuration_profiles mac
ON mac.profile_uuid = hmap.profile_uuid
WHERE hmap.command_uuid = ?
AND hmap.host_uuid = ?`
var dest fleet.ProfileACMECommandResult
err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, stmt, commandUUID, hostUUID)
if err != nil {
if err == sql.ErrNoRows {
return dest, notFound("HostMDMAppleProfile").WithMessage(fmt.Sprintf("command uuid %s not found for host uuid %s", commandUUID, hostUUID))
}
return dest, ctxerr.Wrap(ctx, err, "probe profile for ACME payload")
}
return dest, nil
}
func batchSetProfileLabelAssociationsDB(
ctx context.Context,
tx sqlx.ExtContext,
@@ -3046,7 +3072,15 @@ func (ds *Datastore) ListHostMDMManagedCertificates(ctx context.Context, hostUUI
// RenewMDMManagedCertificates marks managed certificate profiles for resend when renewal is required
func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
totalHostCertsToRenew := 0
// Iteration set: every renewable CA type plus a NULL "non-proxied" bucket.
// Non-proxied (NULL-type) rows come from cert ingestion (no Fleet-side
// proxy step → no known CA type) and need their own renewal pass.
hostCertTypesToRenew := fleet.ListCATypesWithRenewalSupport()
typeMatchers := make([]sql.NullString, 0, len(hostCertTypesToRenew)+1)
for _, t := range hostCertTypesToRenew {
typeMatchers = append(typeMatchers, sql.NullString{String: string(t), Valid: true})
}
typeMatchers = append(typeMatchers, sql.NullString{Valid: false})
// Map is used to take advantage of Go map iteration order randomization so that
// if a customer is issuing certs across multiple platforms we will not bias renewals
// toward a specific platform
@@ -3054,7 +3088,11 @@ func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
"apple": "host_mdm_apple_profiles",
"windows": "host_mdm_windows_profiles",
}
for _, hostCertType := range hostCertTypesToRenew {
for _, typeMatcher := range typeMatchers {
hostCertType := typeMatcher.String
if !typeMatcher.Valid {
hostCertType = "non_proxied"
}
// Limit to 1000 renewals per CA type per run across all platforms
limit := 1000
for hostPlatform, table := range hostProfileTables {
@@ -3075,12 +3113,14 @@ func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
NotValidAfter time.Time `db:"not_valid_after"`
ValidityPeriod int `db:"validity_period"`
}{}
// Fetch all MDM Managed certificates of the given type that aren't already queued for
// resend(hmap.status=null) and which
// Fetch all MDM Managed certificates of the given type (or NULL for
// non-proxied) that aren't already queued for resend (hmap.status=null) and which
// * Have a validity period > 30 days and are expiring in the next 30 days
// * Have a validity period <= 30 days and are within half the validity period of expiration
// nb: we SELECT not_valid_after and validity_period here so we can use them in the HAVING clause, but
// we don't actually need them for the update logic.
// we don't actually need them for the update logic. The `<=>` operator
// is null-safe equal: matches non-NULL values like `=` and matches NULL
// when both sides are NULL.
err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostCertsToRenew, `
SELECT
hmmc.host_uuid,
@@ -3093,12 +3133,12 @@ func (ds *Datastore) RenewMDMManagedCertificates(ctx context.Context) error {
`+table+` hp
ON hmmc.host_uuid = hp.host_uuid AND hmmc.profile_uuid = hp.profile_uuid
WHERE
hmmc.type = ? AND hp.status IS NOT NULL AND hp.operation_type = ?
hmmc.type <=> ? AND hp.status IS NOT NULL AND hp.operation_type = ?
HAVING
validity_period IS NOT NULL AND
((validity_period > 30 AND not_valid_after < DATE_ADD(NOW(), INTERVAL 30 DAY)) OR
(validity_period <= 30 AND not_valid_after < DATE_ADD(NOW(), INTERVAL validity_period/2 DAY)))
LIMIT ?`, hostCertType, fleet.MDMOperationTypeInstall, limit)
LIMIT ?`, typeMatcher, fleet.MDMOperationTypeInstall, limit)
if err != nil {
return ctxerr.Wrap(ctx, err, "retrieving mdm managed certificates to renew")
}
+221
View File
@@ -65,6 +65,8 @@ func TestMDMShared(t *testing.T) {
{"TestListNextPendingMDMWindowsHostUUIDsCursor", testListNextPendingMDMWindowsHostUUIDsCursor},
{"TestCleanUpMDMManagedCertificates", testCleanUpMDMManagedCertificates},
{"TestEnqueueCommandWithName", testEnqueueCommandWithName},
{"TestProfileHasACMEPayloadForCommand", testProfileHasACMEPayloadForCommand},
{"TestRenewMDMManagedCertificatesNullType", testRenewMDMManagedCertificatesNullType},
}
for _, c := range cases {
@@ -10601,3 +10603,222 @@ func testCleanUpMDMManagedCertificates(t *testing.T, ds *Datastore) {
require.Equal(t, appleProfileUUID, uid)
})
}
func testProfileHasACMEPayloadForCommand(t *testing.T, ds *Datastore) {
ctx := t.Context()
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("acme-probe-osq"),
NodeKey: ptr.String("acme-probe-nk"),
UUID: "acme-probe-host-uuid",
Hostname: "acme-probe-host",
Platform: "darwin",
})
require.NoError(t, err)
mkProfile := func(t *testing.T, name string, mobileconfig []byte) string {
t.Helper()
teamID := uint(0)
profileUUID := uuid.NewString()
stmt := `
INSERT INTO mdm_apple_configuration_profiles
(profile_uuid, team_id, identifier, name, mobileconfig, checksum, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())`
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, stmt,
profileUUID, teamID, name, name, mobileconfig, []byte("0123456789abcdef"))
return err
})
return profileUUID
}
mkHostProfileLink := func(t *testing.T, hostUUID, profileUUID, commandUUID string) {
t.Helper()
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
ProfileUUID: profileUUID,
HostUUID: hostUUID,
Checksum: []byte("0123456789abcdef"),
Scope: fleet.PayloadScopeSystem,
OperationType: fleet.MDMOperationTypeInstall,
CommandUUID: commandUUID,
}}))
}
acmeXML := []byte(`<?xml version="1.0"?><plist><dict><key>PayloadContent</key><array><dict><key>PayloadType</key><string>com.apple.security.acme</string></dict></array></dict></plist>`)
scepXML := []byte(`<?xml version="1.0"?><plist><dict><key>PayloadContent</key><array><dict><key>PayloadType</key><string>com.apple.security.scep</string></dict></array></dict></plist>`)
t.Run("darwin host with ACME profile, no pending refetch", func(t *testing.T) {
profUUID := mkProfile(t, "acme-darwin", acmeXML)
cmdUUID := uuid.NewString()
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
got, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, cmdUUID)
require.NoError(t, err)
require.Equal(t, host.ID, got.HostID)
require.Equal(t, "darwin", got.Platform)
require.Equal(t, profUUID, got.ProfileUUID)
require.True(t, got.HasACMEPayload)
})
t.Run("darwin host with non-ACME profile reports has_acme_payload=false", func(t *testing.T) {
profUUID := mkProfile(t, "scep-darwin", scepXML)
cmdUUID := uuid.NewString()
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
got, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, cmdUUID)
require.NoError(t, err)
require.Equal(t, "darwin", got.Platform)
require.False(t, got.HasACMEPayload)
})
t.Run("unknown command returns not found", func(t *testing.T) {
_, err := ds.ProfileHasACMEPayloadForCommand(ctx, host.UUID, "no-such-command")
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
})
t.Run("unknown host returns not found", func(t *testing.T) {
profUUID := mkProfile(t, "acme-unknown-host", acmeXML)
cmdUUID := uuid.NewString()
mkHostProfileLink(t, host.UUID, profUUID, cmdUUID)
_, err := ds.ProfileHasACMEPayloadForCommand(ctx, "no-such-host", cmdUUID)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
})
}
func testRenewMDMManagedCertificatesNullType(t *testing.T, ds *Datastore) {
ctx := t.Context()
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("renew-null-osq"),
NodeKey: ptr.String("renew-null-nk"),
UUID: "renew-null-host-uuid",
Hostname: "renew-null-host",
Platform: "darwin",
})
require.NoError(t, err)
// Helper: create an Apple config profile + a host_mdm_apple_profiles row
// in 'verified' state (eligible for renewal cron resend) and an associated
// host_mdm_managed_certificates row with the given type and an expiring
// not_valid_after. Returns the profile UUID.
mkExpiringRow := func(t *testing.T, name string, certType *string, caName string) string {
t.Helper()
profileUUID := uuid.NewString()
// Insert mdm_apple_configuration_profiles row.
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO mdm_apple_configuration_profiles
(profile_uuid, team_id, identifier, name, mobileconfig, checksum, uploaded_at)
VALUES (?, 0, ?, ?, ?, ?, NOW())`,
profileUUID, name, name, []byte("dummy"), []byte("0123456789abcdef"))
return err
})
// Insert host_mdm_apple_profiles row in verified state, eligible for renewal.
require.NoError(t, ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{{
ProfileUUID: profileUUID,
ProfileIdentifier: name,
ProfileName: name,
HostUUID: host.UUID,
Status: &fleet.MDMDeliveryVerified,
OperationType: fleet.MDMOperationTypeInstall,
CommandUUID: "cmd-" + profileUUID,
Checksum: []byte("0123456789abcdef"),
Scope: fleet.PayloadScopeSystem,
}}))
// Insert host_mdm_managed_certificates row with cert that expires soon
// (within the renewal cron's 30-day threshold, validity_period > 30).
notValidBefore := time.Now().AddDate(-1, 0, 0) // 1 year ago
notValidAfter := time.Now().AddDate(0, 0, 5) // 5 days from now
serial := "0000000000000000000000000000000000000001"
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
INSERT INTO host_mdm_managed_certificates
(host_uuid, profile_uuid, ca_name, type,
not_valid_before, not_valid_after, serial)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
host.UUID, profileUUID, caName, certType, notValidBefore, notValidAfter, serial)
return err
})
return profileUUID
}
ndesStr := "ndes"
// Two rows expiring on the same schedule: one with NULL type
// (non-proxied flow) and one with type='ndes' (proxied flow).
// Both buckets must be picked up by the renewal cron.
nullProfile := mkExpiringRow(t, "null-prof", nil, "non-proxied-ca")
ndesProfile := mkExpiringRow(t, "ndes-prof", &ndesStr, "ndes-ca")
// Sanity: both start as 'verified'.
for _, profUUID := range []string{nullProfile, ndesProfile} {
var status *string
require.NoError(t, sqlx.GetContext(ctx, ds.reader(ctx), &status, `
SELECT status FROM host_mdm_apple_profiles
WHERE host_uuid = ? AND profile_uuid = ?`,
host.UUID, profUUID))
require.NotNil(t, status)
require.Equal(t, fleet.MDMDeliveryVerified, fleet.MDMDeliveryStatus(*status))
}
require.NoError(t, ds.RenewMDMManagedCertificates(ctx))
// Both should now have status=NULL (queued for resend).
for _, profUUID := range []string{nullProfile, ndesProfile} {
var status *string
require.NoError(t, sqlx.GetContext(ctx, ds.reader(ctx), &status, `
SELECT status FROM host_mdm_apple_profiles
WHERE host_uuid = ? AND profile_uuid = ?`,
host.UUID, profUUID))
require.Nil(t, status, "profile %s should be queued for resend", profUUID)
}
// Verify the read paths handle NULL `type` cleanly. The struct fields
// `MDMManagedCertificate.Type` and `HostMDMCertificateProfile.Type` are
// `CAConfigAssetType` (a string alias), not pointers. sqlx scans a NULL
// column into a string-aliased field as the empty string — no error, no
// special-case handling needed. This is the convention used throughout the
// non-proxied flow: NULL in the column == zero value in Go.
listed, err := ds.ListHostMDMManagedCertificates(ctx, host.UUID)
require.NoError(t, err, "ListHostMDMManagedCertificates must round-trip rows with NULL type")
var sawNullRow, sawNDESRow bool
for _, row := range listed {
switch row.ProfileUUID {
case nullProfile:
sawNullRow = true
require.Equal(t, fleet.CAConfigAssetType(""), row.Type,
"NULL type column should scan to empty CAConfigAssetType")
require.Equal(t, "non-proxied-ca", row.CAName)
case ndesProfile:
sawNDESRow = true
require.Equal(t, fleet.CAConfigNDES, row.Type,
"non-NULL type column should round-trip unchanged")
}
}
require.True(t, sawNullRow, "ListHostMDMManagedCertificates must return the NULL-type row")
require.True(t, sawNDESRow, "ListHostMDMManagedCertificates must return the existing ndes row")
// Same expectation via GetAppleHostMDMCertificateProfile, which returns
// HostMDMCertificateProfile (different struct, same nullable column).
nullProfileDetail, err := ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, nullProfile, "non-proxied-ca")
require.NoError(t, err)
require.NotNil(t, nullProfileDetail)
require.Equal(t, fleet.CAConfigAssetType(""), nullProfileDetail.Type,
"HostMDMCertificateProfile.Type must scan a NULL column as empty string")
ndesProfileDetail, err := ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, ndesProfile, "ndes-ca")
require.NoError(t, err)
require.NotNil(t, ndesProfileDetail)
require.Equal(t, fleet.CAConfigNDES, ndesProfileDetail.Type)
}
@@ -0,0 +1,35 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20260518124441, Down_20260518124441)
}
func Up_20260518124441(tx *sql.Tx) error {
// Allow NULL and remove the 'ndes' default on host_mdm_managed_certificates.type
// so rows created from cert ingestion (PR 2.2) — for non-proxied flows where
// Fleet isn't in the issuance path and doesn't know the CA type — can be
// inserted without forcing a misleading type value. Existing rows are
// unaffected; new INSERTs that don't specify type will get NULL instead of
// 'ndes'. All existing INSERT call sites specify type explicitly, so removing
// the default is safe.
_, err := tx.Exec(`
ALTER TABLE host_mdm_managed_certificates
MODIFY COLUMN type ENUM('digicert', 'custom_scep_proxy', 'ndes', 'smallstep')
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
NULL DEFAULT NULL
`)
if err != nil {
return errors.Wrap(err, "alter host_mdm_managed_certificates.type to allow NULL")
}
return nil
}
func Down_20260518124441(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,40 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20260518124441(t *testing.T) {
db := applyUpToPrev(t)
// Pre-existing row using a current enum value: confirms the migration
// preserves rows authored under the old NOT NULL DEFAULT 'ndes' shape.
execNoErr(t, db, `
INSERT INTO host_mdm_managed_certificates
(host_uuid, profile_uuid, ca_name, type)
VALUES (?, ?, ?, ?)`,
"host-1", "profile-1", "ca-existing", "ndes")
applyNext(t, db)
// Existing row still readable with the same value.
var existingType string
require.NoError(t, db.Get(&existingType, `
SELECT type FROM host_mdm_managed_certificates
WHERE host_uuid = 'host-1' AND profile_uuid = 'profile-1' AND ca_name = 'ca-existing'`))
require.Equal(t, "ndes", existingType)
// NULL accepted for ingestion-created rows where Fleet doesn't know the CA type.
execNoErr(t, db, `
INSERT INTO host_mdm_managed_certificates
(host_uuid, profile_uuid, ca_name)
VALUES (?, ?, ?)`,
"host-1", "profile-3", "non-proxied-ca")
var nullType *string
require.NoError(t, db.Get(&nullType, `
SELECT type FROM host_mdm_managed_certificates
WHERE host_uuid = 'host-1' AND profile_uuid = 'profile-3' AND ca_name = 'non-proxied-ca'`))
require.Nil(t, nullType, "type should be NULL when not specified after the migration")
}
@@ -0,0 +1,34 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20260518150028, Down_20260518150028)
}
func Up_20260518150028(tx *sql.Tx) error {
// Add an `origin` column tracking which ingestion source created the
// host_certificates row. This scopes deletion semantics so each ingestion
// source only soft-deletes rows it owns: an osquery sync that omits a row
// inserted via MDM `CertificateList` will not delete that row, and vice
// versa. The column is internal — not exposed in the public API.
//
// Existing rows default to 'osquery' since osquery has been the only
// ingestion source until this change.
_, err := tx.Exec(`
ALTER TABLE host_certificates
ADD COLUMN origin ENUM('osquery', 'mdm') NOT NULL DEFAULT 'osquery'
`)
if err != nil {
return errors.Wrap(err, "add origin column to host_certificates")
}
return nil
}
func Down_20260518150028(tx *sql.Tx) error {
return nil
}
@@ -0,0 +1,57 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20260518150028(t *testing.T) {
db := applyUpToPrev(t)
// Seed a host so we can reference it from host_certificates.
execNoErr(t, db, `INSERT INTO hosts (osquery_host_id, node_key, hostname, uuid, platform) VALUES (?, ?, ?, ?, ?);`,
"oh1", "nk1", "h1", "uuid-1", "darwin")
var hostID uint
require.NoError(t, db.Get(&hostID, `SELECT id FROM hosts WHERE uuid = 'uuid-1'`))
// Insert an existing host_certificates row before migration. This stands in for
// rows already present in production (origin should default to 'osquery').
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
) VALUES (?, '2027-01-01', '2026-01-01', 0, 'cn', 'rsa', 2048, 'digitalSignature',
'1', 'sha256WithRSAEncryption', '', '', '', '', '', '', '', '',
?)`,
hostID, []byte("0123456789abcdef0123"))
applyNext(t, db)
// New origin column must exist with default 'osquery' for the pre-existing row.
var origin string
require.NoError(t, db.Get(&origin, `SELECT origin FROM host_certificates WHERE host_id = ?`, hostID))
require.Equal(t, "osquery", origin)
// Insert a new row explicitly tagged origin='mdm' to confirm the enum accepts both values.
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
) VALUES (?, '2027-01-01', '2026-01-01', 0, 'cn', 'rsa', 2048, 'digitalSignature',
'2', 'sha256WithRSAEncryption', '', '', '', '', '', '', '', '',
?, 'mdm')`,
hostID, []byte("fedcba9876543210fedc"))
var origins []string
require.NoError(t, db.Select(&origins, `SELECT origin FROM host_certificates WHERE host_id = ? ORDER BY serial`, hostID))
require.Equal(t, []string{"osquery", "mdm"}, origins)
}
File diff suppressed because one or more lines are too long
+19
View File
@@ -35,6 +35,25 @@ func (t CAConfigAssetType) SupportsRenewalID() bool {
return slices.Contains(ListCATypesWithRenewalIDSupport(), t)
}
// Scan implements sql.Scanner so that a NULL value in the
// host_mdm_managed_certificates.type column (allowed for ingestion-created
// rows from non-proxied flows) scans into the zero value rather than
// producing "converting NULL to string is unsupported". Empty string is the
// canonical "type unknown" sentinel — the matcher's SupportsRenewalID guard
// and the renewal cron's null-safe equal both honor it.
func (t *CAConfigAssetType) Scan(value any) error {
if value == nil {
*t = ""
return nil
}
raw, ok := value.([]byte)
if !ok {
return fmt.Errorf("unexpected type for CAConfigAssetType: %T", value)
}
*t = CAConfigAssetType(raw)
return nil
}
type CAConfigAsset struct {
Name string `db:"name"`
Value []byte `db:"value"`
+14 -1
View File
@@ -453,7 +453,20 @@ type Datastore interface {
IsHostConnectedToFleetMDM(ctx context.Context, host *Host) (bool, error)
ListHostCertificates(ctx context.Context, hostID uint, opts ListOptions) ([]*HostCertificateRecord, *PaginationMetadata, error)
UpdateHostCertificates(ctx context.Context, hostID uint, hostUUID string, certs []*HostCertificateRecord) 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
// ProfileHasACMEPayloadForCommand returns the host/profile gating data
// needed to decide whether an InstallProfile ack should trigger a
// CertificateList refetch: host platform, profile UUID, whether the
// delivered profile contains a com.apple.security.acme payload, and
// whether a refetch is already pending. All gates are computed
// server-side in a single indexed lookup so the per-ack hot path stays
// cheap. Substring-matched on the mobileconfig blob; bounded false-
// positive risk (one redundant CertificateList per false match).
ProfileHasACMEPayloadForCommand(ctx context.Context, hostUUID, commandUUID string) (ProfileACMECommandResult, error)
// AreHostsConnectedToFleetMDM checks each host MDM enrollment with
// this server and returns a map indexed by the host uuid and a boolean
+2 -2
View File
@@ -531,8 +531,8 @@ const (
// Error message variables
var (
NDESSCEPVariablesMissingErrMsg = fmt.Sprintf("SCEP profile for NDES certificate authority requires: $FLEET_VAR_%s, $FLEET_VAR_%s, and $FLEET_VAR_%s variables.", FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarSCEPRenewalID)
SCEPRenewalIDWithoutURLChallengeErrMsg = "Variable \"$FLEET_VAR_" + string(FleetVarSCEPRenewalID) + "\" can't be used if variables for SCEP URL and Challenge are not specified."
NDESSCEPVariablesMissingErrMsg = fmt.Sprintf("SCEP profile for NDES certificate authority requires: $FLEET_VAR_%s, $FLEET_VAR_%s, and $FLEET_VAR_%s variables.", FleetVarNDESSCEPChallenge, FleetVarNDESSCEPProxyURL, FleetVarCertificateRenewalID)
SCEPRenewalIDWithoutURLChallengeErrMsg = "Variable \"$FLEET_VAR_" + string(FleetVarCertificateRenewalID) + "\" can't be used if variables for SCEP URL and Challenge are not specified."
)
const (
+17
View File
@@ -28,6 +28,19 @@ func (s HostCertificateSource) IsValid() bool {
}
}
// HostCertificateOrigin identifies the ingestion path that recorded a
// host_certificates row. It scopes deletion semantics: each ingestion source
// only soft-deletes rows it owns, so an osquery sync omitting an MDM-only cert
// does not remove that cert, and vice versa.
//
// Internal-only: not exposed in the public API.
type HostCertificateOrigin string
const (
HostCertificateOriginOsquery HostCertificateOrigin = "osquery"
HostCertificateOriginMDM HostCertificateOrigin = "mdm"
)
// HostCertificateRecord is the database model for a host certificate.
type HostCertificateRecord struct {
ID uint `json:"-" db:"id"`
@@ -65,6 +78,10 @@ type HostCertificateRecord struct {
Source HostCertificateSource `json:"-" db:"source"`
Username string `json:"-" db:"username"` // username that owns the certificate, only if source == 'user'
// Origin identifies the ingestion source (osquery vs mdm). Used internally to
// scope deletion semantics; not exposed in the public API.
Origin HostCertificateOrigin `json:"-" db:"origin"`
}
func NewHostCertificateRecord(
+26 -6
View File
@@ -75,7 +75,8 @@ const (
// Certificate authority variables
FleetVarNDESSCEPChallenge FleetVarName = "NDES_SCEP_CHALLENGE"
FleetVarNDESSCEPProxyURL FleetVarName = "NDES_SCEP_PROXY_URL"
FleetVarSCEPRenewalID FleetVarName = "SCEP_RENEWAL_ID"
FleetVarSCEPRenewalID FleetVarName = "SCEP_RENEWAL_ID" // deprecated in favor of FleetVarCertificateRenewalID, but remains for back-compat
FleetVarCertificateRenewalID FleetVarName = "CERTIFICATE_RENEWAL_ID"
FleetVarDigiCertDataPrefix FleetVarName = "DIGICERT_DATA_"
FleetVarDigiCertPasswordPrefix FleetVarName = "DIGICERT_PASSWORD_" // nolint:gosec // G101: Potential hardcoded credentials
FleetVarCustomSCEPChallengePrefix FleetVarName = "CUSTOM_SCEP_CHALLENGE_"
@@ -94,7 +95,7 @@ const (
func HasCAVariables(fleetVars []string) bool {
for _, v := range fleetVars {
if v == string(FleetVarNDESSCEPChallenge) || v == string(FleetVarNDESSCEPProxyURL) ||
v == string(FleetVarSCEPRenewalID) || v == string(FleetVarSCEPWindowsCertificateID) ||
v == string(FleetVarSCEPRenewalID) || v == string(FleetVarCertificateRenewalID) || v == string(FleetVarSCEPWindowsCertificateID) ||
strings.HasPrefix(v, string(FleetVarDigiCertDataPrefix)) || strings.HasPrefix(v, string(FleetVarDigiCertPasswordPrefix)) ||
strings.HasPrefix(v, string(FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(v, string(FleetVarCustomSCEPProxyURLPrefix)) ||
strings.HasPrefix(v, string(FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(v, string(FleetVarSmallstepSCEPProxyURLPrefix)) {
@@ -114,10 +115,17 @@ var (
FleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarNDESSCEPChallenge))
FleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarNDESSCEPProxyURL))
FleetVarHostEndUserIDPFullnameRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostEndUserIDPFullname))
FleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarSCEPRenewalID))
FleetVarHostUUIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostUUID))
FleetVarHostPlatformRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostPlatform))
FleetVarSCEPWindowsCertificateIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarSCEPWindowsCertificateID))
FleetVarCertificateRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarCertificateRenewalID))
// FleetVarRenewalIDRegexp matches either the preferred CERTIFICATE_RENEWAL_ID
// or the legacy SCEP_RENEWAL_ID name. Use this for validation checks where
// either form satisfies the requirement.
FleetVarRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(
`(\$FLEET_VAR_%[1]s)|(\${FLEET_VAR_%[1]s})|(\$FLEET_VAR_%[2]s)|(\${FLEET_VAR_%[2]s})`,
FleetVarCertificateRenewalID, FleetVarSCEPRenewalID,
))
FleetVarHostUUIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostUUID))
FleetVarHostPlatformRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarHostPlatform))
FleetVarSCEPWindowsCertificateIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, FleetVarSCEPWindowsCertificateID))
// Fleet variable replacement failed errors
HostEndUserEmailIDPVariableReplacementFailedError = fmt.Sprintf("There is no IdP email for this host. "+
@@ -311,6 +319,18 @@ type HostMDMProfileRetryCount struct {
Retries uint `db:"retries"`
}
// ProfileACMECommandResult bundles the gates needed to decide whether an
// InstallProfile ack should trigger a CertificateList refetch on macOS:
// host platform, profile UUID, and whether the delivered profile contains a
// com.apple.security.acme payload. Computed in a single query keyed on
// (host_uuid, command_uuid).
type ProfileACMECommandResult struct {
HostID uint `db:"host_id"`
Platform string `db:"platform"`
ProfileUUID string `db:"profile_uuid"`
HasACMEPayload bool `db:"has_acme_payload"`
}
// TeamIDSetter defines the method to set a TeamID value on a struct,
// which helps define authorization helpers based on teams.
type TeamIDSetter interface {
+22
View File
@@ -592,6 +592,7 @@ func TestHasCAVariables(t *testing.T) {
{"NDES challenge", []string{string(fleet.FleetVarHostUUID), string(fleet.FleetVarNDESSCEPChallenge)}, true},
{"NDES proxy URL", []string{string(fleet.FleetVarNDESSCEPProxyURL)}, true},
{"SCEP renewal", []string{string(fleet.FleetVarSCEPRenewalID)}, true},
{"Certificate renewal (preferred)", []string{string(fleet.FleetVarCertificateRenewalID)}, true},
{"DigiCert data", []string{string(fleet.FleetVarDigiCertDataPrefix) + "my_ca"}, true},
{"DigiCert password", []string{string(fleet.FleetVarDigiCertPasswordPrefix) + "my_ca"}, true},
{"Custom SCEP challenge", []string{string(fleet.FleetVarCustomSCEPChallengePrefix) + "my_ca"}, true},
@@ -610,6 +611,27 @@ func TestHasCAVariables(t *testing.T) {
}
}
func TestFleetVarRenewalIDRegexp(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"$FLEET_VAR_CERTIFICATE_RENEWAL_ID", true},
{"${FLEET_VAR_CERTIFICATE_RENEWAL_ID}", true},
{"$FLEET_VAR_SCEP_RENEWAL_ID", true},
{"${FLEET_VAR_SCEP_RENEWAL_ID}", true},
{"prefix $FLEET_VAR_CERTIFICATE_RENEWAL_ID suffix", true},
{"$FLEET_VAR_OTHER_VAR", false},
{"static-value", false},
{"", false},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
require.Equal(t, tc.want, fleet.FleetVarRenewalIDRegexp.MatchString(tc.input))
})
}
}
func TestFilterMacOSOnlyProfilesFromIOSIPadOS(t *testing.T) {
for _, tc := range []struct {
profiles []*fleet.MDMAppleProfilePayload
+27 -4
View File
@@ -16,10 +16,17 @@ import (
const (
// FleetFileVaultPayloadIdentifier is the value for the PayloadIdentifier
// used by Fleet to configure FileVault and FileVault Escrow.
FleetFileVaultPayloadIdentifier = "com.fleetdm.fleet.mdm.filevault"
FleetFileVaultPayloadType = "com.apple.MCX.FileVault2"
FleetCustomSettingsPayloadType = "com.apple.MCX"
FleetRecoveryKeyEscrowPayloadType = "com.apple.security.FDERecoveryKeyEscrow"
FleetFileVaultPayloadIdentifier = "com.fleetdm.fleet.mdm.filevault"
FleetFileVaultPayloadType = "com.apple.MCX.FileVault2"
FleetCustomSettingsPayloadType = "com.apple.MCX"
FleetRecoveryKeyEscrowPayloadType = "com.apple.security.FDERecoveryKeyEscrow"
// ACMEPayloadType is the Apple-defined PayloadType for ACME certificate
// payloads (com.apple.security.acme).
ACMEPayloadType = "com.apple.security.acme"
// SCEPPayloadType is the Apple-defined PayloadType for SCEP certificate
// payloads (com.apple.security.scep).
SCEPPayloadType = "com.apple.security.scep"
DiskEncryptionProfileRestrictionErrMsg = "Couldn't add. The configuration profile can't include FileVault settings."
// FleetdConfigPayloadIdentifier is the value for the PayloadIdentifier used
@@ -208,6 +215,22 @@ func (mc Mobileconfig) payloadSummary() ([]payloadSummary, error) {
return result, nil
}
// HasPayloadType reports whether the profile contains at least one payload
// content item with the given PayloadType. Returns an error if the profile
// cannot be parsed.
func (mc Mobileconfig) HasPayloadType(payloadType string) (bool, error) {
summaries, err := mc.payloadSummary()
if err != nil {
return false, err
}
for _, s := range summaries {
if s.Type == payloadType {
return true, nil
}
}
return false, nil
}
func (mc *Mobileconfig) ScreenPayloads(allowCustomOSUpdatesAndFileVault bool) error {
pct, err := mc.payloadSummary()
if err != nil {
@@ -34,3 +34,57 @@ func TestXMLEscapeString(t *testing.T) {
})
}
}
func TestHasPayloadType(t *testing.T) {
build := func(payloadType string) Mobileconfig {
return Mobileconfig(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>` + payloadType + `</string>
<key>PayloadIdentifier</key>
<string>com.example.profile.cert</string>
<key>PayloadDisplayName</key>
<string>Test Cert</string>
<key>PayloadUUID</key>
<string>00000000-0000-0000-0000-000000000001</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>Test Profile</string>
<key>PayloadIdentifier</key>
<string>com.example.profile</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>00000000-0000-0000-0000-000000000002</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>`)
}
t.Run("ACME profile reports ACME payload", func(t *testing.T) {
got, err := build(ACMEPayloadType).HasPayloadType(ACMEPayloadType)
require.NoError(t, err)
require.True(t, got)
})
t.Run("SCEP profile does not report ACME payload", func(t *testing.T) {
got, err := build(SCEPPayloadType).HasPayloadType(ACMEPayloadType)
require.NoError(t, err)
require.False(t, got)
})
t.Run("SCEP profile reports SCEP payload", func(t *testing.T) {
got, err := build(SCEPPayloadType).HasPayloadType(SCEPPayloadType)
require.NoError(t, err)
require.True(t, got)
})
}
+8 -5
View File
@@ -204,7 +204,7 @@ func preprocessProfileContents(
// In the future we should expand variablesUpdatedAt logic to include non-CA variables as
// well
for _, fleetVar := range fleetVars {
if fleetVar == string(fleet.FleetVarSCEPRenewalID) ||
if fleetVar == string(fleet.FleetVarSCEPRenewalID) || fleetVar == string(fleet.FleetVarCertificateRenewalID) ||
fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL) || fleetVar == string(fleet.FleetVarHostUUID) ||
strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) ||
strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) ||
@@ -230,7 +230,8 @@ func preprocessProfileContents(
case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) || fleetVar == string(fleet.FleetVarHostHardwareSerial) || fleetVar == string(fleet.FleetVarHostPlatform) ||
fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) ||
fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || fleetVar == string(fleet.FleetVarSCEPRenewalID) ||
fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) ||
fleetVar == string(fleet.FleetVarSCEPRenewalID) || fleetVar == string(fleet.FleetVarCertificateRenewalID) ||
fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) || fleetVar == string(fleet.FleetVarHostUUID):
// No extra validation needed for these variables
@@ -398,10 +399,12 @@ func preprocessProfileContents(
// Insert the SCEP URL into the profile contents
hostContents = profiles.ReplaceNDESSCEPProxyURLVariable(appConfig.MDMUrl(), hostUUID, profUUID, hostContents)
case fleetVar == string(fleet.FleetVarSCEPRenewalID):
// Insert the SCEP renewal ID into the SCEP Payload CN or OU
case fleetVar == string(fleet.FleetVarSCEPRenewalID), fleetVar == string(fleet.FleetVarCertificateRenewalID):
// Insert the renewal ID into the SCEP/ACME Payload CN or OU.
// Both legacy SCEP_RENEWAL_ID and the preferred
// CERTIFICATE_RENEWAL_ID substitute to the same value.
fleetRenewalID := "fleet-" + profUUID
hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID)
hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarRenewalIDRegexp, hostContents, fleetRenewalID)
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)):
replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, hostContents)
+4 -2
View File
@@ -121,8 +121,10 @@ func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params
switch {
case fleetVar == string(fleet.FleetVarSCEPWindowsCertificateID):
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPWindowsCertificateIDRegexp, result, params.ProfileUUID)
case fleetVar == string(fleet.FleetVarSCEPRenewalID):
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPRenewalIDRegexp, result, "fleet-"+params.ProfileUUID)
case fleetVar == string(fleet.FleetVarSCEPRenewalID), fleetVar == string(fleet.FleetVarCertificateRenewalID):
// Both legacy SCEP_RENEWAL_ID and the preferred CERTIFICATE_RENEWAL_ID
// substitute to the same value.
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarRenewalIDRegexp, result, "fleet-"+params.ProfileUUID)
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)):
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix))
err := profiles.IsCustomSCEPConfigured(deps.Context, deps.CustomSCEPCAs, caName, fleetVar, func(errMsg string) error {
+15 -3
View File
@@ -345,7 +345,9 @@ 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) error
type UpdateHostCertificatesFunc func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord, origin fleet.HostCertificateOrigin) error
type ProfileHasACMEPayloadForCommandFunc func(ctx context.Context, hostUUID string, commandUUID string) (fleet.ProfileACMECommandResult, error)
type AreHostsConnectedToFleetMDMFunc func(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error)
@@ -2470,6 +2472,9 @@ type DataStore struct {
UpdateHostCertificatesFunc UpdateHostCertificatesFunc
UpdateHostCertificatesFuncInvoked bool
ProfileHasACMEPayloadForCommandFunc ProfileHasACMEPayloadForCommandFunc
ProfileHasACMEPayloadForCommandFuncInvoked bool
AreHostsConnectedToFleetMDMFunc AreHostsConnectedToFleetMDMFunc
AreHostsConnectedToFleetMDMFuncInvoked bool
@@ -6054,11 +6059,18 @@ 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) 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)
return s.UpdateHostCertificatesFunc(ctx, hostID, hostUUID, certs, origin)
}
func (s *DataStore) ProfileHasACMEPayloadForCommand(ctx context.Context, hostUUID string, commandUUID string) (fleet.ProfileACMECommandResult, error) {
s.mu.Lock()
s.ProfileHasACMEPayloadForCommandFuncInvoked = true
s.mu.Unlock()
return s.ProfileHasACMEPayloadForCommandFunc(ctx, hostUUID, commandUUID)
}
func (s *DataStore) AreHostsConnectedToFleetMDM(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) {
+74 -11
View File
@@ -71,7 +71,8 @@ const (
var fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{
fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP,
fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart,
fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname, fleet.FleetVarSCEPRenewalID,
fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname,
fleet.FleetVarSCEPRenewalID, fleet.FleetVarCertificateRenewalID,
fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform,
}
@@ -649,8 +650,8 @@ func additionalCustomSCEPValidation(contents string, customSCEPVars *CustomSCEPV
}
foundCAs = append(foundCAs, ca)
}
if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
}
if len(foundCAs) < len(customSCEPVars.CAs()) {
for _, ca := range customSCEPVars.CAs() {
@@ -702,8 +703,8 @@ func additionalSmallstepValidation(contents string, smallstepVars *SmallstepVars
}
foundCAs = append(foundCAs, ca)
}
if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
}
if len(foundCAs) < len(smallstepVars.CAs()) {
for _, ca := range smallstepVars.CAs() {
@@ -824,8 +825,8 @@ func additionalNDESValidation(contents string, ndesVars *NDESVarsFound) error {
return err
}
if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
if !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
}
// Check for the exact match on challenge and URL
@@ -4118,15 +4119,29 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ
switch requestType {
case "InstallProfile":
return nil, apple_mdm.HandleHostMDMProfileInstallResult(
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
if err := apple_mdm.HandleHostMDMProfileInstallResult(
r.Context,
svc.ds,
cmdResult.Identifier(),
cmdResult.CommandUUID,
mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status),
status,
apple_mdm.FmtErrorChain(cmdResult.ErrorChain),
svc.newActivityFn,
)
); err != nil {
return nil, err
}
// Best-effort: when an ACME profile is acknowledged on macOS, queue
// CertificateList so hardware-bound certs (invisible to osquery) get
// ingested into host_certificates. Failures here are logged but don't
// affect the ack.
if status != nil && *status == fleet.MDMDeliveryVerifying {
if err := svc.maybeQueueCertificateListForACMEProfile(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID); err != nil {
svc.logger.WarnContext(r.Context, "queue CertificateList after ACME profile install",
"err", err, "host_uuid", cmdResult.Identifier(), "command_uuid", cmdResult.CommandUUID)
}
}
return nil, nil
case "RemoveProfile":
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
detail := apple_mdm.FmtErrorChain(cmdResult.ErrorChain)
@@ -5094,7 +5109,7 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx conte
payload = append(payload, parsed)
}
if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload); 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")
}
@@ -5106,6 +5121,54 @@ func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx conte
return nil, nil
}
// maybeQueueCertificateListForACMEProfile fires a CertificateList MDM command
// after a successful InstallProfile ack on a macOS host whose profile contains
// a com.apple.security.acme payload. This populates host_certificates with
// hardware-bound ACME certs that osquery cannot see. iOS/iPadOS do not need
// this hook because IOSiPadOSRefetch already runs CertificateList on a cron.
//
// Gating happens server-side in a single indexed query
// (ProfileHasACMEPayloadForCommand): host platform and ACME payload presence.
// The hot path early-returns for the common non-ACME / non-darwin cases
// without parsing the profile or making additional roundtrips.
//
// We deliberately do NOT dedupe against an in-flight CertificateList: if a
// previous refetch is still pending when this trigger fires, that earlier
// refetch can capture state that predates the new ACME exchange completing
// on-device. Letting the new install queue its own refetch ensures the new
// cert is captured even if the earlier refetch was already in flight.
// host_mdm_commands has a (host_id, command_type) PK so duplicate INSERTs
// collapse via ON DUPLICATE KEY UPDATE, and handleRefetchCertsResults is
// safe to call on an already-removed row.
func (svc *MDMAppleCheckinAndCommandService) maybeQueueCertificateListForACMEProfile(ctx context.Context, hostUUID, commandUUID string) error {
res, err := svc.ds.ProfileHasACMEPayloadForCommand(ctx, hostUUID, commandUUID)
if err != nil {
if fleet.IsNotFound(err) {
return nil
}
return ctxerr.Wrap(ctx, err, "probe profile for ACME payload")
}
if res.Platform != "darwin" || !res.HasACMEPayload {
return nil
}
cmdUUID := uuid.NewString()
if err := svc.commander.CertificateList(ctx, []string{hostUUID}, fleet.RefetchCertsCommandUUIDPrefix+cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "enqueue CertificateList")
}
// Track after the commander call so a CertificateList enqueue failure
// doesn't leave a stale tracking row that would suppress future
// triggers. Matches the iOS/iPadOS pattern in IOSiPadOSRefetch.
if err := svc.ds.AddHostMDMCommands(ctx, []fleet.HostMDMCommand{{
HostID: res.HostID,
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
}}); err != nil {
return ctxerr.Wrap(ctx, err, "track refetch certs command")
}
return nil
}
func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix) {
// Caller should have checked this, but just in case we'll return an error.
+266 -6
View File
@@ -2645,6 +2645,9 @@ func TestMDMCommandAndReportResultsProfileHandling(t *testing.T) {
require.ElementsMatch(t, toRetry, []string{profileIdentifier})
return nil
}
ds.ProfileHasACMEPayloadForCommandFunc = func(ctx context.Context, hUUID, cmdUUID string) (fleet.ProfileACMECommandResult, error) {
return fleet.ProfileACMECommandResult{Platform: "ios"}, nil
}
_, err := svc.CommandAndReportResults(
&mdm.Request{Context: ctx},
@@ -2674,6 +2677,120 @@ func TestMDMCommandAndReportResultsProfileHandling(t *testing.T) {
}
}
// TestMaybeQueueCertificateListForACMEProfile verifies the on-demand
// CertificateList trigger fires only on macOS hosts whose acked profile
// contains an ACME payload, and that it dedups against pending refetches.
func TestMaybeQueueCertificateListForACMEProfile(t *testing.T) {
ctx := context.Background()
const (
hostUUID = "host-uuid"
commandUUID = "cmd-uuid"
profileUUID = "profile-uuid"
hostID = uint(42)
)
cases := []struct {
name string
probeResult fleet.ProfileACMECommandResult
probeErr error
expectAddCommand bool
expectEnqueue bool
}{
{
name: "macOS + ACME profile: enqueues CertificateList",
probeResult: fleet.ProfileACMECommandResult{
HostID: hostID, Platform: "darwin", ProfileUUID: profileUUID,
HasACMEPayload: true,
},
expectAddCommand: true,
expectEnqueue: true,
},
{
name: "iOS host: skipped (existing refetch cron handles it)",
probeResult: fleet.ProfileACMECommandResult{
HostID: hostID, Platform: "ios", ProfileUUID: profileUUID,
HasACMEPayload: true,
},
expectAddCommand: false,
expectEnqueue: false,
},
{
name: "macOS + non-ACME profile: no trigger",
probeResult: fleet.ProfileACMECommandResult{
HostID: hostID, Platform: "darwin", ProfileUUID: profileUUID,
HasACMEPayload: false,
},
expectAddCommand: false,
expectEnqueue: false,
},
{
name: "command not found: no error, no trigger",
probeErr: &notFoundError{},
expectAddCommand: false,
expectEnqueue: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ds := new(mock.Store)
ds.ProfileHasACMEPayloadForCommandFunc = func(ctx context.Context, hUUID, cmdUUID string) (fleet.ProfileACMECommandResult, error) {
require.Equal(t, hostUUID, hUUID)
require.Equal(t, commandUUID, cmdUUID)
return c.probeResult, c.probeErr
}
var addedCommands []fleet.HostMDMCommand
ds.AddHostMDMCommandsFunc = func(ctx context.Context, cmds []fleet.HostMDMCommand) error {
addedCommands = append(addedCommands, cmds...)
return nil
}
mdmStorage := &mdmmock.MDMAppleStore{}
pushFactory, _ := newMockAPNSPushProviderFactory()
pusher := nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushFactory, NewNanoMDMLogger(slog.New(slog.DiscardHandler)))
cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
var enqueued bool
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
enqueued = true
require.Equal(t, []string{hostUUID}, id)
require.Equal(t, "CertificateList", cmd.Command.Command.RequestType)
return nil, nil
}
mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, ids []string) (map[string]*mdm.Push, error) {
res := make(map[string]*mdm.Push, len(ids))
for _, id := range ids {
res[id] = &mdm.Push{Token: []byte(id), Topic: "topic", PushMagic: "magic"}
}
return res, nil
}
mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
cert, err := tls.LoadX509KeyPair("testdata/server.pem", "testdata/server.key")
return &cert, "", err
}
mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) {
return false, nil
}
svc := &MDMAppleCheckinAndCommandService{
ds: ds,
logger: slog.New(slog.DiscardHandler),
commander: cmdr,
}
err := svc.maybeQueueCertificateListForACMEProfile(ctx, hostUUID, commandUUID)
require.NoError(t, err)
if c.expectAddCommand {
require.Len(t, addedCommands, 1)
require.Equal(t, fleet.RefetchCertsCommandUUIDPrefix, addedCommands[0].CommandType)
require.Equal(t, hostID, addedCommands[0].HostID)
} else {
require.Empty(t, addedCommands)
}
require.Equal(t, c.expectEnqueue, enqueued)
})
}
}
func TestMDMCommandAndReportResultsInstallApplicationAlreadyInstalled(t *testing.T) {
const (
hostUUID = "HOST-UUID-XYZ"
@@ -7364,7 +7481,7 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
profile: customSCEPForValidationWithoutRenewalID("$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_scepName", "$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_scepName",
"$FLEET_VAR_SCEP_RENEWAL_ID",
"com.apple.security.scep"),
errMsg: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
errMsg: "Variable $FLEET_VAR_CERTIFICATE_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
},
{
name: "Custom SCEP profile is not scep",
@@ -7443,7 +7560,7 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
profile: customSCEPForValidationWithoutRenewalID("$FLEET_VAR_NDES_SCEP_CHALLENGE", "$FLEET_VAR_NDES_SCEP_PROXY_URL",
"$FLEET_VAR_SCEP_RENEWAL_ID",
"com.apple.security.scep"),
errMsg: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
errMsg: "Variable $FLEET_VAR_CERTIFICATE_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
},
{
name: "NDES profile is not scep",
@@ -7464,10 +7581,13 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
errMsg: "Variable \"$FLEET_VAR_NDES_SCEP_PROXY_URL\" must be in the SCEP certificate's \"URL\" field.",
},
{
name: "SCEP renewal ID without other variables",
// Non-proxied SCEP: marker in CN won't trigger auto-renewal
// but upload is not blocked.
name: "raw SCEP with renewal-ID variable in CN uploads cleanly",
profile: customSCEPForValidation("challenge", "url",
"Name", "com.apple.security.scep"),
errMsg: fleet.SCEPRenewalIDWithoutURLChallengeErrMsg,
errMsg: "",
vars: []string{"SCEP_RENEWAL_ID"},
},
{
name: "NDES happy path",
@@ -7535,13 +7655,13 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
profile: customSCEPForValidationWithoutRenewalID("$FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_smallstepName", "$FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_smallstepName",
"$FLEET_VAR_SCEP_RENEWAL_ID",
"com.apple.security.scep"),
errMsg: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
errMsg: "Variable $FLEET_VAR_CERTIFICATE_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
},
{
name: "Smallstep renewal ID in both CN and OU",
profile: customSCEPWithOURenewalIDForValidation("${FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_smallstepName}", "${FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_smallstepName}",
"Name $FLEET_VAR_SCEP_RENEWAL_ID", "com.apple.security.scep"),
errMsg: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
errMsg: "Variable $FLEET_VAR_CERTIFICATE_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU).",
},
{
name: "Smallstep challenge is not a fleet variable",
@@ -7583,6 +7703,146 @@ func TestValidateConfigProfileFleetVariables(t *testing.T) {
}
}
// ACME and non-proxied SCEP profiles upload regardless of marker presence
// or placement; the renewal-ID variable is opt-in only.
func TestApplePayloadValidatorsAreOptional(t *testing.T) {
t.Parallel()
premiumLic := &fleet.LicenseInfo{Tier: fleet.TierPremium}
groupedCAs := &fleet.GroupedCertificateAuthorities{}
const acmeProfile = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key><string>com.apple.security.acme</string>
<key>PayloadIdentifier</key><string>com.test.acme</string>
<key>PayloadUUID</key><string>11111111-2222-3333-4444-555555555555</string>
<key>DirectoryURL</key><string>https://acme.example.com/directory</string>
<key>Subject</key>
<array>
<array><array><string>CN</string><string>device-cn</string></array></array>
<array><array><string>OU</string><string>%s</string></array></array>
</array>
</dict>
</array>
<key>PayloadIdentifier</key><string>com.test.profile.acme</string>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadUUID</key><string>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee</string>
<key>PayloadVersion</key><integer>1</integer>
</dict>
</plist>`
const rawSCEPProfile = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key><string>com.apple.security.scep</string>
<key>PayloadIdentifier</key><string>com.test.scep</string>
<key>PayloadUUID</key><string>22222222-3333-4444-5555-666666666666</string>
<key>PayloadContent</key>
<dict>
<key>Challenge</key><string>static-challenge-value</string>
<key>URL</key><string>https://scep.example.com/scep</string>
<key>Subject</key>
<array>
<array><array><string>CN</string><string>device-cn</string></array></array>
<array><array><string>OU</string><string>%s</string></array></array>
</array>
</dict>
</dict>
</array>
<key>PayloadIdentifier</key><string>com.test.profile.rawscep</string>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadUUID</key><string>bbbbbbbb-cccc-dddd-eeee-ffffffffffff</string>
<key>PayloadVersion</key><integer>1</integer>
</dict>
</plist>`
// Marker in CN, literal OU — confirms upload acceptance is independent
// of marker placement.
const acmeProfileCNMarker = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key><string>com.apple.security.acme</string>
<key>PayloadIdentifier</key><string>com.test.acme.cn</string>
<key>PayloadUUID</key><string>33333333-4444-5555-6666-777777777777</string>
<key>DirectoryURL</key><string>https://acme.example.com/directory</string>
<key>Subject</key>
<array>
<array><array><string>CN</string><string>%s</string></array></array>
<array><array><string>OU</string><string>static-ou-value</string></array></array>
</array>
</dict>
</array>
<key>PayloadIdentifier</key><string>com.test.profile.acme.cn</string>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadUUID</key><string>cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa</string>
<key>PayloadVersion</key><integer>1</integer>
</dict>
</plist>`
const rawSCEPProfileCNMarker = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key><string>com.apple.security.scep</string>
<key>PayloadIdentifier</key><string>com.test.scep.cn</string>
<key>PayloadUUID</key><string>44444444-5555-6666-7777-888888888888</string>
<key>PayloadContent</key>
<dict>
<key>Challenge</key><string>static-challenge-value</string>
<key>URL</key><string>https://scep.example.com/scep</string>
<key>Subject</key>
<array>
<array><array><string>CN</string><string>%s</string></array></array>
<array><array><string>OU</string><string>static-ou-value</string></array></array>
</array>
</dict>
</dict>
</array>
<key>PayloadIdentifier</key><string>com.test.profile.rawscep.cn</string>
<key>PayloadType</key><string>Configuration</string>
<key>PayloadUUID</key><string>dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb</string>
<key>PayloadVersion</key><integer>1</integer>
</dict>
</plist>`
cases := []struct {
name string
profile string
}{
{"ACME with preferred marker in OU", fmt.Sprintf(acmeProfile, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")},
{"ACME with legacy marker in OU", fmt.Sprintf(acmeProfile, "$FLEET_VAR_SCEP_RENEWAL_ID")},
{"ACME with no marker", fmt.Sprintf(acmeProfile, "static-ou-value")},
{"ACME with preferred marker in CN", fmt.Sprintf(acmeProfileCNMarker, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")},
{"ACME with legacy marker in CN", fmt.Sprintf(acmeProfileCNMarker, "$FLEET_VAR_SCEP_RENEWAL_ID")},
{"raw SCEP with preferred marker in OU", fmt.Sprintf(rawSCEPProfile, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")},
{"raw SCEP with legacy marker in OU", fmt.Sprintf(rawSCEPProfile, "$FLEET_VAR_SCEP_RENEWAL_ID")},
{"raw SCEP with no marker", fmt.Sprintf(rawSCEPProfile, "static-ou-value")},
{"raw SCEP with preferred marker in CN", fmt.Sprintf(rawSCEPProfileCNMarker, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")},
{"raw SCEP with legacy marker in CN", fmt.Sprintf(rawSCEPProfileCNMarker, "$FLEET_VAR_SCEP_RENEWAL_ID")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := validateConfigProfileFleetVariables(tc.profile, premiumLic, groupedCAs)
require.NoError(t, err)
})
}
}
func TestValidateDeclarationFleetVariables(t *testing.T) {
t.Parallel()
+6
View File
@@ -71,6 +71,12 @@ const conditionalAccessAppleProfileTemplate = `<?xml version="1.0" encoding="UTF
<string>{{.CertificateCN}}</string>
</array>
</array>
<array>
<array>
<string>OU</string>
<string>$FLEET_VAR_CERTIFICATE_RENEWAL_ID</string>
</array>
</array>
</array>
<key>SubjectAltName</key>
<dict>
@@ -260,6 +260,17 @@ func TestConditionalAccessGetIdPAppleProfile(t *testing.T) {
// Verify certificate CN is present in the profile
require.Contains(t, profileStr, "Fleet conditional access for Okta")
// Verify the renewal-ID marker is in the SCEP payload's Subject OU
// so auto-renewal activates by default. Substituted to
// fleet-<profile_uuid> at delivery time; Fleet's own SCEP CA
// preserves OU in the issued cert.
require.Contains(t, profileStr, "$FLEET_VAR_CERTIFICATE_RENEWAL_ID")
require.Regexp(t,
`(?s)<key>Subject</key>.*<string>OU</string>\s*<string>\$FLEET_VAR_CERTIFICATE_RENEWAL_ID</string>`,
profileStr,
"renewal-ID marker must be in Subject OU, not CN",
)
})
t.Run("missing CA certificate", func(t *testing.T) {
+1 -1
View File
@@ -15669,7 +15669,7 @@ func (s *integrationTestSuite) TestHostCertificates() {
Source: fleet.SystemHostCertificate,
})
}
require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs))
require.NoError(t, s.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery))
// list all certs
certResp = listHostCertificatesResponse{}
@@ -8384,7 +8384,7 @@ func testWindowsSCEPProfile(s *integrationMDMTestSuite, windowsScepProfile []byt
}},
http.StatusBadRequest)
errMsg = extractServerErrorText(resp.Body)
require.Contains(t, errMsg, "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables")
require.Contains(t, errMsg, "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables")
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+24 -11
View File
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/variables"
)
@@ -193,7 +194,7 @@ func (cs *CustomSCEPVarsFound) ErrorMessage() string {
}
if !cs.renewalIdFound || len(cs.challengeCA) == 0 || len(cs.urlCA) == 0 {
return fmt.Sprintf("SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_%s<CA_NAME>, $FLEET_VAR_%s<CA_NAME>, and $FLEET_VAR_%s variables.", fleet.FleetVarCustomSCEPChallengePrefix, fleet.FleetVarCustomSCEPProxyURLPrefix, fleet.FleetVarSCEPRenewalID)
return fmt.Sprintf("SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_%s<CA_NAME>, $FLEET_VAR_%s<CA_NAME>, and $FLEET_VAR_%s variables.", fleet.FleetVarCustomSCEPChallengePrefix, fleet.FleetVarCustomSCEPProxyURLPrefix, fleet.FleetVarCertificateRenewalID)
}
for ca := range cs.challengeCA {
@@ -296,7 +297,7 @@ func (cs *SmallstepVarsFound) ErrorMessage() string {
return fleet.SCEPRenewalIDWithoutURLChallengeErrMsg
}
if !cs.renewalIdFound || len(cs.challengeCA) == 0 || len(cs.urlCA) == 0 {
return fmt.Sprintf("SCEP profile for Smallstep certificate authority requires: $FLEET_VAR_%s<CA_NAME>, $FLEET_VAR_%s<CA_NAME>, and $FLEET_VAR_%s variables.", fleet.FleetVarSmallstepSCEPChallengePrefix, fleet.FleetVarSmallstepSCEPProxyURLPrefix, fleet.FleetVarSCEPRenewalID)
return fmt.Sprintf("SCEP profile for Smallstep certificate authority requires: $FLEET_VAR_%s<CA_NAME>, $FLEET_VAR_%s<CA_NAME>, and $FLEET_VAR_%s variables.", fleet.FleetVarSmallstepSCEPChallengePrefix, fleet.FleetVarSmallstepSCEPProxyURLPrefix, fleet.FleetVarCertificateRenewalID)
}
for ca := range cs.challengeCA {
if _, ok := cs.urlCA[ca]; !ok {
@@ -454,10 +455,11 @@ func validateProfileCertificateAuthorityVariables(profileContents string, lic *f
case k == string(fleet.FleetVarNDESSCEPChallenge):
caFound = true
ndesVars, ok = ndesVars.SetChallenge()
case k == string(fleet.FleetVarSCEPRenewalID):
case k == string(fleet.FleetVarSCEPRenewalID), k == string(fleet.FleetVarCertificateRenewalID):
caFound = true
// This is kind of a goofy way of doing things but essentially, since custom SCEP, NDES, and Smallstep
// share the renewal ID Fleet variable, we need to set the
// Custom SCEP, NDES, and Smallstep all share the renewal-ID
// Fleet variable. The legacy SCEP_RENEWAL_ID and the preferred
// CERTIFICATE_RENEWAL_ID names are interchangeable here.
customSCEPVars, ok = customSCEPVars.SetRenewalID()
if ok {
@@ -473,9 +475,10 @@ func validateProfileCertificateAuthorityVariables(profileContents string, lic *f
return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s does not exist.", k)}
}
if k == string(fleet.FleetVarSCEPRenewalID) {
// Special message for renewal ID
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_SCEP_RENEWAL_ID must be in the SCEP certificate's organizational unit (OU)."}
if k == string(fleet.FleetVarSCEPRenewalID) || k == string(fleet.FleetVarCertificateRenewalID) {
// Special message for renewal ID — surface the preferred name
// in the error regardless of which form the user authored.
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarCertificateRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
}
return &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is already present in configuration profile.", k)}
@@ -505,9 +508,19 @@ func validateProfileCertificateAuthorityVariables(profileContents string, lic *f
if smallstepVars.RenewalOnly() {
smallstepVars = nil
}
// If only the renewal ID variable appeared without any of its associated variables, return an error. It is shared
// by the 3 CA types but is only allowed when CA vars are in use
if ndesVars == nil && smallstepVars == nil && customSCEPVars == nil {
// ACME and non-proxied SCEP profiles legitimately have only the
// renewal-ID variable; bypass the "needs URL/Challenge" error when
// such a payload is present. Windows profiles parse as no-payload
// and remain subject to the check.
hasRenewableCertPayload := false
mc := mobileconfig.Mobileconfig(profileContents)
for _, pt := range []string{mobileconfig.ACMEPayloadType, mobileconfig.SCEPPayloadType} {
if found, err := mc.HasPayloadType(pt); err == nil && found {
hasRenewableCertPayload = true
break
}
}
if ndesVars == nil && smallstepVars == nil && customSCEPVars == nil && !hasRenewableCertPayload {
return &fleet.BadRequestError{Message: fleet.SCEPRenewalIDWithoutURLChallengeErrMsg}
}
}
+17 -5
View File
@@ -91,20 +91,32 @@ func TestValidateProfileCertificateAuthorityVariables(t *testing.T) {
{
name: "Custom SCEP challenge missing",
profile: customSCEPForValidation("challenge", "$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_scepName", "Name", "com.apple.security.scep"),
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
},
{
name: "Custom SCEP url missing",
profile: customSCEPForValidation("$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_scepName", "https://bozo.com", "Name",
"com.apple.security.scep"),
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
},
{
name: "Custom SCEP renewal ID missing",
profile: strings.Replace(customSCEPForValidation("$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_scepName", "$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_scepName",
"Name",
"com.apple.security.scep"), "$FLEET_VAR_SCEP_RENEWAL_ID", "", 1),
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
errMsg: "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
},
{
// This variable was renamed but needs to still validate
// for back-compat.
name: "Custom SCEP accepts legacy $FLEET_VAR_SCEP_RENEWAL_ID",
profile: customSCEPForValidation(
"$FLEET_VAR_CUSTOM_SCEP_CHALLENGE_scepName",
"$FLEET_VAR_CUSTOM_SCEP_PROXY_URL_scepName",
"Name",
"com.apple.security.scep",
),
errMsg: "",
},
{
name: "Custom SCEP challenge and url CA names don't match",
@@ -160,13 +172,13 @@ func TestValidateProfileCertificateAuthorityVariables(t *testing.T) {
{
name: "Smallstep challenge missing",
profile: customSCEPForValidation("challenge", "$FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_smallstepName", "Name", "com.apple.security.scep"),
errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
},
{
name: "Smallstep url missing",
profile: customSCEPForValidation("$FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_smallstepName", "https://bozo.com", "Name",
"com.apple.security.scep"),
errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables.",
errMsg: "Smallstep certificate authority requires: $FLEET_VAR_SMALLSTEP_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_SMALLSTEP_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_CERTIFICATE_RENEWAL_ID variables.",
},
{
name: "Smallstep challenge and url CA names don't match",
+2 -2
View File
@@ -3531,7 +3531,7 @@ func directIngestHostCertificatesDarwin(
return nil
}
return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs)
return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery)
}
func directIngestHostCertificatesWindows(
@@ -3629,7 +3629,7 @@ func directIngestHostCertificatesWindows(
return nil
}
return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs)
return ds.UpdateHostCertificates(ctx, host.ID, host.UUID, certs, fleet.HostCertificateOriginOsquery)
}
func maybeUpdateLastRestartedAt(now time.Time, host *fleet.Host) {
+6 -3
View File
@@ -2776,9 +2776,10 @@ func TestDirectIngestHostCertificates(t *testing.T) {
"path": "/Library/Keychains/System.keychain",
}
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) 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)
require.Len(t, certs, 2)
require.Equal(t, "9c1e9c00d8120c1a9d96274d2a17c38ffa30fd31", hex.EncodeToString(certs[0].SHA1Sum))
require.Equal(t, "Cert 1 Common Name", certs[0].CommonName)
@@ -2855,7 +2856,8 @@ func TestDirectIngestHostCertificatesDarwinHexEscapes(t *testing.T) {
"path": "/Library/Keychains/System.keychain",
}
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) 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]
@@ -2938,9 +2940,10 @@ func TestDirectIngestHostCertificatesWindows(t *testing.T) {
rows := []map[string]string{c1, c2, c3, c4, c5, c6, c7}
ds.UpdateHostCertificatesFunc = func(ctx context.Context, hostID uint, hostUUID string, certs []*fleet.HostCertificateRecord) 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)
require.Len(t, certs, 3)
// We expect that the ingest function will deduplicate certs based on SHA1+username
+17 -7
View File
@@ -148,6 +148,7 @@ var fleetVarsSupportedInWindowsProfiles = []fleet.FleetVarName{
fleet.FleetVarHostHardwareSerial,
fleet.FleetVarSCEPWindowsCertificateID,
fleet.FleetVarSCEPRenewalID,
fleet.FleetVarCertificateRenewalID,
fleet.FleetVarHostEndUserIDPUsername,
fleet.FleetVarHostEndUserIDPUsernameLocalPart,
fleet.FleetVarHostEndUserIDPFullname,
@@ -158,6 +159,18 @@ var fleetVarsSupportedInWindowsProfiles = []fleet.FleetVarName{
fleet.FleetVarNDESSCEPProxyURL,
}
// subjectNameHasRenewalIDMarker reports whether a SubjectName data string
// contains the renewal-ID variable in OU=. The legacy SCEP_RENEWAL_ID name
// is accepted alongside CERTIFICATE_RENEWAL_ID for back-compat.
func subjectNameHasRenewalIDMarker(data string) bool {
for _, v := range []fleet.FleetVarName{fleet.FleetVarCertificateRenewalID, fleet.FleetVarSCEPRenewalID} {
if strings.Contains(data, "OU="+v.WithPrefix()) || strings.Contains(data, "OU="+v.WithBraces()) {
return true
}
}
return false
}
func validateWindowsProfileFleetVariables(contents string, lic *fleet.LicenseInfo, groupedCAs *fleet.GroupedCertificateAuthorities) ([]string, error) {
foundVars := variables.Find(contents)
if len(foundVars) == 0 {
@@ -283,11 +296,9 @@ func additionalNDESValidationForWindowsProfiles(contents string, ndesVars *NDESV
"Variable %q must be in the SCEP certificate's \"ServerURL\" field.", fleet.FleetVarNDESSCEPProxyURL.WithPrefix()),
}
}
if isSubjectName &&
!strings.Contains(dataContent, "OU="+fleet.FleetVarSCEPRenewalID.WithPrefix()) &&
!strings.Contains(dataContent, "OU="+fleet.FleetVarSCEPRenewalID.WithBraces()) {
if isSubjectName && !subjectNameHasRenewalIDMarker(dataContent) {
return &fleet.BadRequestError{
Message: fmt.Sprintf("SubjectName item must contain the %s variable in the OU field", fleet.FleetVarSCEPRenewalID.WithPrefix()),
Message: fmt.Sprintf("SubjectName item must contain the %s variable in the OU field", fleet.FleetVarCertificateRenewalID.WithPrefix()),
}
}
}
@@ -325,9 +336,8 @@ func additionalCustomSCEPValidationForWindowsProfiles(contents string, customSCE
return errors.New("SubjectName item is missing data")
}
if !strings.Contains(cmd.Data.Content, "OU="+fleet.FleetVarSCEPRenewalID.WithPrefix()) && !strings.Contains(cmd.Data.Content, "OU="+fleet.FleetVarSCEPRenewalID.WithBraces()) {
// Does not contain the renewal ID in any of it's two fleet var forms as the OU field
return fmt.Errorf("SubjectName item must contain the %s variable in the OU field", fleet.FleetVarSCEPRenewalID.WithPrefix())
if !subjectNameHasRenewalIDMarker(cmd.Data.Content) {
return fmt.Errorf("SubjectName item must contain the %s variable in the OU field", fleet.FleetVarCertificateRenewalID.WithPrefix())
}
}
}
+13 -1
View File
@@ -268,7 +268,19 @@ func TestAdditionalNDESValidationForWindowsProfiles(t *testing.T) {
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/ServerURL", "$FLEET_VAR_NDES_SCEP_PROXY_URL") +
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/SubjectName", "CN=test"),
wantErr: true,
errContains: "SubjectName item must contain the $FLEET_VAR_SCEP_RENEWAL_ID variable in the OU field",
errContains: "SubjectName item must contain the $FLEET_VAR_CERTIFICATE_RENEWAL_ID variable in the OU field",
},
{
name: "valid NDES profile with preferred CERTIFICATE_RENEWAL_ID",
contents: addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/Challenge", "$FLEET_VAR_NDES_SCEP_CHALLENGE") +
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/ServerURL", "$FLEET_VAR_NDES_SCEP_PROXY_URL") +
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/SubjectName", "CN=test,OU=$FLEET_VAR_CERTIFICATE_RENEWAL_ID"),
},
{
name: "valid NDES profile with preferred CERTIFICATE_RENEWAL_ID (braces syntax)",
contents: addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/Challenge", "${FLEET_VAR_NDES_SCEP_CHALLENGE}") +
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/ServerURL", "${FLEET_VAR_NDES_SCEP_PROXY_URL}") +
addItem("./Device/Vendor/MSFT/ClientCertificateInstall/SCEP/cert1/Install/SubjectName", "CN=test,OU=${FLEET_VAR_CERTIFICATE_RENEWAL_ID}"),
},
{
name: "nil ndes vars returns nil",