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:
@@ -8,6 +8,7 @@ import * as yaml from 'yaml';
|
||||
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { CacheService } from './CacheService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
@@ -42,6 +43,7 @@ export interface ClassifiedImage {
|
||||
Containers: number;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged' | 'unused';
|
||||
isSencho: boolean;
|
||||
}
|
||||
|
||||
export interface PortInUseInfo {
|
||||
@@ -57,6 +59,7 @@ export interface ClassifiedVolume {
|
||||
CreatedAt: string | null;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged';
|
||||
isSencho: boolean;
|
||||
}
|
||||
|
||||
export interface ClassifiedNetwork {
|
||||
@@ -66,6 +69,7 @@ export interface ClassifiedNetwork {
|
||||
Scope: string;
|
||||
managedBy: string | null;
|
||||
managedStatus: 'managed' | 'unmanaged' | 'system';
|
||||
isSencho: boolean;
|
||||
}
|
||||
|
||||
export interface TopologyContainer {
|
||||
@@ -188,6 +192,12 @@ class DockerController {
|
||||
};
|
||||
}
|
||||
|
||||
// Sencho's own image, networks, and named volumes are always in use by the
|
||||
// running container, so Docker's server-side prune APIs (pruneContainers,
|
||||
// pruneImages, pruneNetworks, pruneVolumes) skip them by definition. No
|
||||
// extra self-guard is needed at this layer; the `managed` scope path goes
|
||||
// through `pruneManagedOnly`, which adds an explicit self filter for
|
||||
// defense-in-depth.
|
||||
public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes', labelFilter?: string) {
|
||||
let spaceReclaimed = 0;
|
||||
if (target === 'containers') {
|
||||
@@ -268,6 +278,8 @@ class DockerController {
|
||||
if (stack) imageToStack.set(c.ImageID, stack);
|
||||
}
|
||||
|
||||
const selfIdentity = SelfIdentityService.getInstance();
|
||||
|
||||
const images: ClassifiedImage[] = this.validateApiData<any[]>(rawImages).map((img: any) => {
|
||||
const stack = imageToStack.get(img.Id) ?? null;
|
||||
const managedStatus: ClassifiedImage['managedStatus'] =
|
||||
@@ -280,6 +292,7 @@ class DockerController {
|
||||
Containers: img.Containers ?? 0,
|
||||
managedBy: stack,
|
||||
managedStatus,
|
||||
isSencho: selfIdentity.isOwnImage(img.Id),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -294,12 +307,13 @@ class DockerController {
|
||||
CreatedAt: vol.CreatedAt ?? null,
|
||||
managedBy: stack,
|
||||
managedStatus,
|
||||
isSencho: selfIdentity.isOwnVolume(vol.Name),
|
||||
};
|
||||
});
|
||||
|
||||
const networks: ClassifiedNetwork[] = this.validateApiData<any[]>(rawNetworks).map((net: any) => {
|
||||
if (DockerController.SYSTEM_NETWORKS.has(net.Name)) {
|
||||
return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const };
|
||||
return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const, isSencho: false };
|
||||
}
|
||||
const stack = DockerController.resolveProjectLabel(net.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
|
||||
const managedStatus: ClassifiedNetwork['managedStatus'] = stack ? 'managed' : 'unmanaged';
|
||||
@@ -310,6 +324,7 @@ class DockerController {
|
||||
Scope: net.Scope,
|
||||
managedBy: stack,
|
||||
managedStatus,
|
||||
isSencho: selfIdentity.isOwnNetwork(net.Id) || selfIdentity.isOwnNetwork(net.Name),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -326,6 +341,7 @@ class DockerController {
|
||||
): Promise<{ success: boolean; reclaimedBytes: number }> {
|
||||
const knownSet = new Set(knownStackNames);
|
||||
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
|
||||
const selfIdentity = SelfIdentityService.getInstance();
|
||||
let reclaimedBytes = 0;
|
||||
|
||||
if (target === 'volumes') {
|
||||
@@ -333,7 +349,8 @@ class DockerController {
|
||||
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
const prunable = rawVolumes.filter((v: any) => {
|
||||
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
|
||||
&& (v.UsageData?.RefCount ?? 1) === 0;
|
||||
&& (v.UsageData?.RefCount ?? 1) === 0
|
||||
&& !selfIdentity.isOwnVolume(v.Name);
|
||||
});
|
||||
// Removals are independent and Docker handles concurrent volume
|
||||
// deletes; parallelize so wall time matches the slowest single
|
||||
@@ -349,7 +366,9 @@ class DockerController {
|
||||
} else if (target === 'networks') {
|
||||
const rawNetworks = await this.docker.listNetworks();
|
||||
const prunable = (rawNetworks as any[]).filter((n: any) => {
|
||||
return !!DockerController.resolveProjectLabel(n.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
|
||||
return !!DockerController.resolveProjectLabel(n.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
|
||||
&& !selfIdentity.isOwnNetwork(n.Id)
|
||||
&& !selfIdentity.isOwnNetwork(n.Name);
|
||||
});
|
||||
await Promise.all(prunable.map(async (net) => {
|
||||
try {
|
||||
@@ -371,7 +390,9 @@ class DockerController {
|
||||
}
|
||||
const rawImages = await this.docker.listImages({ all: false });
|
||||
const prunable = (rawImages as any[]).filter((img: any) =>
|
||||
img.Containers === 0 && !unmanagedImageIds.has(img.Id)
|
||||
img.Containers === 0
|
||||
&& !unmanagedImageIds.has(img.Id)
|
||||
&& !selfIdentity.isOwnImage(img.Id)
|
||||
);
|
||||
await Promise.all(prunable.map(async (img) => {
|
||||
try {
|
||||
@@ -398,6 +419,7 @@ class DockerController {
|
||||
): Promise<{ reclaimableBytes: number }> {
|
||||
const knownSet = new Set(knownStackNames);
|
||||
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
|
||||
const selfIdentity = SelfIdentityService.getInstance();
|
||||
let reclaimableBytes = 0;
|
||||
|
||||
if (target === 'volumes') {
|
||||
@@ -405,7 +427,8 @@ class DockerController {
|
||||
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
const prunable = rawVolumes.filter((v: any) => {
|
||||
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
|
||||
&& (v.UsageData?.RefCount ?? 1) === 0;
|
||||
&& (v.UsageData?.RefCount ?? 1) === 0
|
||||
&& !selfIdentity.isOwnVolume(v.Name);
|
||||
});
|
||||
for (const vol of prunable) reclaimableBytes += vol.UsageData?.Size ?? 0;
|
||||
} else if (target === 'networks') {
|
||||
@@ -424,7 +447,9 @@ class DockerController {
|
||||
}
|
||||
const rawImages = await this.docker.listImages({ all: false });
|
||||
const prunable = (rawImages as any[]).filter((img: any) =>
|
||||
img.Containers === 0 && !unmanagedImageIds.has(img.Id),
|
||||
img.Containers === 0
|
||||
&& !unmanagedImageIds.has(img.Id)
|
||||
&& !selfIdentity.isOwnImage(img.Id),
|
||||
);
|
||||
for (const img of prunable) reclaimableBytes += img.Size ?? 0;
|
||||
}
|
||||
@@ -1173,11 +1198,17 @@ class DockerController {
|
||||
|
||||
// 2. Filter and categorize orphans
|
||||
const orphans: Record<string, any[]> = {};
|
||||
const selfIdentity = SelfIdentityService.getInstance();
|
||||
|
||||
allContainers.forEach((container) => {
|
||||
// Look for the docker compose project label
|
||||
const projectName = container.Labels?.['com.docker.compose.project'];
|
||||
|
||||
// Sencho's own container is not a stack on this node, so when it carries
|
||||
// a compose-project label (compose-deployed installations) it would
|
||||
// otherwise surface here as a stray under that project name.
|
||||
if (selfIdentity.isOwnContainer(container.Id)) return;
|
||||
|
||||
// If it has a project label, but the project is NOT in our known list...
|
||||
if (projectName && !knownStackNames.includes(projectName)) {
|
||||
if (!orphans[projectName]) {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import fs from 'fs/promises';
|
||||
import DockerController from './DockerController';
|
||||
|
||||
/**
|
||||
* Identifies the Docker resources that belong to the running Sencho container
|
||||
* (image, attached networks, named volumes, the container itself) so the
|
||||
* Resources view and destructive routes can refuse to delete them and the
|
||||
* Unmanaged tab can filter out Sencho's own container.
|
||||
*
|
||||
* Identification strategy, in order:
|
||||
* 1. `process.env.HOSTNAME` resolves via `docker.getContainer(...).inspect()`.
|
||||
* This is Docker's default (HOSTNAME equals the container's short ID).
|
||||
* 2. `/proc/self/cgroup` fallback. Custom `--hostname`, Compose `hostname:`,
|
||||
* or `--uts=host` decouples HOSTNAME from the container ID; the kernel
|
||||
* still places the process in a cgroup that names the full 64-hex
|
||||
* container ID for both cgroupv1 (`.../docker/<id>`) and cgroupv2
|
||||
* (`.../docker-<id>.scope` / `.../libpod-<id>.scope`).
|
||||
*
|
||||
* In dev mode (`npm run dev` outside Docker) both paths fail, the service
|
||||
* stays in its empty state, every `isOwn*()` returns false, and today's
|
||||
* behavior is preserved.
|
||||
*/
|
||||
class SelfIdentityService {
|
||||
private static instance: SelfIdentityService;
|
||||
private containerId: string | null = null;
|
||||
private containerName: string | null = null;
|
||||
private imageIdHex: string | null = null;
|
||||
private networkIds = new Set<string>();
|
||||
private networkNames = new Set<string>();
|
||||
private volumeNames = new Set<string>();
|
||||
private initialized = false;
|
||||
|
||||
public static getInstance(): SelfIdentityService {
|
||||
if (!SelfIdentityService.instance) {
|
||||
SelfIdentityService.instance = new SelfIdentityService();
|
||||
}
|
||||
return SelfIdentityService.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
|
||||
const docker = DockerController.getInstance().getDocker();
|
||||
const info = await this.resolveSelfInspect(docker);
|
||||
if (!info) return;
|
||||
|
||||
this.containerId = info.Id ?? null;
|
||||
this.containerName = (info.Name || '').replace(/^\//, '') || null;
|
||||
this.imageIdHex = SelfIdentityService.stripSha(info.Image ?? '') || null;
|
||||
|
||||
const nets = info.NetworkSettings?.Networks ?? {};
|
||||
for (const [name, net] of Object.entries(nets)) {
|
||||
if (name) this.networkNames.add(name);
|
||||
const id = (net as { NetworkID?: string } | null)?.NetworkID;
|
||||
if (id) this.networkIds.add(id);
|
||||
}
|
||||
|
||||
const mounts = (info.Mounts ?? []) as Array<{ Type?: string; Name?: string }>;
|
||||
for (const m of mounts) {
|
||||
if (m.Type === 'volume' && m.Name) {
|
||||
this.volumeNames.add(m.Name);
|
||||
}
|
||||
}
|
||||
|
||||
const cidShort = this.containerId ? this.containerId.substring(0, 12) : '?';
|
||||
const iidShort = this.imageIdHex ? this.imageIdHex.substring(0, 12) : '?';
|
||||
console.log(
|
||||
`[SelfIdentity] Detected self: container=${cidShort}, image=${iidShort}, ` +
|
||||
`networks=${this.networkNames.size}, volumes=${this.volumeNames.size}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveSelfInspect(
|
||||
docker: ReturnType<typeof DockerController.prototype.getDocker>,
|
||||
): Promise<Awaited<ReturnType<ReturnType<typeof docker.getContainer>['inspect']>> | null> {
|
||||
const hostname = process.env.HOSTNAME;
|
||||
if (hostname) {
|
||||
try {
|
||||
return await docker.getContainer(hostname).inspect();
|
||||
} catch (err) {
|
||||
const e = err as { statusCode?: number; message?: string };
|
||||
if (e?.statusCode !== 404) {
|
||||
console.warn('[SelfIdentity] HOSTNAME inspect failed:', e?.message || String(err));
|
||||
return null;
|
||||
}
|
||||
// 404 on HOSTNAME means custom hostname or running outside Docker;
|
||||
// fall through to the cgroup probe.
|
||||
}
|
||||
}
|
||||
|
||||
const cgroupId = await SelfIdentityService.readContainerIdFromCgroup();
|
||||
if (!cgroupId) {
|
||||
console.log('[SelfIdentity] no HOSTNAME match and no container ID in /proc/self/cgroup; self-protection disabled (not running in Docker?)');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await docker.getContainer(cgroupId).inspect();
|
||||
} catch (err) {
|
||||
const e = err as { statusCode?: number; message?: string };
|
||||
if (e?.statusCode === 404) {
|
||||
console.log('[SelfIdentity] cgroup container ID inspect returned 404; self-protection disabled');
|
||||
return null;
|
||||
}
|
||||
console.warn('[SelfIdentity] cgroup-resolved inspect failed:', e?.message || String(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the given container ID or name matches the running Sencho container. Accepts short or full IDs. */
|
||||
isOwnContainer(idOrName: string): boolean {
|
||||
if (!idOrName) return false;
|
||||
if (this.containerId && SelfIdentityService.matchesId(this.containerId, idOrName)) return true;
|
||||
if (this.containerName && this.containerName === idOrName) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when the given image reference (full or short hex ID, optionally `sha256:`-prefixed) matches Sencho's own image. */
|
||||
isOwnImage(idOrTag: string): boolean {
|
||||
if (!idOrTag || !this.imageIdHex) return false;
|
||||
const target = SelfIdentityService.stripSha(idOrTag);
|
||||
return SelfIdentityService.matchesId(this.imageIdHex, target);
|
||||
}
|
||||
|
||||
/** True when the given network ID or name matches a network the Sencho container is attached to. */
|
||||
isOwnNetwork(idOrName: string): boolean {
|
||||
if (!idOrName) return false;
|
||||
// Names match exactly. Only hex-looking inputs (12 to 64 chars) are
|
||||
// prefix-matched against the cached IDs, so a network NAMED like a hex
|
||||
// prefix of Sencho's network ID is not falsely flagged.
|
||||
if (this.networkNames.has(idOrName)) return true;
|
||||
if (this.networkIds.has(idOrName)) return true;
|
||||
if (!SelfIdentityService.isHexId(idOrName)) return false;
|
||||
for (const id of this.networkIds) {
|
||||
if (SelfIdentityService.matchesId(id, idOrName)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when the given volume name matches a named volume mounted into Sencho. Bind mounts are excluded by design. */
|
||||
isOwnVolume(name: string): boolean {
|
||||
if (!name) return false;
|
||||
return this.volumeNames.has(name);
|
||||
}
|
||||
|
||||
/** Diagnostic snapshot used by route handlers when composing error responses. */
|
||||
getIdentity(): {
|
||||
containerId: string | null;
|
||||
containerName: string | null;
|
||||
imageId: string | null;
|
||||
networkNames: string[];
|
||||
volumeNames: string[];
|
||||
} {
|
||||
return {
|
||||
containerId: this.containerId,
|
||||
containerName: this.containerName,
|
||||
imageId: this.imageIdHex,
|
||||
networkNames: [...this.networkNames],
|
||||
volumeNames: [...this.volumeNames],
|
||||
};
|
||||
}
|
||||
|
||||
/** Test hook: clear cached state so a fresh initialize() can run with a different stub. */
|
||||
resetForTesting(): void {
|
||||
this.containerId = null;
|
||||
this.containerName = null;
|
||||
this.imageIdHex = null;
|
||||
this.networkIds.clear();
|
||||
this.networkNames.clear();
|
||||
this.volumeNames.clear();
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
private static stripSha(s: string): string {
|
||||
return s.startsWith('sha256:') ? s.slice('sha256:'.length) : s;
|
||||
}
|
||||
|
||||
private static isHexId(s: string): boolean {
|
||||
return /^[a-f0-9]{12,64}$/i.test(s);
|
||||
}
|
||||
|
||||
// Prefix matching is restricted to hex-shaped candidates: a 12-char short
|
||||
// ID hits the cached full ID and vice versa, but a name like "bridge" never
|
||||
// matches a cached ID just because of a partial overlap.
|
||||
private static matchesId(full: string, candidate: string): boolean {
|
||||
if (!full || !candidate) return false;
|
||||
if (full === candidate) return true;
|
||||
if (!SelfIdentityService.isHexId(full) || !SelfIdentityService.isHexId(candidate)) return false;
|
||||
if (full.startsWith(candidate)) return true;
|
||||
if (candidate.startsWith(full)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both cgroupv1 (`12:cpuset:/docker/<64hex>`) and cgroupv2
|
||||
// (`0::/system.slice/docker-<64hex>.scope`, also podman's
|
||||
// `libpod-<64hex>.scope`) embed the full container ID as a 64-hex run.
|
||||
// Matching the longest such run survives kernel + runtime variation.
|
||||
static async readContainerIdFromCgroup(path = '/proc/self/cgroup'): Promise<string | null> {
|
||||
try {
|
||||
const contents = await fs.readFile(path, 'utf8');
|
||||
const matches = contents.match(/[a-f0-9]{64}/gi);
|
||||
return matches && matches.length > 0 ? matches[matches.length - 1].toLowerCase() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default SelfIdentityService;
|
||||
Reference in New Issue
Block a user