diff --git a/cmd/maintained-apps/validate/windows.go b/cmd/maintained-apps/validate/windows.go index 0bc45cde68f..053e45131e9 100644 --- a/cmd/maintained-apps/validate/windows.go +++ b/cmd/maintained-apps/validate/windows.go @@ -5,11 +5,13 @@ package main import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "os" "os/exec" "path/filepath" + "regexp" "strings" "time" @@ -20,6 +22,18 @@ import ( var preInstalled = []string{} +// sqlIdentifierAllowlist restricts identifiers interpolated into SQL LIKE +// queries to a safe, narrow character set as defense-in-depth on top of +// validateSqlInput's blocklist-style checks. +var sqlIdentifierAllowlist = regexp.MustCompile(`^[a-zA-Z0-9 ._:()/\\+-]*$`) + +func validateSqlIdentifierStrict(input string) error { + if !sqlIdentifierAllowlist.MatchString(input) { + return fmt.Errorf("contains disallowed characters") + } + return nil +} + func postApplicationInstall(_ context.Context, _ *slog.Logger, _ string) error { return nil } @@ -46,9 +60,15 @@ func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueIdentifi if err := validateSqlInput(appName); err != nil { return false, fmt.Errorf("Invalid character found in appName: '%w'. Not executing query...", err) } + if err := validateSqlIdentifierStrict(appName); err != nil { + return false, fmt.Errorf("Invalid character found in appName: '%w'. Not executing query...", err) + } if err := validateSqlInput(appPath); err != nil { return false, fmt.Errorf("Invalid character found in appPath: '%w'. Not executing query...", err) } + if err := validateSqlIdentifierStrict(appPath); err != nil { + return false, fmt.Errorf("Invalid character found in appPath: '%w'. Not executing query...", err) + } logger.InfoContext(ctx, fmt.Sprintf("Looking for app: %s, version: %s", appName, appVersion)) query := ` @@ -65,6 +85,9 @@ func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueIdentifi if err := validateSqlInput(uniqueIdentifier); err != nil { return false, fmt.Errorf("Invalid character found in uniqueIdentifier: '%w'. Not executing query...", err) } + if err := validateSqlIdentifierStrict(uniqueIdentifier); err != nil { + return false, fmt.Errorf("Invalid character found in uniqueIdentifier: '%w'. Not executing query...", err) + } query += ` OR LOWER(name) LIKE LOWER('%` + uniqueIdentifier + `%')` } if appPath != "" { @@ -294,7 +317,8 @@ func executeScript(cfg *Config, scriptContents string) (string, error) { // (pkgscripts.MaxHostSoftwareInstallExecutionTime); 10 minutes is a // reasonable validator cap that covers large-payload installers without // letting a hung script run indefinitely. - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + const scriptTimeout = 10 * time.Minute + ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout) defer cancel() // Use custom execution with non-interactive flags for Windows @@ -320,6 +344,9 @@ func executeScript(cfg *Config, scriptContents string) (string, error) { --------------------`, string(output)) if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return result, fmt.Errorf("script execution exceeded the %s validator timeout (this is the validator's own cap, not the script's logic): %w", scriptTimeout, err) + } return result, err } if exitCode != 0 { diff --git a/cmd/osquery-perf/software-library/tools/import-data/main.go b/cmd/osquery-perf/software-library/tools/import-data/main.go index 3db689d8c79..fb4e1d4b886 100644 --- a/cmd/osquery-perf/software-library/tools/import-data/main.go +++ b/cmd/osquery-perf/software-library/tools/import-data/main.go @@ -12,7 +12,7 @@ import ( "regexp" "strings" - _ "github.com/mattn/go-sqlite3" + "github.com/mattn/go-sqlite3" ) // SoftwareEntry represents a software item from server data @@ -46,7 +46,7 @@ var knownPublicSoftware = []string{ // - 172.16.0.0/12 (172.16.x.x - 172.31.x.x) // - 192.168.0.0/16 (192.168.x.x) // - 127.0.0.0/8 (127.x.x.x - loopback) -var privateIPRegex = regexp.MustCompile(`^(10(\.\d{1,3}){3}|127(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2[0-9]|3[0-1])(\.\d{1,3}){2})`) +var privateIPRegex = regexp.MustCompile(`^(10(\.\d{1,3}){3}|127(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2[0-9]|3[0-1])(\.\d{1,3}){2})$`) type ImportStats struct { Total int @@ -246,7 +246,8 @@ func (imp *Importer) importEntry(entry SoftwareEntry) { if !imp.dryRun { err := imp.insertSoftware(entry) if err != nil { - if strings.Contains(err.Error(), "UNIQUE constraint failed") { + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { imp.stats.Duplicates++ if imp.verbose { fmt.Printf(" ⏭️ Duplicate: %s v%s\n", entry.Name, entry.Version) diff --git a/ee/maintained-apps/inputs/homebrew/scripts/expressvpn-install.sh b/ee/maintained-apps/inputs/homebrew/scripts/expressvpn-install.sh index f7e7d755e75..d2d0ec80d09 100755 --- a/ee/maintained-apps/inputs/homebrew/scripts/expressvpn-install.sh +++ b/ee/maintained-apps/inputs/homebrew/scripts/expressvpn-install.sh @@ -4,6 +4,11 @@ APPDIR="/Applications/" TMPDIR=$(mktemp -d) +cleanup() { + rm -rf "$TMPDIR" +} +trap cleanup EXIT + # functions quit_application() { @@ -16,8 +21,8 @@ quit_application() { fi local console_user - console_user=$(stat -f "%Su" /dev/console) - if [[ $EUID -eq 0 && "$console_user" == "root" ]]; then + console_user=$(stat -f "%Su" /dev/console 2>/dev/null || echo "") + if [[ -z "$console_user" || "$console_user" == "root" || "$console_user" == "loginwindow" ]]; then echo "Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'." return fi @@ -83,3 +88,4 @@ if [ $EXIT_CODE -ne 0 ]; then exit $EXIT_CODE fi + diff --git a/ee/tools/puppet/fleetdm/lib/puppet/util/fleet_client.rb b/ee/tools/puppet/fleetdm/lib/puppet/util/fleet_client.rb index 1eb10295433..8b0df7996da 100644 --- a/ee/tools/puppet/fleetdm/lib/puppet/util/fleet_client.rb +++ b/ee/tools/puppet/fleetdm/lib/puppet/util/fleet_client.rb @@ -175,7 +175,7 @@ def req(method: :get, path: '', body: nil, headers: {}, cached: false, environme end end rescue => e - out['error'] = e + out['error'] = e.message end out diff --git a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx index a4048c0f181..2ada48c88bc 100644 --- a/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx +++ b/frontend/components/ActivityDetails/InstallDetails/SoftwareInstallDetailsModal/SoftwareInstallDetailsModal.tsx @@ -365,7 +365,7 @@ export const SoftwareInstallDetailsModal = ({ const overrideFailedMessageWithInstalledMessage = canOverrideFailureWithInstalled && ["failed_install", "failed_uninstall"].includes( - swInstallResult?.status || "" || "" + swInstallResult?.status || "" ); // Hide version section from pending installs or failures that aren't overridden to installed (4.82 #31663) diff --git a/frontend/hooks/useTeamIdParam.ts b/frontend/hooks/useTeamIdParam.ts index c24bb7ce9c8..b2b2e57fe1c 100644 --- a/frontend/hooks/useTeamIdParam.ts +++ b/frontend/hooks/useTeamIdParam.ts @@ -76,14 +76,28 @@ const rebuildQueryStringWithTeamId = ( parts.splice(pageIndex, 1, "page=0"); } - // Backward compat: rewrite legacy team_id= to fleet_id= - const legacyIndex = parts.findIndex((p) => p.startsWith("team_id=")); - if (legacyIndex !== -1) { - parts.splice( - legacyIndex, - 1, - parts[legacyIndex].replace("team_id=", "fleet_id=") - ); + // Backward compat: rewrite legacy team_id= to fleet_id=, but only if there + // isn't already a fleet_id= param present. If both are present, the + // fleet_id= param takes precedence and the stale legacy param is simply + // dropped below in the main fleet_id handling, avoiding a second splice + // against stale indices. + const hasFleetIdParam = parts.some((p) => p.startsWith("fleet_id=")); + if (!hasFleetIdParam) { + const legacyIndex = parts.findIndex((p) => p.startsWith("team_id=")); + if (legacyIndex !== -1) { + parts.splice( + legacyIndex, + 1, + parts[legacyIndex].replace("team_id=", "fleet_id=") + ); + } + } else { + // Drop any stray legacy team_id= params since fleet_id= is authoritative + for (let i = parts.length - 1; i >= 0; i -= 1) { + if (parts[i].startsWith("team_id=")) { + parts.splice(i, 1); + } + } } const teamIndex = parts.findIndex((p) => p.startsWith("fleet_id=")); @@ -186,13 +200,25 @@ const getUserTeams = ({ : filterUserTeamsByRole(currentUser.teams, permittedAccessByTeamRole); }; +// Name of the built-in "Workstations" fleet, as seeded/created elsewhere in +// the app (e.g. wherever the default "Workstations" team is provisioned). +// Kept as a single documented constant here since this hook is the only +// consumer of the match today; if a shared location for team-name constants +// is introduced, this should be moved there to avoid drift. +const WORKSTATIONS_TEAM_NAME = "workstations"; +// U+1F4BB PERSONAL COMPUTER emoji, optionally followed by a variation +// selector (U+FE0F), as may be produced by different emoji input methods. +const WORKSTATIONS_EMOJI_PREFIX_PATTERN = /^\u{1F4BB}\uFE0F?\s*/u; + // Prefer a fleet named "Workstations" (with or without emoji prefix), // otherwise fall back to the fleet with the lowest ID. export const preferredOrLowestIdFleet = (fleets: ITeamSummary[]) => { - const name = "workstations"; const workstations = fleets.find((t) => { - const lower = t.name.toLowerCase(); - return lower === name || lower === `\u{1F4BB} ${name}`; + const lower = t.name + .toLowerCase() + .replace(WORKSTATIONS_EMOJI_PREFIX_PATTERN, "") + .trim(); + return lower === WORKSTATIONS_TEAM_NAME; }); return workstations ?? sortBy(fleets, (t) => t.id)[0]; }; @@ -462,11 +488,17 @@ export const useTeamIdParam = ({ if (hasLegacyTeamIdParam) { // Backward compat: redirect legacy ?team_id= URLs to ?fleet_id= // Skip other reconciliation to avoid a second redirect overwriting this one. - router.replace( - pathname - .concat(search.replace(/\bteam_id=/g, "fleet_id=")) - .concat(hash || "") - ); + // If a fleet_id= param is also already present in the URL, treat it as + // authoritative and simply drop the stale legacy team_id= param instead + // of overwriting fleet_id=, so this path can never disagree with + // rebuildQueryStringWithTeamId's own legacy-param handling. + const hasFleetIdParam = /(?:^|[?&])fleet_id=/.test(search); + const newSearch = hasFleetIdParam + ? search.replace(/([?&])team_id=[^&]*(&)?/g, (_match, lead, trail) => + trail ? lead : lead === "?" ? "?" : "" + ) + : search.replace(/\bteam_id=/g, "fleet_id="); + router.replace(pathname.concat(newSearch).concat(hash || "")); } else if (isFreeTier) { // free tier should never have fleet_id param, so change to "All teams" if (query.fleet_id) { diff --git a/frontend/pages/DashboardPage/cards/Software/Software.tsx b/frontend/pages/DashboardPage/cards/Software/Software.tsx index 82660ca26c8..bf238b59c27 100644 --- a/frontend/pages/DashboardPage/cards/Software/Software.tsx +++ b/frontend/pages/DashboardPage/cards/Software/Software.tsx @@ -97,7 +97,7 @@ const Software = ({ data={(isSoftwareEnabled && software?.software) || []} isLoading={isSoftwareFetching} pageIndex={softwarePageIndex} - defaultSortHeader={SOFTWARE_DEFAULT_SORT_DIRECTION} + defaultSortHeader={SOFTWARE_DEFAULT_SORT_HEADER} defaultSortDirection={SOFTWARE_DEFAULT_SORT_DIRECTION} resultsTitle="software" emptyComponent={() => } diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx index e28108d1185..501a00a793a 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/BootstrapPackage/BootstrapPackage.tsx @@ -141,13 +141,34 @@ const BootstrapPackage = ({ const onDelete = async () => { try { await mdmAPI.deleteBootstrapPackage(currentTeamId); + } catch (error) { + console.error("Failed to delete bootstrap package:", error); + renderFlash("error", "Couldn't delete. Please try again."); + setShowDeleteBootstrapPackageModal(false); + refretchBootstrapMetadata(); + if (currentTeamId !== API_NO_TEAM_ID) { + refetchTeamConfig(); + } else { + refetchGlobalConfig(); + } + return; + } + + try { await mdmAPI.updateSetupExperienceSettings({ fleet_id: currentTeamId, macos_manual_agent_install: false, }); renderFlash("success", "Successfully deleted."); - } catch { - renderFlash("error", "Couldn't delete. Please try again."); + } catch (error) { + console.error( + "Bootstrap package deleted, but failed to update setup experience settings:", + error + ); + renderFlash( + "error", + "Bootstrap package deleted, but couldn't update settings. Please try again." + ); } finally { setShowDeleteBootstrapPackageModal(false); refretchBootstrapMetadata(); diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx index 474fe6a3066..3fdc566e6cf 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useState, useMemo } from "react"; +import React, { useCallback, useContext, useState, useMemo, useEffect } from "react"; import { isEqual } from "lodash"; import { InjectedRouter } from "react-router"; @@ -124,6 +124,14 @@ const InstallSoftwareForm = ({ initialSelectedSoftware ); + // Keep local selection in sync with the latest server-side data whenever + // softwareTitles changes (e.g. after a refetch following a save), so that + // stale local selections don't diverge from the freshly fetched truth. + useEffect(() => { + setSelectedSoftwareIds(initialSelectedSoftware); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [softwareTitles]); + const installSoftwareDuringSetupCount = selectedSoftwareIds.length; const onChangeSoftwareSelect = useCallback((select: boolean, id: number) => { @@ -158,6 +166,7 @@ const InstallSoftwareForm = ({ const errorNotifications: INotification[] = []; let hadSuccess = false; + let softwareUpdateFailed = false; // 1. Software selection update if (shouldUpdateSoftware) { @@ -170,6 +179,7 @@ const InstallSoftwareForm = ({ hadSuccess = true; // Still let parent refetch even if the macOS call later fails } catch (e) { + softwareUpdateFailed = true; errorNotifications.push({ id: "update-software", alertType: "error", @@ -216,7 +226,14 @@ const InstallSoftwareForm = ({ renderFlash("success", "Successfully updated."); } - refetchSoftwareTitles(); + // Only refetch (which resyncs local selection state from server data via + // the effect above) if the software-selection save didn't fail. If it + // failed, refetching would discard the user's unsaved local selection + // and replace it with stale server data without any indication that the + // save didn't happen. + if (!softwareUpdateFailed) { + refetchSoftwareTitles(); + } setIsSaving(false); }; diff --git a/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/helpers.tests.ts b/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/helpers.tests.ts index 7e2848753b7..1368d694ae3 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/helpers.tests.ts +++ b/frontend/pages/admin/IntegrationsPage/cards/IdentityProviders/components/EndUserAuthSection/helpers.tests.ts @@ -79,23 +79,25 @@ describe("IdPSection helpers", () => { ).toBe(true); // metadata or metadata_url must be present }); - expect( - isMissingAnyRequiredField({ - entity_id: "entityId", - idp_name: "idpImageUrl", - metadata: "", - metadata_url: "metadataUrl", - }) - ).toBe(false); // metadata is not required if metadata_url is present + it("returns false if metadata or metadata_url is present", () => { + expect( + isMissingAnyRequiredField({ + entity_id: "entityId", + idp_name: "idpImageUrl", + metadata: "", + metadata_url: "metadataUrl", + }) + ).toBe(false); // metadata is not required if metadata_url is present - expect( - isMissingAnyRequiredField({ - entity_id: "entityId", - idp_name: "idpImageUrl", - metadata: "metadata", - metadata_url: "", - }) - ).toBe(false); // metadata_url is not required if metadata is present + expect( + isMissingAnyRequiredField({ + entity_id: "entityId", + idp_name: "idpImageUrl", + metadata: "metadata", + metadata_url: "", + }) + ).toBe(false); // metadata_url is not required if metadata is present + }); }); describe("validateFormDataIdP", () => { @@ -213,37 +215,39 @@ describe("IdPSection helpers", () => { }); // all fields valid }); - expect( - newFormDataIdp({ - entity_id: "entityId ", - idp_name: " idpImageUrl", - issuer_uri: "issuerUri", + it("returns expected new form data for edge cases", () => { + expect( + newFormDataIdp({ + entity_id: "entityId ", + idp_name: " idpImageUrl", + issuer_uri: "issuerUri", + metadata: "metadata", + metadata_url: " https://metadataUrl.com ", + }) + ).toEqual({ + entity_id: "entityId", + idp_name: "idpImageUrl", metadata: "metadata", - metadata_url: " https://metadataUrl.com ", - }) - ).toEqual({ - entity_id: "entityId", - idp_name: "idpImageUrl", - metadata: "metadata", - metadata_url: "https://metadataUrl.com", - }); // whitespace trimmed - - expect(newFormDataIdp(undefined)).toEqual({ - entity_id: "", - idp_name: "", - metadata: "", - metadata_url: "", - }); // all fields missing - - expect( - newFormDataIdp({ + metadata_url: "https://metadataUrl.com", + }); // whitespace trimmed + + expect(newFormDataIdp(undefined)).toEqual({ + entity_id: "", + idp_name: "", + metadata: "", + metadata_url: "", + }); // all fields missing + + expect( + newFormDataIdp({ + entity_id: "entityId", + } as IEndUserAuthentication) + ).toEqual({ entity_id: "entityId", - } as IEndUserAuthentication) - ).toEqual({ - entity_id: "entityId", - idp_name: "", - metadata: "", - metadata_url: "", - }); // idp_name, metadata, metadata_url missing + idp_name: "", + metadata: "", + metadata_url: "", + }); // idp_name, metadata, metadata_url missing + }); }); }); diff --git a/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx b/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx index e15439621e4..40e7a1a69b5 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/Info/Info.tsx @@ -184,20 +184,37 @@ const Info = ({ const onInputChange = ({ name, value }: IInputFieldParseTarget) => { setFormData({ ...formData, [name]: value }); - setFormErrors({}); + setFormErrors((prevErrors) => { + const newFormData = { ...formData, [name]: value }; + const newErrs = computeFormErrorsFor(newFormData); + const updatedErrors: IOrgInfoFormErrors = { ...prevErrors }; + (Object.keys(prevErrors) as (keyof IOrgInfoFormErrors)[]).forEach( + (key) => { + if (!newErrs[key]) { + delete updatedErrors[key]; + } + } + ); + return updatedErrors; + }); }; - const computeFormErrors = (): IOrgInfoFormErrors => { + const computeFormErrorsFor = ( + data: IOrgInfoFormData + ): IOrgInfoFormErrors => { const errors: IOrgInfoFormErrors = {}; - if (!orgName) { + if (!data.orgName) { errors.org_name = "Organization name must be present"; } - if (!orgSupportURL) { + if (!data.orgSupportURL) { errors.org_support_url = `Organization support URL must be present`; } else if ( - !validUrl({ url: orgSupportURL, protocols: ["http", "https", "file"] }) + !validUrl({ + url: data.orgSupportURL, + protocols: ["http", "https", "file"], + }) ) { errors.org_support_url = "Organization support URL is not a valid URL"; } @@ -205,6 +222,10 @@ const Info = ({ return errors; }; + const computeFormErrors = (): IOrgInfoFormErrors => { + return computeFormErrorsFor(formData); + }; + const validateForm = () => { setFormErrors(computeFormErrors()); }; @@ -323,6 +344,8 @@ const Info = ({ await op(); succeededModes.push(mode); } catch (e) { + // eslint-disable-next-line no-console + console.error(`Failed to update ${mode} mode logo:`, e); failedModes.push(mode); } } diff --git a/frontend/pages/hosts/ManageHostsPage/components/CustomValueContainer/CustomValueContainer.tsx b/frontend/pages/hosts/ManageHostsPage/components/CustomValueContainer/CustomValueContainer.tsx index 27bda6d19af..329c851fe2f 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/CustomValueContainer/CustomValueContainer.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/CustomValueContainer/CustomValueContainer.tsx @@ -5,17 +5,17 @@ import Icon from "components/Icon"; const baseClass = "custom-dropdown-indicator"; -const CustomDropdownIndicator = ({ props }: ValueContainerProps | any) => { +const CustomValueContainer = (props: ValueContainerProps) => { const { children } = props; // no access to hover state here from react-select so that is done in the scss // file of LabelFilterSelect. return ( - + {children} - + ); }; -export default CustomDropdownIndicator; +export default CustomValueContainer; diff --git a/frontend/pages/hosts/details/cards/HostSoftwareLibrary/helpers.tsx b/frontend/pages/hosts/details/cards/HostSoftwareLibrary/helpers.tsx index cb4ac6c630e..44e5624d250 100644 --- a/frontend/pages/hosts/details/cards/HostSoftwareLibrary/helpers.tsx +++ b/frontend/pages/hosts/details/cards/HostSoftwareLibrary/helpers.tsx @@ -91,7 +91,7 @@ export const getUninstallErrorMessage = (e: unknown) => { "Couldn't uninstall." ); } else if (reason.startsWith("No uninstall script exists")) { - return `${UNINSTALL_SOFTWARE_ERROR_PREFIX}. An uninstall script does not exist for this package.`; + return `${UNINSTALL_SOFTWARE_ERROR_PREFIX} An uninstall script does not exist for this package.`; } return DEFAULT_UNINSTALL_ERROR_MESSAGE; diff --git a/frontend/pages/hosts/details/components/InventoryVersions/InventoryVersions.tsx b/frontend/pages/hosts/details/components/InventoryVersions/InventoryVersions.tsx index c40d3740a99..e7819685a65 100644 --- a/frontend/pages/hosts/details/components/InventoryVersions/InventoryVersions.tsx +++ b/frontend/pages/hosts/details/components/InventoryVersions/InventoryVersions.tsx @@ -114,7 +114,7 @@ const InventoryVersion = ({ ); return ( -
+
{sigInfo?.hash_sha256 && ( )}
- {installedVersions.map((installedVersion) => { + {installedVersions.map((installedVersion, index) => { return ( 0 && stringContains(s, sub) { + return true + } + } + return false +} + +func stringContains(s, sub string) bool { + return len(s) >= len(sub) && indexOf(s, sub) >= 0 +} + +func indexOf(s, sub string) int { + n := len(sub) + if n == 0 { + return 0 + } + for i := 0; i+n <= len(s); i++ { + if s[i:i+n] == sub { + return i + } + } + return -1 +} + // Create the goose_db_version table // and insert the initial 0 value into it func (c *Client) createVersionTable(db *sql.DB) error { diff --git a/server/logging/pubsub.go b/server/logging/pubsub.go index 6f5b19fedc8..f196ee854fd 100644 --- a/server/logging/pubsub.go +++ b/server/logging/pubsub.go @@ -78,9 +78,13 @@ func (w *pubSubLogWriter) Write(ctx context.Context, logs []json.RawMessage) err } if len(data)+estimateAttributeSize(attributes) > pubsub.MaxPublishRequestBytes { + truncateLen := 100 + if len(log) < truncateLen { + truncateLen = len(log) + } w.logger.InfoContext(ctx, "dropping log over 10MB PubSub limit", "size", len(data), - "log", string(log[:100])+"...", + "log", string(log[:truncateLen])+"...", ) continue } @@ -95,6 +99,9 @@ func (w *pubSubLogWriter) Write(ctx context.Context, logs []json.RawMessage) err // Wait for each message to be pushed to the server for _, result := range results { + if result == nil { + continue + } _, err := result.Get(ctx) if err != nil { return ctxerr.Wrap(ctx, err, "pubsub publish") diff --git a/server/mdm/nanomdm/http/mdm/mdm.go b/server/mdm/nanomdm/http/mdm/mdm.go index f6f620bf67c..c3c113290e2 100644 --- a/server/mdm/nanomdm/http/mdm/mdm.go +++ b/server/mdm/nanomdm/http/mdm/mdm.go @@ -57,6 +57,7 @@ func CheckinHandler(svc service.Checkin, logger log.Logger) http.HandlerFunc { httpStatus = statusErr.Status } http.Error(w, http.StatusText(httpStatus), httpStatus) + return } _, _ = w.Write(respBytes) } @@ -86,6 +87,7 @@ func CommandAndReportResultsHandler(svc service.CommandAndReportResults, logger httpStatus = statusErr.Status } http.Error(w, http.StatusText(httpStatus), httpStatus) + return } _, _ = w.Write(respBytes) } diff --git a/server/mdm/scep/depot/file/depot.go b/server/mdm/scep/depot/file/depot.go index a4a9874f18f..81e89cb4e10 100644 --- a/server/mdm/scep/depot/file/depot.go +++ b/server/mdm/scep/depot/file/depot.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "io/ioutil" + "log/slog" "math/big" "os" "path/filepath" @@ -26,7 +27,7 @@ func NewFileDepot(path string) (*fileDepot, error) { fmt.Sprintf("%s/index.txt", path), os.O_RDONLY|os.O_CREATE, 0o666) if err != nil { - return nil, err + return nil, fmt.Errorf("opening index.txt: %w", err) } defer f.Close() return &fileDepot{dirPath: path}, nil @@ -41,19 +42,19 @@ type fileDepot struct { func (d *fileDepot) CA(pass []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) { caPEM, err := d.getFile("ca.pem") if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("reading ca.pem: %w", err) } cert, err := loadCert(caPEM.Data) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("loading ca certificate: %w", err) } keyPEM, err := d.getFile("ca.key") if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("reading ca.key: %w", err) } key, err := loadKey(keyPEM.Data, pass) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("loading ca key: %w", err) } return []*x509.Certificate{cert}, key, nil } @@ -100,7 +101,11 @@ func (d *fileDepot) Put(cn string, crt *x509.Certificate) error { return err } if err := d.writeDB(cn, serial, filename, crt); err != nil { - // TODO : remove certificate in case of writeDB problems + // remove the certificate file we just wrote so the on-disk PEM files + // stay in sync with index.txt; otherwise a leftover cert file with + // no corresponding index entry will desync the depot and cause a + // subsequent Put() with the same cn/serial to fail with "file exists". + os.Remove(filepath) return err } @@ -115,19 +120,19 @@ func (d *fileDepot) Serial() (*big.Int, error) { if err := d.check("serial"); err != nil { // assuming it doesnt exist, create if err := d.writeSerial(s); err != nil { - return nil, err + return nil, fmt.Errorf("writing serial: %w", err) } return s, nil } file, err := os.Open(name) if err != nil { - return nil, err + return nil, fmt.Errorf("opening serial file: %w", err) } defer file.Close() r := bufio.NewReader(file) - data, err := r.ReadString('\r') + data, err := r.ReadString('\n') if err != nil && err != io.EOF { - return nil, err + return nil, fmt.Errorf("reading serial file: %w", err) } data = strings.TrimSuffix(data, "\r") data = strings.TrimSuffix(data, "\n") @@ -136,7 +141,7 @@ func (d *fileDepot) Serial() (*big.Int, error) { return nil, errors.New("could not convert " + data + " to serial number") } if err := d.incrementSerial(serial); err != nil { - return serial, err + return serial, fmt.Errorf("incrementing serial: %w", err) } return serial, nil } @@ -236,7 +241,7 @@ func (d *fileDepot) HasCN(_ string, allowTime int, cert *x509.Certificate, revok return false, errors.New("DN " + dn + " already exists") } if revokeOldCertificate { - fmt.Println("Revoking certificate with serial " + key + " from DB. Recreation of CRL needed.") + slog.Info("revoking certificate from DB, recreation of CRL needed", "serial", key) entries := strings.Split(value, "\t") addDB.WriteString("R\t" + entries[1] + "\t" + makeOpenSSLTime(time.Now()) + "\t" + strings.ToUpper(entries[3]) + "\t" + entries[4] + "\t" + entries[5] + "\n") } @@ -354,14 +359,17 @@ func (d *fileDepot) check(path string) error { func (d *fileDepot) getFile(path string) (*file, error) { if err := d.check(path); err != nil { - return nil, err + return nil, fmt.Errorf("checking %s: %w", path, err) } fi, err := os.Stat(d.path(path)) if err != nil { - return nil, err + return nil, fmt.Errorf("stat %s: %w", path, err) } b, err := ioutil.ReadFile(d.path(path)) - return &file{fi, b}, err + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + return &file{fi, b}, nil } func (d *fileDepot) path(name string) string { diff --git a/server/service/async/collect.go b/server/service/async/collect.go index ed4cccbb56a..5899a353e8c 100644 --- a/server/service/async/collect.go +++ b/server/service/async/collect.go @@ -106,7 +106,13 @@ func (c *collector) exec(ctx context.Context) { c.addSkipStats(failed) return } - defer conn.Do("DEL", keyLock) //nolint:errcheck + defer func() { + if _, err := conn.Do("DEL", keyLock); err != nil { + if c.errHandler != nil { + c.errHandler(c.name, err) + } + } + }() // at this point, the lock has been acquired, execute the collector handler ctx, cancel := context.WithTimeout(ctx, time.Duration(c.lockTimeout.Seconds())*time.Second) @@ -179,9 +185,11 @@ func (c *collector) nextRunAfter() time.Duration { var jitter time.Duration if c.jitterPct > 0 { maxJitter := time.Duration(c.jitterPct) * c.execInterval / time.Duration(100.0) - randDuration, err := rand.Int(rand.Reader, big.NewInt(int64(maxJitter))) - if err == nil { - jitter = time.Duration(randDuration.Int64()) + if maxJitter > 0 { + randDuration, err := rand.Int(rand.Reader, big.NewInt(int64(maxJitter))) + if err == nil { + jitter = time.Duration(randDuration.Int64()) + } } } diff --git a/server/service/embedded_scripts/delete-duplicate-scep-certificates.sh b/server/service/embedded_scripts/delete-duplicate-scep-certificates.sh index 5df7ceeca84..51b8ecaa256 100755 --- a/server/service/embedded_scripts/delete-duplicate-scep-certificates.sh +++ b/server/service/embedded_scripts/delete-duplicate-scep-certificates.sh @@ -59,6 +59,14 @@ trap 'rm -f "$tmpfile" "$tmpfile.raw" "$tmpfile.err"' EXIT security find-certificate -a -c "$CN" -Z -p "$KEYCHAIN" >"$tmpfile.raw" 2>"$tmpfile.err" || true +begin_count=$(grep -c -- "-----BEGIN CERTIFICATE-----" "$tmpfile.raw" || true) +end_count=$(grep -c -- "-----END CERTIFICATE-----" "$tmpfile.raw" || true) +hash_count=$(grep -c "^SHA-1 hash:" "$tmpfile.raw" || true) +if [ "$begin_count" -ne "$end_count" ] || [ "$begin_count" -ne "$hash_count" ]; then + echo "Error: unexpected output from 'security find-certificate' (found $hash_count hash(es), $begin_count BEGIN marker(s), $end_count END marker(s)); refusing to proceed to avoid deleting the wrong identity." >&2 + exit 1 +fi + # Split the raw output into individual cert blocks and extract hash + date. current_hash="" current_pem="" @@ -66,6 +74,10 @@ while IFS= read -r line; do case "$line" in "SHA-1 hash:"*) current_hash=$(echo "$line" | awk '{print $NF}') + if ! echo "$current_hash" | grep -Eq '^[0-9A-Fa-f]{40}$'; then + echo "Error: malformed SHA-1 hash encountered (\"$current_hash\"); refusing to proceed." >&2 + exit 1 + fi ;; "-----BEGIN CERTIFICATE-----") current_pem="$line"$'\n' @@ -73,7 +85,11 @@ while IFS= read -r line; do "-----END CERTIFICATE-----") current_pem+="$line"$'\n' not_before=$(echo "$current_pem" | openssl x509 -noout -startdate 2>/dev/null | cut -d= -f2) - epoch=$(date -j -f "%b %e %T %Y %Z" "$not_before" "+%s" 2>/dev/null || echo "0") + epoch=$(date -j -f "%b %e %T %Y %Z" "$not_before" "+%s" 2>/dev/null || echo "") + if [ -z "$epoch" ]; then + echo "Error: failed to parse Not Before date (\"$not_before\") for certificate $current_hash; refusing to proceed to avoid deleting the wrong identity." >&2 + exit 1 + fi echo "$epoch $current_hash" >> "$tmpfile" current_pem="" ;; diff --git a/server/service/externalsvc/jira.go b/server/service/externalsvc/jira.go index c142c22f37f..d076de03828 100644 --- a/server/service/externalsvc/jira.go +++ b/server/service/externalsvc/jira.go @@ -3,6 +3,7 @@ package externalsvc import ( "context" "errors" + "log" "net" "net/http" "strconv" @@ -118,6 +119,11 @@ func doWithRetry(fn func() (*jira.Response, error)) error { } } + if resp == nil { + // no response received (e.g. connection error); treat as retryable + return err + } + if resp.StatusCode >= http.StatusInternalServerError { // 500+ status, can be worth retrying return err @@ -128,8 +134,11 @@ func doWithRetry(fn func() (*jira.Response, error)) error { // https://developer.atlassian.com/cloud/jira/platform/rate-limiting/ // for details. rawAfter := resp.Header.Get("Retry-After") - afterSecs, err := strconv.ParseInt(rawAfter, 10, 0) - if err == nil && (time.Duration(afterSecs)*time.Second) < maxWaitForRetryAfter { + afterSecs, parseErr := strconv.ParseInt(rawAfter, 10, 0) + if parseErr != nil { + log.Printf("jira: failed to parse Retry-After header %q: %v", rawAfter, parseErr) + } + if parseErr == nil && (time.Duration(afterSecs)*time.Second) < maxWaitForRetryAfter { // the retry-after duration is reasonable, wait for it and return a // retryable error so that we try again. time.Sleep(time.Duration(afterSecs) * time.Second) diff --git a/server/service/jitter_test.go b/server/service/jitter_test.go index 768ac58a5cc..d18d09362f6 100644 --- a/server/service/jitter_test.go +++ b/server/service/jitter_test.go @@ -35,7 +35,7 @@ func TestJitterForHost(t *testing.T) { t.Logf("min=%d \t max=%d \t variation=%d\n", minVal, maxVal, variation) // check that variation is below 1% of the total amount of hosts - require.Less(t, variation, int(float32(hostCount)/0.01)) + require.Less(t, variation, int(float32(hostCount)*0.01)) } func TestNoJitter(t *testing.T) { diff --git a/server/service/software_installers.go b/server/service/software_installers.go index f909631fc65..15002636a4a 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -55,6 +55,7 @@ type updateSoftwareInstallerRequest struct { LabelsIncludeAll []string Categories []string DisplayName *string + AutomaticInstall *bool // Configuration is the in-house app's managed app configuration as raw XML bytes (iOS / iPadOS only). nil means leave unchanged. Configuration []byte } @@ -64,8 +65,13 @@ type uploadSoftwareInstallerResponse struct { Err error `json:"error,omitempty"` } -// TODO: We parse the whole body before running svc.authz.Authorize. -// An authenticated but unauthorized user could abuse this. +// NOTE: This handler parses the whole multipart body (including a potentially +// large file upload) before svc.authz.Authorize runs. An authenticated but +// unauthorized user could abuse this to force the server to buffer/parse +// arbitrary large uploads before being rejected. This is a known +// resource-exhaustion risk that should be addressed by moving authorization +// earlier in the request pipeline (e.g. checking authz before or during body +// parsing) rather than after DecodeRequest completes. func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { decoded := updateSoftwareInstallerRequest{} @@ -153,6 +159,15 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.SelfService = &parsed } + val, ok = r.MultipartForm.Value["automatic_install"] + if ok && len(val) > 0 && val[0] != "" { + parsed, err := strconv.ParseBool(val[0]) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode automatic_install bool in multipart form: %s", err.Error())} + } + decoded.AutomaticInstall = &parsed + } + // decode labels and categories var inclAny, exclAny, inclAll, categories []string var existsInclAny, existsExclAny, existsInclAll, existsCategories bool @@ -293,8 +308,13 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return nil, fleet.ErrMissingLicense } -// TODO: We parse the whole body before running svc.authz.Authorize. -// An authenticated but unauthorized user could abuse this. +// NOTE: This handler parses the whole multipart body (including a potentially +// large file upload) before svc.authz.Authorize runs. An authenticated but +// unauthorized user could abuse this to force the server to buffer/parse +// arbitrary large uploads before being rejected. This is a known +// resource-exhaustion risk that should be addressed by moving authorization +// earlier in the request pipeline (e.g. checking authz before or during body +// parsing) rather than after DecodeRequest completes. func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { decoded := uploadSoftwareInstallerRequest{} @@ -1153,3 +1173,4 @@ func (svc *Service) GetInHouseAppPackage(ctx context.Context, titleID uint, toke return nil, fleet.ErrMissingLicense } + diff --git a/server/vulnerabilities/io/github.go b/server/vulnerabilities/io/github.go index 0dea9be7096..b03544cd91e 100644 --- a/server/vulnerabilities/io/github.go +++ b/server/vulnerabilities/io/github.go @@ -110,7 +110,7 @@ func (gh GitHubClient) MacOfficeReleaseNotes(ctx context.Context) (MetadataFileN } // Nothing found ... - return MetadataFileName{}, "", nil + return MetadataFileName{}, "", errors.New("no MacOffice release notes found") } // list iterates over the latest release in our Github NVD repo @@ -128,7 +128,7 @@ func (gh GitHubClient) list(ctx context.Context, prefix string, ctor func(fileNa &github.ListOptions{Page: 0, PerPage: 10}, ) if err != nil { - return nil, err + return nil, fmt.Errorf("list github releases for prefix %q: %w", prefix, err) } if r.StatusCode != http.StatusOK { @@ -143,7 +143,7 @@ func (gh GitHubClient) list(ctx context.Context, prefix string, ctor func(fileNa if strings.HasPrefix(name, prefix) { metadataFileName, err := ctor(name) if err != nil { - return nil, err + return nil, fmt.Errorf("build metadata file name for asset %q (prefix %q): %w", name, prefix, err) } results[metadataFileName] = e.GetBrowserDownloadURL() } diff --git a/server/vulnerabilities/osv/sync.go b/server/vulnerabilities/osv/sync.go index f9608f59220..ef3f7529912 100644 --- a/server/vulnerabilities/osv/sync.go +++ b/server/vulnerabilities/osv/sync.go @@ -9,6 +9,7 @@ import ( "time" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/rs/zerolog/log" ) const ( @@ -43,9 +44,8 @@ func Refresh( upToDateVersions := make([]string, 0, len(syncResult.Downloaded)+len(syncResult.Skipped)) upToDateVersions = append(upToDateVersions, syncResult.Downloaded...) upToDateVersions = append(upToDateVersions, syncResult.Skipped...) - err = removeOldOSVArtifacts(now, vulnPath, upToDateVersions) - if err != nil { - return syncResult.Downloaded, fmt.Errorf("warning: failed to clean up old OSV artifacts: %w", err) + if err := removeOldOSVArtifacts(now, vulnPath, upToDateVersions); err != nil { + log.Warn().Err(err).Msg("failed to clean up old OSV artifacts") } return syncResult.Downloaded, nil @@ -270,7 +270,7 @@ func RefreshRHEL( upToDateVersions = append(upToDateVersions, syncResult.Downloaded...) upToDateVersions = append(upToDateVersions, syncResult.Skipped...) if err := removeOldRHELOSVArtifacts(now, vulnPath, upToDateVersions); err != nil { - return syncResult.Downloaded, fmt.Errorf("warning: failed to clean up old RHEL OSV artifacts: %w", err) + log.Warn().Err(err).Msg("failed to clean up old RHEL OSV artifacts") } return syncResult.Downloaded, nil diff --git a/tools/github-manage/pkg/ghapi/issues.go b/tools/github-manage/pkg/ghapi/issues.go index 32e00e31791..584603c8662 100644 --- a/tools/github-manage/pkg/ghapi/issues.go +++ b/tools/github-manage/pkg/ghapi/issues.go @@ -127,14 +127,14 @@ func RemoveIssueFromProject(issueNumber int, projectID int) error { if err.Error() == fmt.Sprintf("issue #%d not found in project %d", issueNumber, projectID) { return nil } - return fmt.Errorf("failed to get project item ID: %v", err) + return fmt.Errorf("failed to get project item ID: %w", err) } // Remove the item using the project ID and item ID command := fmt.Sprintf("gh project item-delete %d --owner fleetdm --id %s", projectID, itemID) _, err = RunCommandAndReturnOutput(command) if err != nil { - return fmt.Errorf("failed to remove issue %d from project %d: %v", issueNumber, projectID, err) + return fmt.Errorf("failed to remove issue %d from project %d: %w", issueNumber, projectID, err) } return nil @@ -145,19 +145,19 @@ func SyncEstimateField(issueNumber int, sourceProjectID, targetProjectID int) er // Get the source project item to find the current estimate sourceItemID, err := GetProjectItemID(issueNumber, sourceProjectID) if err != nil { - return fmt.Errorf("failed to get source project item ID: %v", err) + return fmt.Errorf("failed to get source project item ID: %w", err) } // Get the target project item targetItemID, err := GetProjectItemID(issueNumber, targetProjectID) if err != nil { - return fmt.Errorf("failed to get target project item ID: %v", err) + return fmt.Errorf("failed to get target project item ID: %w", err) } // Get the estimate value from the source project using GraphQL sourceEstimate, err := GetProjectItemFieldValue(sourceItemID, sourceProjectID, "Estimate") if err != nil { - return fmt.Errorf("failed to get source estimate: %v", err) + return fmt.Errorf("failed to get source estimate: %w", err) } if sourceEstimate == "" || sourceEstimate == "0" { @@ -167,7 +167,7 @@ func SyncEstimateField(issueNumber int, sourceProjectID, targetProjectID int) er // Set the estimate in the target project err = SetProjectItemFieldValue(targetItemID, targetProjectID, "Estimate", sourceEstimate) if err != nil { - return fmt.Errorf("failed to set target estimate: %v", err) + return fmt.Errorf("failed to set target estimate: %w", err) } return nil @@ -178,19 +178,19 @@ func SetCurrentSprint(issueNumber int, projectID int) error { // Get the project item ID itemID, err := GetProjectItemID(issueNumber, projectID) if err != nil { - return fmt.Errorf("failed to get project item ID: %v", err) + return fmt.Errorf("failed to get project item ID: %w", err) } // Look up the sprint field ID sprintField, err := LookupProjectFieldName(projectID, "sprint") if err != nil { - return fmt.Errorf("failed to lookup sprint field: %v", err) + return fmt.Errorf("failed to lookup sprint field: %w", err) } // Use the general field setting function to set the sprint field to @current err = SetProjectItemFieldValue(itemID, projectID, sprintField.Name, "@current") if err != nil { - return fmt.Errorf("failed to set current sprint: %v", err) + return fmt.Errorf("failed to set current sprint: %w", err) } return nil @@ -201,13 +201,13 @@ func SetIssueStatus(issueNumber int, projectID int, status string) error { // Get the project item ID itemID, err := GetProjectItemID(issueNumber, projectID) if err != nil { - return fmt.Errorf("failed to get project item ID: %v", err) + return fmt.Errorf("failed to get project item ID: %w", err) } // Use the general field setting function to set the Status field err = SetProjectItemFieldValue(itemID, projectID, "Status", status) if err != nil { - return fmt.Errorf("failed to set status: %v", err) + return fmt.Errorf("failed to set status: %w", err) } return nil @@ -371,7 +371,10 @@ listLoop: for i, issue := range issues { // if we find an error, we'll drain concurrent executions and then bail - if stopError != nil { + mu.Lock() + stopped := stopError != nil + mu.Unlock() + if stopped { break } @@ -400,7 +403,9 @@ listLoop: fmt.Fprintf(os.Stderr, " ERROR\n") mu.Unlock() } + mu.Lock() stopError = err + mu.Unlock() logger.Errorf("Error checking timeline for issue #%d: %v", iss.Number, err) return } @@ -423,5 +428,9 @@ listLoop: fmt.Fprintf(os.Stderr, " done\n") } - return filteredIssues, stopError + mu.Lock() + finalErr := stopError + mu.Unlock() + + return filteredIssues, finalErr } diff --git a/tools/gitops-migrate/migrate.sh b/tools/gitops-migrate/migrate.sh index 6ee60794caa..9aa6c97f765 100755 --- a/tools/gitops-migrate/migrate.sh +++ b/tools/gitops-migrate/migrate.sh @@ -74,7 +74,6 @@ validate_yaml() { extract_keys_from_software() { local software_file="$1" local temp_file=$(mktemp -p .) - chmod 666 $temp_file # Extract the keys we need { @@ -93,7 +92,6 @@ remove_keys_from_software() { # Create a temporary file with keys removed local temp_file=$(mktemp -p .) - chmod 666 $temp_file yq eval --output-format=yaml 'del(.self_service, .categories, .labels_include_any, .labels_exclude_any)' "$software_file" > "$temp_file" # Replace the original file @@ -190,7 +188,7 @@ process_team_file() { # Clean up temp file rm -f "$keys_temp_file" - PROCESSED_PACKAGED=$((PROCESSED_PACKAGES+1)) + PROCESSED_PACKAGES=$((PROCESSED_PACKAGES+1)) echo -e "${GREEN} ✓ Package processed successfully${NC}" done @@ -333,3 +331,4 @@ main() { # Run main function with all script arguments main "$@" + diff --git a/tools/mdm/migration/mdmproxy/entrypoint.sh b/tools/mdm/migration/mdmproxy/entrypoint.sh index 8177f1c912d..347f53eb7f9 100644 --- a/tools/mdm/migration/mdmproxy/entrypoint.sh +++ b/tools/mdm/migration/mdmproxy/entrypoint.sh @@ -1,31 +1,27 @@ #!/bin/sh set -e -AUTH_TOKEN_ARG="" -MIGRATE_PERCENTAGE_ARG="" -MIGRATE_UDIDS_ARG="" if [ -z "${MDMPROXY_SERVER_ADDRESS}" ]; then MDMPROXY_SERVER_ADDRESS=":8080" fi +set -- \ + -existing-hostname "${MDMPROXY_EXISTING_HOSTNAME:?}" \ + -existing-url "${MDMPROXY_EXISTING_URL:?}" \ + -fleet-url "${MDMPROXY_FLEET_URL:?}" \ + -server-address "${MDMPROXY_SERVER_ADDRESS:?}" + if [ -n "${MDMPROXY_AUTH_TOKEN}" ]; then - AUTH_TOKEN_ARG="-auth-token \"${MDMPROXY_AUTH_TOKEN:?}\"" + set -- "$@" -auth-token "${MDMPROXY_AUTH_TOKEN:?}" fi if [ -n "${MDMPROXY_MIGRATE_PERCENTAGE}" ]; then - MIGRATE_PERCENTAGE_ARG="-migrate-percentage \"${MDMPROXY_MIGRATE_PERCENTAGE:?}\"" + set -- "$@" -migrate-percentage "${MDMPROXY_MIGRATE_PERCENTAGE:?}" fi if [ -n "${MDMPROXY_MIGRATE_UDIDS}" ]; then - MIGRATE_UDIDS_ARG="-migrate-udids \"${MDMPROXY_MIGRATE_UDIDS:?}\"" + set -- "$@" -migrate-udids "${MDMPROXY_MIGRATE_UDIDS:?}" fi -eval exec /usr/bin/mdmproxy \ - ${AUTH_TOKEN_ARG} \ - -existing-hostname "${MDMPROXY_EXISTING_HOSTNAME:?}" \ - -existing-url "${MDMPROXY_EXISTING_URL:?}" \ - -fleet-url "${MDMPROXY_FLEET_URL:?}" \ - ${MIGRATE_PERCENTAGE_ARG} \ - ${MIGRATE_UDIDS_ARG} \ - -server-address "${MDMPROXY_SERVER_ADDRESS:?}" +exec /usr/bin/mdmproxy "$@" diff --git a/tools/mdm/migration/simplemdm/main.go b/tools/mdm/migration/simplemdm/main.go index 1c4231101a3..f73f808c1cd 100644 --- a/tools/mdm/migration/simplemdm/main.go +++ b/tools/mdm/migration/simplemdm/main.go @@ -18,7 +18,6 @@ import ( const DELAY = 10 * time.Second // adjust this to simulate slow webhook response var ( - apiTokenFlag = flag.String("api-token", "", "API token") deviceIDFlag = flag.String("device-id", "", "Device ID to unenroll") ) @@ -48,7 +47,7 @@ func newSimpleClient(apiToken string) *simpleClient { // if err != nil { // return 0, err // } -// req.SetBasicAuth(*apiTokenFlag, "") +// req.SetBasicAuth(c.apiToken, "") // req.Header.Set("Content-Type", "application/json") // resp, err := client.Do(req) // if err != nil { @@ -75,7 +74,7 @@ func (c *simpleClient) unenroll(deviceID uint) error { if err != nil { return err } - req.SetBasicAuth(*apiTokenFlag, "") + req.SetBasicAuth(c.apiToken, "") req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { @@ -98,8 +97,9 @@ func (c *simpleClient) unenroll(deviceID uint) error { func main() { flag.Parse() - if *apiTokenFlag == "" { - log.Fatal("--api-token must be provided") + apiToken := os.Getenv("SIMPLEMDM_API_TOKEN") + if apiToken == "" { + log.Fatal("SIMPLEMDM_API_TOKEN environment variable must be set") } if *deviceIDFlag == "" { @@ -110,6 +110,8 @@ func main() { log.Fatalf("invalid device ID %s: %v", *deviceIDFlag, err) } + client := newSimpleClient(apiToken) + http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { var detail string body, err := io.ReadAll(request.Body) @@ -132,11 +134,15 @@ func main() { // } // TODO: Use getDeviceIDBySerial to find the device ID by serial number - // For now, we just use the device ID provided via command line flag. + // from the parsed request body above, instead of relying on the + // device ID provided via command line flag. Until that is + // implemented, this handler will always unenroll the device + // identified by --device-id, regardless of which host triggered + // the webhook. time.Sleep(DELAY) - if err := newSimpleClient(*apiTokenFlag).unenroll(uint(deviceID)); err != nil { + if err := client.unenroll(uint(deviceID)); err != nil { log.Printf("error unenrolling device %d: %s", deviceID, err.Error()) writer.WriteHeader(http.StatusBadGateway) if _, err := writer.Write([]byte("Error unenrolling device")); err != nil { diff --git a/tools/team-builder/build_teams.sh b/tools/team-builder/build_teams.sh index 68766a0264b..6c4c5aefd54 100755 --- a/tools/team-builder/build_teams.sh +++ b/tools/team-builder/build_teams.sh @@ -12,7 +12,7 @@ run(){ while getopts s:p:u:f:d:o:x flag do case "${flag}" in - f) #path to file containing team names. Must end with newline char. + s) #path to file containing team names. Must end with newline char. source=($OPTARG);; p) #types of installers to create. Pass an individual flag for each type types+=($OPTARG);; diff --git a/tools/terraform/provider/teams_resource.go b/tools/terraform/provider/teams_resource.go index 48731a4fa73..872f6d84f7b 100644 --- a/tools/terraform/provider/teams_resource.go +++ b/tools/terraform/provider/teams_resource.go @@ -131,7 +131,13 @@ func (r *teamsResource) Create(ctx context.Context, req resource.CreateRequest, resp.Diagnostics.Append(diag.NewErrorDiagnostic( "failed to convert fleet api return to TF structs", fmt.Sprintf("failed to convert fleet api return to TF structs: %s", err))) - _ = r.client.DeleteTeam(newTeam.Team.ID) // Problematic. :-/ + if delErr := r.client.DeleteTeam(newTeam.Team.ID); delErr != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "failed to clean up after failed team conversion", + fmt.Sprintf("failed to delete team %s while cleaning up "+ + "failure converting API response: %s. Team will need to be "+ + "manually deleted.", plan.Name.ValueString(), delErr))) + } return } @@ -247,6 +253,19 @@ func (r *teamsResource) Update(ctx context.Context, req resource.UpdateRequest, } } + if upTeam == nil { + // Nothing was actually updated on the Fleet side (e.g. agent options + // changed to an empty string and name/description are unchanged), so + // fetch the current team to safely populate state. + upTeam, err = r.client.GetTeam(state.Id.ValueInt64()) + if err != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "Failed to get team", + fmt.Sprintf("Failed to get team: %s", err))) + return + } + } + err = teamModelToTF(ctx, upTeam, &state) if err != nil { resp.Diagnostics.Append(diag.NewErrorDiagnostic( @@ -297,3 +316,4 @@ func (r *teamsResource) Delete(ctx context.Context, req resource.DeleteRequest, resp.State.RemoveResource(ctx) } + diff --git a/website/api/controllers/android-proxy/get-android-enterprises.js b/website/api/controllers/android-proxy/get-android-enterprises.js index 69876343b56..e17d3fc7e07 100644 --- a/website/api/controllers/android-proxy/get-android-enterprises.js +++ b/website/api/controllers/android-proxy/get-android-enterprises.js @@ -18,6 +18,7 @@ module.exports = { missingOriginHeader: { description: 'The request was missing an Origin header', responseType: 'badRequest'}, unauthorized: { description: 'Invalid authentication token.', responseType: 'unauthorized'}, notFound: { description: 'No Android enterprise found for this Fleet server.', responseType: 'notFound'}, + rateLimited: { description: 'The Android management API rate limit was exceeded.', responseType: 'tooManyRequests' }, }, @@ -46,11 +47,18 @@ module.exports = { throw 'notFound'; } - if (thisAndroidEnterprise.fleetServerSecret !== fleetServerSecret) { + let crypto = require('crypto'); + let providedSecretBuffer = Buffer.from(fleetServerSecret); + let storedSecretBuffer = Buffer.from(thisAndroidEnterprise.fleetServerSecret); + let secretsMatch = providedSecretBuffer.length === storedSecretBuffer.length && + crypto.timingSafeEqual(providedSecretBuffer, storedSecretBuffer); + + if (!secretsMatch) { throw 'unauthorized'; } // Get the Android enterprises list from Google + let isRateLimited = false; try { let enterprisesList = await sails.helpers.flow.build(async ()=>{ let { google } = require('googleapis'); @@ -93,6 +101,7 @@ module.exports = { }).intercept({status: 429}, (err)=>{ // If the Android management API returns a 429 response, log an additional warning that will trigger a help-p1 alert. sails.log.warn(`p1: Android management API rate limit exceeded!`); + isRateLimited = true; return err; }).intercept((err)=>{ // Re-throw the error for handling outside the intercept @@ -114,6 +123,9 @@ module.exports = { return { enterprises: filteredEnterprises }; } catch (err) { + if (isRateLimited) { + throw 'rateLimited'; + } throw new Error(`When attempting to list android enterprises, an error occurred. Error: ${err}`); } diff --git a/website/api/controllers/articles/view-basic-webinar.js b/website/api/controllers/articles/view-basic-webinar.js index fa3cfc2288e..43aaefd897b 100644 --- a/website/api/controllers/articles/view-basic-webinar.js +++ b/website/api/controllers/articles/view-basic-webinar.js @@ -44,11 +44,11 @@ module.exports = { let pageTitleForMeta; let pageDescriptionForMeta; - if(thisPage.meta.articleTitle) { + if(thisPage.meta && thisPage.meta.articleTitle) { pageTitleForMeta = thisPage.meta.articleTitle; } - if(thisPage.meta.description) { + if(thisPage.meta && thisPage.meta.description) { pageDescriptionForMeta = thisPage.meta.description; } diff --git a/website/api/helpers/microsoft-proxy/get-access-token-and-api-urls.js b/website/api/helpers/microsoft-proxy/get-access-token-and-api-urls.js index 17e3018a2f6..c6b189ca6e4 100644 --- a/website/api/helpers/microsoft-proxy/get-access-token-and-api-urls.js +++ b/website/api/helpers/microsoft-proxy/get-access-token-and-api-urls.js @@ -28,7 +28,7 @@ module.exports = { let informationAboutThisTenant = await MicrosoftComplianceTenant.findOne({id: complianceTenantRecordId}); if(!informationAboutThisTenant) { - return new Error(`No matching tenant record could be found with the specified ID. (${complianceTenantRecordId}`); + throw new Error(`No matching tenant record could be found with the specified ID. (${complianceTenantRecordId})`); } // Get a graph access token for this tenant @@ -109,3 +109,4 @@ module.exports = { }; + diff --git a/website/assets/js/pages/docs/vital-details.page.js b/website/assets/js/pages/docs/vital-details.page.js index 20f1a74ccb7..7d2d853626c 100644 --- a/website/assets/js/pages/docs/vital-details.page.js +++ b/website/assets/js/pages/docs/vital-details.page.js @@ -85,11 +85,13 @@ parasails.registerPage('vital-details', { })(); $('[purpose="copy-button"]').on('click', async function() { let code = $(this).closest('[purpose="codeblock"]').find('pre:visible code').text(); - $(this).addClass('copied'); - await setTimeout(()=>{ - $(this).removeClass('copied'); - }, 2000); - navigator.clipboard.writeText(code); + if(code) { + $(this).addClass('copied'); + await setTimeout(()=>{ + $(this).removeClass('copied'); + }, 2000); + navigator.clipboard.writeText(code); + } }); // Add a scroll event listener to shift the platform filters upwards when the header is hidden. window.addEventListener('scroll', this.handleScrollingPlatformFilters); @@ -148,3 +150,4 @@ parasails.registerPage('vital-details', { } }, }); + diff --git a/website/assets/resources/install-fleetctl.sh b/website/assets/resources/install-fleetctl.sh index 9d0aad5fa15..01f118b30fe 100644 --- a/website/assets/resources/install-fleetctl.sh +++ b/website/assets/resources/install-fleetctl.sh @@ -6,7 +6,7 @@ FLEETCTL_INSTALL_DIR="${HOME}/.fleetctl/" # Check for necessary commands -for cmd in curl tar grep sed; do +for cmd in curl tar grep sed shasum; do if ! command -v $cmd &> /dev/null; then echo "Error: $cmd is not installed." >&2 exit 1 @@ -20,6 +20,13 @@ echo "Fetching the latest version of fleetctl..." latest_strippedVersion=$(curl -s "https://registry.npmjs.org/fleetctl/latest" | grep -o '"version": *"[^"]*"' | cut -d'"' -f4) echo "Latest version available on NPM: $latest_strippedVersion" +# Validate that the version string looks like a semver value before using it +# in a URL or filesystem path. +if ! [[ "$latest_strippedVersion" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Unexpected version string received from NPM: '${latest_strippedVersion}'" >&2 + exit 1 +fi + version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } @@ -48,10 +55,36 @@ mkdir -p "${FLEETCTL_INSTALL_DIR}" # Construct download URL # ex: https://github.com/fleetdm/fleet/releases/download/fleet-v4.43.3/fleetctl_v4.43.3_macos.zip DOWNLOAD_URL="https://github.com/fleetdm/fleet/releases/download/fleet-v${latest_strippedVersion}/fleetctl_v${latest_strippedVersion}_${OS}.tar.gz" +CHECKSUMS_URL="https://github.com/fleetdm/fleet/releases/download/fleet-v${latest_strippedVersion}/fleetctl_v${latest_strippedVersion}.checksums.txt" -# Download the latest version of fleetctl and extract it. +# Download the latest version of fleetctl, verify its checksum, then extract it. echo "Downloading fleetctl ${latest_strippedVersion} for ${OS_DISPLAY_NAME}..." -curl -sSL "$DOWNLOAD_URL" | tar -xz -C "$FLEETCTL_INSTALL_DIR" --strip-components=1 fleetctl_v"${latest_strippedVersion}"_${OS}/ + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "${TMP_DIR}"' EXIT + +ARCHIVE_NAME="fleetctl_v${latest_strippedVersion}_${OS}.tar.gz" +ARCHIVE_PATH="${TMP_DIR}/${ARCHIVE_NAME}" +CHECKSUMS_PATH="${TMP_DIR}/fleetctl_v${latest_strippedVersion}.checksums.txt" + +curl -sSL -o "${ARCHIVE_PATH}" "$DOWNLOAD_URL" +curl -sSL -o "${CHECKSUMS_PATH}" "$CHECKSUMS_URL" + +EXPECTED_SHA=$(grep " ${ARCHIVE_NAME}\$" "${CHECKSUMS_PATH}" | awk '{print $1}') +if [[ -z "$EXPECTED_SHA" ]]; then + echo "Error: Could not find checksum for ${ARCHIVE_NAME} in checksums file." >&2 + exit 1 +fi + +ACTUAL_SHA=$(shasum -a 256 "${ARCHIVE_PATH}" | awk '{print $1}') +if [[ "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then + echo "Error: Checksum verification failed for ${ARCHIVE_NAME}." >&2 + echo "Expected: ${EXPECTED_SHA}" >&2 + echo "Actual: ${ACTUAL_SHA}" >&2 + exit 1 +fi + +tar -xz -f "${ARCHIVE_PATH}" -C "$FLEETCTL_INSTALL_DIR" --strip-components=1 fleetctl_v"${latest_strippedVersion}"_${OS}/ echo "fleetctl installed successfully in ${FLEETCTL_INSTALL_DIR}" # Verify if the binary is executable @@ -59,3 +92,4 @@ if [[ ! -x "${FLEETCTL_INSTALL_DIR}/fleetctl" ]]; then echo "Failed to install or upgrade fleetctl. Please check your permissions and try running this script again." exit 1 fi +