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 (