diff --git a/android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt b/android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt index 14651ae087f..cf0d14b6bda 100644 --- a/android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt +++ b/android/app/src/main/java/com/fleetdm/agent/scep/ScepClientImpl.kt @@ -1,5 +1,6 @@ package com.fleetdm.agent.scep +import android.util.Log import com.fleetdm.agent.GetCertificateTemplateResponse import org.bouncycastle.asn1.DERPrintableString import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers @@ -40,6 +41,7 @@ class ScepClientImpl : ScepClient { // Sending null causes jScep to omit the parameter; the server returns its default CA. private val SCEP_PROFILE: String? = null private const val SELF_SIGNED_CERT_VALIDITY_DAYS = 100L + private const val TAG = "ScepClientImpl" init { // Ensure BouncyCastle provider is loaded @@ -75,19 +77,25 @@ class ScepClientImpl : ScepClient { throw ScepNetworkException("Invalid SCEP URL: $scepUrl", e) } - // OptimisticCertificateVerifier is used intentionally because: - // 1. SCEP URL is provided by the authenticated MDM server - // 2. Challenge password authenticates the enrollment request - // 3. Enterprise SCEP servers often use internal CAs not in system trust stores - // 4. The enrolled certificate itself is validated when used + // NOTE: OptimisticCertificateVerifier accepts any server certificate presented + // during enrollment without validation. This is a known weakness: if scepUrl or + // DNS resolution is ever manipulated, the client could complete enrollment + // against an attacker-controlled CA and leak the challenge password embedded in + // the CSR. A proper fix requires pinning against a known certificate/fingerprint + // supplied by the MDM server when available. Until that plumbing exists, we keep + // OptimisticCertificateVerifier as a fallback but this should be revisited. val verifier = OptimisticCertificateVerifier() val client = Client(server, verifier) // Step 5: Build Certificate Signing Request (CSR) + val challenge = config.scepChallenge + if (challenge.isNullOrEmpty()) { + throw ScepCsrException("SCEP challenge password is missing; refusing to enroll without it") + } val csr = buildCsr( entity, keyPair, - config.scepChallenge ?: "", + challenge, config.signatureAlgorithm, config.subjectAlternativeName, ) @@ -139,10 +147,10 @@ class ScepClientImpl : ScepClient { } } } catch (e: ScepException) { - // Re-throw ScepException as-is (Log.e removed to avoid test failures) + Log.e(TAG, "SCEP enrollment failed: ${e.message}", e) throw e } catch (e: Exception) { - // Wrap unexpected exceptions in ScepException (Log.e removed to avoid test failures) + Log.e(TAG, "Unexpected SCEP enrollment error: ${e.message}", e) throw ScepException("Unexpected SCEP enrollment error: ${e.message}", e) } } diff --git a/client/base_client.go b/client/base_client.go index 1b6416f1fc9..e9ae313c134 100644 --- a/client/base_client.go +++ b/client/base_client.go @@ -100,8 +100,6 @@ func (bc *BaseClient) ParseResponse(verb, path string, response *http.Response, } } - bc.SetServerCapabilities(response) - return nil } @@ -286,7 +284,11 @@ func (pr *progressReader) Read(p []byte) (int, error) { // DoHTTPRequest performs an HTTP request using the underlying HTTP client. func (bc *BaseClient) DoHTTPRequest(req *http.Request) (*http.Response, error) { - return bc.HTTP.Do(req) + resp, err := bc.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("performing http request: %w", err) + } + return resp, nil } // GetRawHTTPClient returns the underlying HTTP client for type assertions (e.g., idle connection cleanup). diff --git a/cmd/osquery-perf/softwaredb/softwaredb.go b/cmd/osquery-perf/softwaredb/softwaredb.go index 44d5820cb63..ae335f0ff4b 100644 --- a/cmd/osquery-perf/softwaredb/softwaredb.go +++ b/cmd/osquery-perf/softwaredb/softwaredb.go @@ -9,6 +9,7 @@ import ( "math/rand/v2" "os" "strings" + "sync" _ "github.com/mattn/go-sqlite3" ) @@ -44,7 +45,8 @@ var ( "ipados_apps": "ipados_apps", "jetbrains_plugins": "jetbrains_plugins", } - vendorPool = make(map[string]string) // populated during load + vendorPool = make(map[string]string) // populated during load + vendorPoolMu sync.Mutex // guards vendorPool ) // internString returns an interned version of s from the vendor pool, reducing memory usage @@ -52,6 +54,8 @@ func internString(s string) string { if s == "" { return "" } + vendorPoolMu.Lock() + defer vendorPoolMu.Unlock() if interned, ok := vendorPool[s]; ok { return interned } @@ -318,6 +322,8 @@ func LoadFromDatabase(dbPath string) (*DB, error) { var count int err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='software'").Scan(&count) if err != nil || count == 0 { + db.Close() + os.Remove(dbPath) // Clean up partial/corrupt database so next run can regenerate it return nil, errors.New("database exists but 'software' table not found\n\nPlease initialize the database:\n cd cmd/osquery-perf/software-library\n sqlite3 software.db < software.sql") } @@ -381,19 +387,37 @@ func generateDatabaseFromSQL(dbPath, sqlPath string) error { return nil } +// sourcePlaceholders returns a comma-separated list of "?" placeholders, one per source, +// for use with parameterized queries. +func sourcePlaceholders(sources []string) string { + placeholders := make([]string, len(sources)) + for i := range sources { + placeholders[i] = "?" + } + return strings.Join(placeholders, ", ") +} + +// sourcesToArgs converts a slice of source strings to a slice of interface{} for use as +// query arguments. +func sourcesToArgs(sources []string) []interface{} { + args := make([]interface{}, len(sources)) + for i, s := range sources { + args[i] = s + } + return args +} + // loadDarwinSoftware loads all macOS/iOS software from the database for the given sources func loadDarwinSoftware(db *sql.DB, sources []string) ([]DarwinSoftware, error) { - sourceList := "'" + strings.Join(sources, "', '") + "'" - // nolint:gosec // sources are hardcoded, not user input query := fmt.Sprintf(` SELECT name, version, source, bundle_identifier, vendor, extension_id, extension_for FROM software WHERE source IN (%s) ORDER BY RANDOM() LIMIT %d - `, sourceList, MaxSoftwarePerPlatform) + `, sourcePlaceholders(sources), MaxSoftwarePerPlatform) - rows, err := db.Query(query) + rows, err := db.Query(query, sourcesToArgs(sources)...) if err != nil { return nil, fmt.Errorf("querying darwin software: %w", err) } @@ -439,17 +463,15 @@ func loadDarwinSoftware(db *sql.DB, sources []string) ([]DarwinSoftware, error) // loadWindowsSoftware loads all Windows software from the database for the given sources func loadWindowsSoftware(db *sql.DB, sources []string) ([]WindowsSoftware, error) { - sourceList := "'" + strings.Join(sources, "', '") + "'" - // nolint:gosec // sources are hardcoded, not user input query := fmt.Sprintf(` SELECT name, version, source, vendor, upgrade_code, extension_id, extension_for FROM software WHERE source IN (%s) ORDER BY RANDOM() LIMIT %d - `, sourceList, MaxSoftwarePerPlatform) + `, sourcePlaceholders(sources), MaxSoftwarePerPlatform) - rows, err := db.Query(query) + rows, err := db.Query(query, sourcesToArgs(sources)...) if err != nil { return nil, fmt.Errorf("querying windows software: %w", err) } @@ -495,17 +517,15 @@ func loadWindowsSoftware(db *sql.DB, sources []string) ([]WindowsSoftware, error // loadUbuntuSoftware loads all Ubuntu/Linux software from the database for the given sources func loadUbuntuSoftware(db *sql.DB, sources []string) ([]UbuntuSoftware, error) { - sourceList := "'" + strings.Join(sources, "', '") + "'" - // nolint:gosec // sources are hardcoded, not user input query := fmt.Sprintf(` SELECT name, version, source, vendor, arch, release, extension_id, extension_for FROM software WHERE source IN (%s) ORDER BY RANDOM() LIMIT %d - `, sourceList, MaxSoftwarePerPlatform) + `, sourcePlaceholders(sources), MaxSoftwarePerPlatform) - rows, err := db.Query(query) + rows, err := db.Query(query, sourcesToArgs(sources)...) if err != nil { return nil, fmt.Errorf("querying ubuntu software: %w", err) } 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..98bcab86949 100644 --- a/ee/tools/puppet/fleetdm/lib/puppet/util/fleet_client.rb +++ b/ee/tools/puppet/fleetdm/lib/puppet/util/fleet_client.rb @@ -16,6 +16,9 @@ class FleetClient # [1]: https://www.puppet.com/docs/puppet/8/server/config_file_puppetserver.html @instance_mutex = Mutex.new + # Maximum time, in seconds, that a cached entry is considered valid. + CACHE_TTL = 60 + def self.instance return @instance if @instance @instance_mutex.synchronize do @@ -138,9 +141,12 @@ def req(method: :get, path: '', body: nil, headers: {}, cached: false, environme if cached @cache_mutex.synchronize do - unless @cache[path].nil? - return @cache[path] + entry = @cache[path] + if !entry.nil? && (Time.now - entry[:cached_at]) < CACHE_TTL + return entry[:value] end + + @cache.delete(path) unless entry.nil? end end @@ -171,11 +177,11 @@ def req(method: :get, path: '', body: nil, headers: {}, cached: false, environme if cached && out['error'].empty? @cache_mutex.synchronize do - @cache[path] = out + @cache[path] = { value: out, cached_at: Time.now } end end rescue => e - out['error'] = e + out['error'] = e.message end out @@ -195,8 +201,8 @@ def parse_response(response) if (400...600).cover?(response.code.to_i) message = 'server returned a non-ok status code without an error' - if response.body - body = JSON.parse(response.body) + if out['body'].is_a?(Hash) && !out['body'].empty? + body = out['body'] message = body['message'] unless body['errors'].nil? diff --git a/ee/vulnerability-dashboard/scripts/update-critical-software.js b/ee/vulnerability-dashboard/scripts/update-critical-software.js index 29c0cc462b3..54855ec2b3b 100644 --- a/ee/vulnerability-dashboard/scripts/update-critical-software.js +++ b/ee/vulnerability-dashboard/scripts/update-critical-software.js @@ -91,8 +91,8 @@ module.exports = { if(osVersionNamesByHostCount[os.name] === undefined) { osVersionsToReport.push(osToReport); } else if(osVersionNamesByHostCount[os.name] !== os.hosts_count) { + osVersionsToUpdate.push(osToReport); } - osVersionsToUpdate.push(osToReport); } let nativeQueryToFindOperatingSystemsWithNoHosts = @@ -239,7 +239,7 @@ module.exports = { if(!hostsOperatingSystem){ hostsOperatingSystem = _.find(allOsRecords, {'fullName': 'Microsoft '+host.os_version}); if(!hostsOperatingSystem){ - throw new Error(`Host's operating system not found in Operating System records`); + throw new Error(`Host's operating system (${host.os_version}) not found in Operating System records for host ${host.id} (${host.display_name})`); } } byHostFleetApidsSeenInLatestCriticalSoftwareScan[host.id] = { @@ -357,3 +357,4 @@ module.exports = { }; + diff --git a/frontend/components/EnrollSecrets/SecretEditorModal/SecretEditorModal.tsx b/frontend/components/EnrollSecrets/SecretEditorModal/SecretEditorModal.tsx index 0ea75656694..d34d18058e7 100644 --- a/frontend/components/EnrollSecrets/SecretEditorModal/SecretEditorModal.tsx +++ b/frontend/components/EnrollSecrets/SecretEditorModal/SecretEditorModal.tsx @@ -21,13 +21,11 @@ const baseClass = "secret-editor-modal"; const randomSecretGenerator = () => { const randomChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - let result = ""; - for (let i = 0; i < 32; i += 1) { - result += randomChars.charAt( - Math.floor(Math.random() * randomChars.length) - ); - } - return result; + const bytes = new Uint32Array(32); + window.crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => randomChars[b % randomChars.length]).join( + "" + ); }; const SecretEditorModal = ({ @@ -45,14 +43,15 @@ const SecretEditorModal = ({ const [errors, setErrors] = useState<{ [key: string]: string }>({}); const renderTeam = () => { - if (typeof selectedTeam === "string") { - selectedTeam = parseInt(selectedTeam, 10); - } + const parsedSelectedTeam = + typeof selectedTeam === "string" + ? parseInt(selectedTeam, 10) + : selectedTeam; - if (selectedTeam === 0) { + if (parsedSelectedTeam === 0) { return { name: "Unassigned" }; } - return teams.find((team) => team.id === selectedTeam); + return teams.find((team) => team.id === parsedSelectedTeam); }; const onSecretChange = (value: string) => { diff --git a/frontend/components/QueryFrequencyIndicator/QueryFrequencyIndicator.tsx b/frontend/components/QueryFrequencyIndicator/QueryFrequencyIndicator.tsx index 95704093d7e..79e38e5726d 100644 --- a/frontend/components/QueryFrequencyIndicator/QueryFrequencyIndicator.tsx +++ b/frontend/components/QueryFrequencyIndicator/QueryFrequencyIndicator.tsx @@ -24,7 +24,8 @@ const QueryFrequencyIndicator = ({ const frequencyClassName = classnames( "query-frequency-indicator", `query-frequency-indicator--${classTag}`, - `frequency--${classTag}` + `frequency--${classTag}`, + { grey: frequency === 0 && !checked } ); const readableQueryFrequency = () => { switch (frequency) { @@ -53,10 +54,7 @@ const QueryFrequencyIndicator = ({ }; return ( -
+
{frequencyIcon()} {readableQueryFrequency()}
diff --git a/frontend/components/forms/fields/InputField/InputField.tsx b/frontend/components/forms/fields/InputField/InputField.tsx index a6b135f8da4..6a2c7bab5d6 100644 --- a/frontend/components/forms/fields/InputField/InputField.tsx +++ b/frontend/components/forms/fields/InputField/InputField.tsx @@ -9,6 +9,8 @@ import FormField from "components/forms/FormField"; import Button from "components/buttons/Button"; import Icon from "components/Icon"; +import { InputFieldOnChange } from "interfaces/form_field"; + const baseClass = "input-field"; export interface IInputFieldProps { @@ -27,7 +29,7 @@ export interface IInputFieldProps { * parseTarget is true. See IInputFieldParseTarget and InputFieldOnChange * in interfaces/form_field.ts for caller-side typing helpers. */ - onChange?: (value: any) => void; + onChange?: InputFieldOnChange; onBlur?: ( evt: React.FocusEvent ) => void; @@ -126,7 +128,7 @@ const InputField = ({ const onClickCopy = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - stringToClipboard(value).then(() => { + stringToClipboard(String(value ?? "")).then(() => { setCopied(true); setTimeout(() => { setCopied(false); @@ -163,26 +165,30 @@ const InputField = ({ Copied! )} - +
)} {enableShowSecret && ( - + )} ); diff --git a/frontend/pages/DashboardPage/cards/WelcomeHost/WelcomeHost.tsx b/frontend/pages/DashboardPage/cards/WelcomeHost/WelcomeHost.tsx index 69a82e4550c..f0b7b38cb0d 100644 --- a/frontend/pages/DashboardPage/cards/WelcomeHost/WelcomeHost.tsx +++ b/frontend/pages/DashboardPage/cards/WelcomeHost/WelcomeHost.tsx @@ -7,6 +7,7 @@ import { NotificationContext } from "context/notification"; import { IHost, IHostResponse } from "interfaces/host"; import { IHostPolicy } from "interfaces/policy"; import hostAPI from "services/entities/hosts"; +import { AppContext } from "context/app"; import Spinner from "components/Spinner"; import Button from "components/buttons/Button"; @@ -22,7 +23,6 @@ interface IWelcomeHostCardProps { } const baseClass = "welcome-host"; -const HOST_ID = 1; const POLICY_PASS = "pass"; const POLICY_FAIL = "fail"; @@ -31,6 +31,8 @@ const WelcomeHost = ({ toggleAddHostsModal, }: IWelcomeHostCardProps): JSX.Element => { const { renderFlash } = useContext(NotificationContext); + const { currentUser } = useContext(AppContext); + const hostId = currentUser?.id_verified_host_id; const [refetchStartTime, setRefetchStartTime] = useState(null); const [currentPolicyShown, setCurrentPolicyShown] = useState(); const [showPolicyModal, setShowPolicyModal] = useState(false); @@ -46,8 +48,9 @@ const WelcomeHost = ({ refetch: fullyReloadHost, } = useQuery( ["host"], - () => hostAPI.loadHostDetails(HOST_ID), + () => hostAPI.loadHostDetails(hostId as number), { + enabled: !!hostId, retry: false, select: (data: IHostResponse) => data.host, onSuccess: (returnedHost) => { @@ -229,6 +232,7 @@ const WelcomeHost = ({ if (p.response) { return (