mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
feat(resources): protect Sencho's own image, network, volumes from deletion (#1149)
* feat(resources): protect Sencho's own image, network, volumes from deletion Adds SelfIdentityService that reads HOSTNAME at startup and inspects the running Sencho container via Dockerode to record its image ID, attached networks, named volumes, and container ID. The classification API marks these with isSencho:true, destructive delete routes return 423 Locked when the target matches self, the orphan-containers API filters the Sencho container out so it cannot be selected and purged from the Unmanaged tab, and the managed-prune path adds an explicit self filter for defense-in-depth on top of Docker's in-use semantics. The Resources view renders a Sencho pill alongside the managed badge on matching rows and disables the trash control with a hover tooltip. When Sencho runs outside Docker (dev mode), inspect returns 404, the service stays empty, and every isOwn* returns false so today's behaviour is preserved. * fix(resources): handle sha256-prefixed image IDs and custom hostnames Addresses independent-review findings on PR #1149: - Strip sha256: prefix in POST /api/system/images/delete before validating the ID, matching the inspect route's handling. Without this, /system/images responses round-trip through the UI as sha256:<hex> and got 400 Invalid image ID format before rejectIfSelf could run. - Add /proc/self/cgroup fallback to SelfIdentityService so custom --hostname, Compose hostname:, or --uts=host setups still self-identify. HOSTNAME inspect runs first; on 404 the service parses the cgroup file for a 64-hex container ID (cgroupv1 docker, cgroupv2 docker, podman libpod formats all covered) and retries inspect with that ID. - Restrict prefix matching in isOwnNetwork / matchesId to hex-shaped candidates (12 to 64 hex chars), so a non-Sencho network whose name happens to start with a hex prefix of Sencho's network ID is no longer flagged as self. - Trim the resources.mdx Note to customer-visible behaviour without enumerating every tab. - New tests: prefixed-image-ID 200 path, three cgroup file format parses (v1, v2, podman) plus the no-match and missing-file cases, HOSTNAME-404-then-cgroup-success fallback path, name-collision regression for the hex-only prefix rule, and an empty-cache no-regression check. Test hygiene: mockReset on the inspect stub and restoreAllMocks in afterEach so spies do not leak across tests. * chore(security): VEX not_affected for CVE-2026-46680 (containerd in docker-compose) Trivy now flags CVE-2026-46680 HIGH on usr/local/lib/docker/cli-plugins/docker-compose, which statically embeds github.com/containerd/containerd/v2 v2.2.3 (compose v5.1.3's resolved module graph). The CVE is a runtime-executor flaw: containerd's runc invocation can be tricked into running a Kubernetes pod marked runAsNonRoot as root via crafted user ID handling. The vulnerable code path is reached only by containerd-shim executing a container with a populated OCI runtime spec on the daemon side. docker-compose vendors the containerd Go module purely as a client (gRPC stubs, API types, shared utilities); it never executes containers and never enforces runAsNonRoot. Sencho's compose usage (up / down / ps against user-authored files) cannot construct a Kubernetes pod security context. The vulnerable path is unreachable. Adds a not_affected entry to security/vex/sencho.openvex.json with justification vulnerable_code_not_in_execute_path, bumps version 5 to 6, and updates last_updated to 2026-05-22 per Directive 23.
This commit is contained in:
@@ -61,6 +61,7 @@ interface DockerImage {
|
||||
Containers: number;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged' | 'unused';
|
||||
isSencho: boolean;
|
||||
}
|
||||
|
||||
interface DockerVolume {
|
||||
@@ -71,6 +72,7 @@ interface DockerVolume {
|
||||
CreatedAt: string | null;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged';
|
||||
isSencho: boolean;
|
||||
}
|
||||
|
||||
const NETWORK_DRIVERS = ['bridge', 'overlay', 'macvlan', 'host', 'none'] as const;
|
||||
@@ -83,6 +85,7 @@ export interface DockerNetwork {
|
||||
Scope: string;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged' | 'system';
|
||||
isSencho: boolean;
|
||||
}
|
||||
|
||||
interface UnmanagedContainer {
|
||||
@@ -175,6 +178,20 @@ function ManagedBadge({ status, managedBy }: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Sencho Self-Protection Badge ───────────────────────────────────────────────
|
||||
|
||||
function SenchoBadge() {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-brand/25 bg-brand/8 text-brand text-[10px] font-medium"
|
||||
title="Protected · running Sencho instance"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand shrink-0" />
|
||||
Sencho
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Severity Badge ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_BADGE_CLASSES: Record<VulnSeverity | 'CLEAN', string> = {
|
||||
@@ -793,6 +810,7 @@ export default function ResourcesView() {
|
||||
{img.Containers > 0 ? "In Use" : "Unused"}
|
||||
</Badge>
|
||||
<ManagedBadge status={img.managedStatus} managedBy={img.managedBy} />
|
||||
{img.isSencho && <SenchoBadge />}
|
||||
{(() => {
|
||||
const tag = img.RepoTags?.[0];
|
||||
const summary = tag ? scanSummaries[tag] : undefined;
|
||||
@@ -844,7 +862,14 @@ export default function ResourcesView() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}>
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={img.isSencho}
|
||||
title={img.isSencho ? 'Protected · running Sencho instance' : undefined}
|
||||
onClick={() => setConfirmDelete({ type: 'images', id: img.Id, name: img.RepoTags?.[0] })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>}
|
||||
</div>
|
||||
@@ -890,7 +915,12 @@ export default function ResourcesView() {
|
||||
<TableCell className="font-mono text-xs max-w-[200px] truncate">{vol.Name}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-[10px] h-5">{vol.Driver}</Badge></TableCell>
|
||||
<TableCell className="hidden md:table-cell text-xs text-muted-foreground truncate max-w-[300px]">{vol.Mountpoint}</TableCell>
|
||||
<TableCell><ManagedBadge status={vol.managedStatus} managedBy={vol.managedBy} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<ManagedBadge status={vol.managedStatus} managedBy={vol.managedBy} />
|
||||
{vol.isSencho && <SenchoBadge />}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{isAdmin && (
|
||||
@@ -905,7 +935,14 @@ export default function ResourcesView() {
|
||||
<FolderOpen className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors" onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}>
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={vol.isSencho}
|
||||
title={vol.isSencho ? 'Protected · running Sencho instance' : undefined}
|
||||
onClick={() => setConfirmDelete({ type: 'volumes', id: vol.Name, name: vol.Name })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>}
|
||||
</div>
|
||||
@@ -1013,7 +1050,12 @@ export default function ResourcesView() {
|
||||
<TableCell className="font-medium max-w-[200px] truncate">{net.Name}</TableCell>
|
||||
<TableCell className="text-xs">{net.Driver}</TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-[10px] h-5">{net.Scope}</Badge></TableCell>
|
||||
<TableCell><ManagedBadge status={net.managedStatus} managedBy={net.managedBy} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<ManagedBadge status={net.managedStatus} managedBy={net.managedBy} />
|
||||
{net.isSencho && <SenchoBadge />}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
@@ -1028,8 +1070,9 @@ export default function ResourcesView() {
|
||||
{isAdmin && <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30"
|
||||
disabled={net.managedStatus === 'system'}
|
||||
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-destructive/60"
|
||||
disabled={net.managedStatus === 'system' || net.isSencho}
|
||||
title={net.isSencho ? 'Protected · running Sencho instance' : undefined}
|
||||
onClick={() => setConfirmDelete({ type: 'networks', id: net.Id, name: net.Name })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
|
||||
Reference in New Issue
Block a user