Compare commits

...

4 Commits

Author SHA1 Message Date
dependabot[bot]
ae866c5c4d
Merge 815a2017c2 into 01c82d6921 2026-05-21 13:40:31 +07:00
Nicolas Mowen
01c82d6921
Improve language around prompt restrictions (#23274)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-05-20 20:38:00 -05:00
Josh Hawkins
68e8afd35c
Improve credential redaction handling (#23265)
* redact credentials in config endpoint with sentinel

* backend test

* frontend

* apply widget for credential fields

* i18n
2026-05-20 15:59:01 -06:00
Josh Hawkins
5ef8b9b924
Debug replay fixes (#23270)
* ensure motion masks from source camera are copied to replay

* stop polling debug_replay/status after live_ready

* use vod for constructing replay clips
2026-05-20 16:37:02 -05:00
18 changed files with 205 additions and 47 deletions

View File

@ -43,6 +43,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.genai import PROVIDERS, load_providers
from frigate.jobs.media_sync import (
@ -61,7 +62,11 @@ from frigate.util.builtin import (
process_config_query_string,
update_yaml_file_bulk,
)
from frigate.util.config import apply_section_update, find_config_file
from frigate.util.config import (
apply_section_update,
find_config_file,
redact_credential,
)
from frigate.util.schema import get_config_schema
from frigate.util.services import (
get_nvidia_driver_info,
@ -284,26 +289,24 @@ def config(request: Request):
if request.headers.get("remote-role") != "admin":
config.pop("environment_vars", None)
# remove mqtt credentials
config["mqtt"].pop("password", None)
config["mqtt"].pop("user", None)
# redact mqtt credentials
redact_credential(config["mqtt"], "password")
# remove the proxy secret
config["proxy"].pop("auth_secret", None)
# redact proxy secret
redact_credential(config["proxy"], "auth_secret")
# remove genai api keys
for genai_name, genai_cfg in config.get("genai", {}).items():
# redact genai api keys
for _genai_name, genai_cfg in config.get("genai", {}).items():
if isinstance(genai_cfg, dict):
genai_cfg.pop("api_key", None)
redact_credential(genai_cfg, "api_key")
for camera_name, camera in request.app.frigate_config.cameras.items():
camera_dict = config["cameras"][camera_name]
# remove onvif credentials
# redact onvif credentials
onvif_dict = camera_dict.get("onvif", {})
if onvif_dict:
onvif_dict.pop("user", None)
onvif_dict.pop("password", None)
redact_credential(onvif_dict, "password")
# clean paths
for input in camera_dict.get("ffmpeg", {}).get("inputs", []):
@ -680,6 +683,10 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop any field whose value is still the redaction sentinel
updates = {
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
}
if not updates:
return JSONResponse(
@ -790,6 +797,13 @@ def config_set(request: Request, body: AppConfigSetBody):
updates = flatten_config_data(body.config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
# Drop sentinel-valued fields so untouched credential
# placeholders don't clobber the saved YAML value.
updates = {
k: v
for k, v in updates.items()
if v != REDACTED_CREDENTIAL_SENTINEL
}
if not updates:
return JSONResponse(

View File

@ -86,10 +86,15 @@ class DebugReplayStopResponse(BaseModel):
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session asynchronously."""
replay_manager = request.app.replay_manager
internal_port = request.app.frigate_config.networking.listen.internal
if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1])
source = RecordingDebugReplaySource(
source_camera=body.camera,
start_ts=body.start_time,
end_ts=body.end_time,
internal_port=internal_port,
)
try:

View File

@ -21,6 +21,8 @@ PLUS_API_HOST = "https://api.frigate.video"
SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
# Attribute & Object constants
DEFAULT_ATTRIBUTE_LABEL_MAP = {

View File

@ -245,11 +245,23 @@ class DebugReplayManager:
"frame_shape",
"raw_mask",
"mask",
"improved_contrast_enabled",
"enabled_in_config",
"rasterized_mask",
}
)
if source_config.motion.mask:
motion_dict["mask"] = {
mask_id: (
mask_cfg.model_dump(
exclude={"raw_coordinates", "enabled_in_config"}
)
if mask_cfg is not None
else None
)
for mask_id, mask_cfg in source_config.motion.mask.items()
}
return {
"enabled": True,
"ffmpeg": {

View File

@ -63,8 +63,8 @@ Describe the scene based on observable actions and movements, evaluate the activ
## Analysis Guidelines
When forming your description:
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
- **Treat "Objects in Scene" as the list of tracked subjects to describe.** Do not introduce additional people or vehicles that are not present in this list. You may freely reference other items, surfaces, and environmental details visible in the frames when describing what the listed subjects are doing.
- **Describe the most likely activity from visible cues across the sequence** the subject's path, what they are carrying, and what they interact with. Avoid asserting completed outcomes you do not observe; describe in-progress actions rather than results.
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.

View File

@ -1,4 +1,4 @@
"""Debug replay startup job: ffmpeg concat + camera config publish.
"""Debug replay startup job: ffmpeg remux + camera config publish.
The runner orchestrates the async portion of starting a debug replay
session. The DebugReplayManager (in frigate.debug_replay) owns session
@ -153,15 +153,22 @@ class DebugReplaySource(ABC):
class RecordingDebugReplaySource(DebugReplaySource):
"""Replay source backed by the Recordings table.
Builds a concat playlist of recording files covering the time range
and feeds it to ffmpeg's concat demuxer.
Feeds ffmpeg the internal VOD endpoint so segments with mismatched
SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS
discontinuities.
"""
def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None:
def __init__(
self,
source_camera: str,
start_ts: float,
end_ts: float,
internal_port: int,
) -> None:
self._camera = source_camera
self._start_ts = start_ts
self._end_ts = end_ts
self._concat_file: Optional[str] = None
self._internal_port = internal_port
@property
def source_camera(self) -> str:
@ -185,18 +192,16 @@ class RecordingDebugReplaySource(DebugReplaySource):
)
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}"
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt")
recordings = query_recordings(self._camera, self._start_ts, self._end_ts)
with open(concat_file, "w") as f:
for recording in recordings:
f.write(f"file '{recording.path}'\n")
self._concat_file = concat_file
return ["-f", "concat", "-safe", "0", "-i", concat_file]
def cleanup(self, working_dir: str) -> None:
if self._concat_file:
_remove_silent(self._concat_file)
playlist_url = (
f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8"
)
return [
"-protocol_whitelist",
"pipe,file,http,tcp",
"-i",
playlist_url,
]
class ExportDebugReplaySource(DebugReplaySource):

View File

@ -2,6 +2,7 @@ from unittest.mock import Mock, patch
import frigate.genai
from frigate.config import GenAIProviderEnum
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter
@ -75,6 +76,20 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
####################################################################################################################
################################### Credential redaction sentinel ################################################
####################################################################################################################
def test_config_response_redacts_mqtt_password_with_sentinel(self):
self.minimal_config["mqtt"]["user"] = "mqttuser"
self.minimal_config["mqtt"]["password"] = "supersecret"
app = super().create_app()
with AuthTestClient(app) as client:
response = client.get("/config")
assert response.status_code == 200
mqtt = response.json()["mqtt"]
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
####################################################################################################################
################################### POST /genai/probe Endpoint ##################################################
####################################################################################################################

View File

@ -101,7 +101,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="missing", start_ts=100.0, end_ts=200.0
source_camera="missing",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
@ -112,7 +115,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=200.0, end_ts=100.0
source_camera="front",
start_ts=200.0,
end_ts=100.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
@ -126,7 +132,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
@ -156,7 +165,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
):
job_id = start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
@ -193,7 +205,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
@ -203,7 +218,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(RuntimeError):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
@ -271,7 +289,10 @@ class TestRunnerHappyPath(unittest.TestCase):
):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
@ -342,7 +363,10 @@ class TestRunnerFailurePath(unittest.TestCase):
):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
@ -420,7 +444,10 @@ class TestRunnerCancellation(unittest.TestCase):
):
start_debug_replay_job(
source=RecordingDebugReplaySource(
source_camera="front", start_ts=100.0, end_ts=200.0
source_camera="front",
start_ts=100.0,
end_ts=200.0,
internal_port=5000,
),
frigate_config=self.frigate_config,
config_publisher=self.publisher,

View File

@ -8,7 +8,7 @@ from typing import Any, Optional, Union
from ruamel.yaml import YAML
from frigate.const import CONFIG_DIR, EXPORT_DIR
from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.util.builtin import deep_merge
from frigate.util.services import get_video_properties
@ -18,6 +18,21 @@ CURRENT_CONFIG_VERSION = "0.18-0"
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
def redact_credential(obj: dict[str, Any], key: str) -> None:
"""Replace obj[key] with the redaction sentinel if a value is saved, else drop.
Used when shaping the /config response so saved credentials never leave
the server. The frontend recognizes REDACTED_CREDENTIAL_SENTINEL, renders
the field as empty with a "saved — leave blank to keep" placeholder, and
/config/set strips it from any incoming payload so the YAML value is
preserved when the user doesn't touch the field.
"""
if obj.get(key):
obj[key] = REDACTED_CREDENTIAL_SENTINEL
else:
obj.pop(key, None)
def find_config_file() -> str:
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)

View File

@ -316,5 +316,8 @@
"pixels": "{{area}}px"
},
"no_items": "No items",
"validation_errors": "Validation Errors"
"validation_errors": "Validation Errors",
"credentialField": {
"savedPlaceholder": "Saved — leave blank to keep current"
}
}

View File

@ -24,6 +24,7 @@ const genai: SectionConfigOverrides = {
"ui:widget": "genaiRoles",
},
"*.api_key": {
"ui:widget": "password",
"ui:options": { size: "lg" },
},
"*.base_url": {

View File

@ -64,6 +64,7 @@ const mqtt: SectionConfigOverrides = {
liveValidate: true,
uiSchema: {
password: {
"ui:widget": "password",
"ui:options": { size: "xs" },
},
},

View File

@ -29,6 +29,9 @@ const onvif: SectionConfigOverrides = {
host: {
"ui:options": { size: "sm" },
},
password: {
"ui:widget": "password",
},
profile: {
"ui:widget": "onvifProfile",
},

View File

@ -18,6 +18,7 @@ const proxy: SectionConfigOverrides = {
"ui:options": { size: "lg" },
},
auth_secret: {
"ui:widget": "password",
"ui:options": { size: "md" },
},
header_map: {

View File

@ -3,8 +3,10 @@ import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { getSizedFieldClassName } from "../utils";
export function PasswordWidget(props: WidgetProps) {
@ -21,17 +23,31 @@ export function PasswordWidget(props: WidgetProps) {
options,
} = props;
const { t } = useTranslation(["common"]);
const [showPassword, setShowPassword] = useState(false);
const fieldClassName = getSizedFieldClassName(options, "sm");
// When the backend returns the sentinel, hide it visually and prompt the
// user that a value is already saved. The value stays as the sentinel in
// form state — backend /config/set strips it so the saved YAML is
// preserved when the user doesn't touch the field.
const isRedacted = value === REDACTED_CREDENTIAL_SENTINEL;
const displayValue = isRedacted ? "" : (value ?? "");
const effectivePlaceholder = isRedacted
? t("credentialField.savedPlaceholder", {
ns: "common",
defaultValue: "Saved — leave blank to keep current",
})
: placeholder || "";
return (
<div className={cn("relative", fieldClassName)}>
<Input
id={id}
type={showPassword ? "text" : "password"}
value={value ?? ""}
value={displayValue}
disabled={disabled || readonly}
placeholder={placeholder || ""}
placeholder={effectivePlaceholder}
onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value)
}
@ -46,7 +62,7 @@ export function PasswordWidget(props: WidgetProps) {
size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={disabled}
disabled={disabled || isRedacted}
>
{showPassword ? (
<LuEyeOff className="h-4 w-4" />

View File

@ -1,6 +1,16 @@
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
/**
* Sentinel the backend substitutes for saved credentials (api keys,
* passwords, secrets) in /config responses. The credential widget renders
* this value as an empty input with a "saved — leave blank to keep" hint,
* and stripRedactedCredentials() removes any field still equal to this
* value before sending a config/set payload so the saved YAML value is
* preserved. Mirror of frigate.const.REDACTED_CREDENTIAL_SENTINEL.
*/
export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__";
export const ANNOTATION_OFFSET_MIN = -10000;
export const ANNOTATION_OFFSET_MAX = 5000;
export const ANNOTATION_OFFSET_STEP = 50;

View File

@ -121,7 +121,7 @@ export default function Replay() {
mutate: refreshStatus,
isLoading,
} = useSWR<DebugReplayStatus>("debug_replay/status", {
refreshInterval: 1000,
refreshInterval: (latestData) => (latestData?.live_ready ? 0 : 1000),
});
const { payload: replayJob } =
useJobStatus<DebugReplayJobResults>("debug_replay");

View File

@ -11,6 +11,7 @@ import isEqual from "lodash/isEqual";
import mergeWith from "lodash/mergeWith";
import set from "lodash/set";
import { isJsonObject } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { applySchemaDefaults } from "@/lib/config-schema";
import { normalizeConfigValue } from "@/hooks/use-config-override";
import {
@ -29,6 +30,33 @@ import type {
import type { SectionConfig } from "../components/config-form/sections/BaseSection";
import { sectionConfigs } from "../components/config-form/sectionConfigs";
/**
* Recursively strip any key whose value is the redaction sentinel from a
* config_data payload. Use just before sending to /config/set so untouched
* credential placeholder fields don't clobber the saved YAML value. Mutates
* and returns the input.
*/
export function stripRedactedCredentials<T>(value: T): T {
if (Array.isArray(value)) {
for (const item of value) {
stripRedactedCredentials(item);
}
return value;
}
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
const v = obj[key];
if (v === REDACTED_CREDENTIAL_SENTINEL) {
delete obj[key];
} else if (v && typeof v === "object") {
stripRedactedCredentials(v);
}
}
}
return value;
}
// ---------------------------------------------------------------------------
// cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics
// ---------------------------------------------------------------------------