Files
sencho/backend/src/__tests__/system-maintenance-self-protect.test.ts
T
Anso b740dd1078 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.
2026-05-21 23:10:13 -04:00

205 lines
7.5 KiB
TypeScript

/**
* Route-level tests for self-protection. Stubs SelfIdentityService matchers
* to simulate "this is Sencho's own resource" and verifies the delete routes
* return 423 Locked, plus that /prune/orphans filters self out silently.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let SelfIdentityService: typeof import('../services/SelfIdentityService').default;
let DockerController: typeof import('../services/DockerController').default;
const SELF_IMAGE = 'a'.repeat(64);
const SELF_NETWORK = 'b'.repeat(64);
const SELF_CONTAINER = 'c'.repeat(64);
const OTHER_IMAGE = 'd'.repeat(64);
const OTHER_NETWORK = 'e'.repeat(64);
const OTHER_CONTAINER = 'f'.repeat(64);
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ default: SelfIdentityService } = await import('../services/SelfIdentityService'));
({ default: DockerController } = await import('../services/DockerController'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => {
vi.restoreAllMocks();
SelfIdentityService.getInstance().resetForTesting();
});
function stubSelfIdentity(opts: { imageId?: string; networkId?: string; containerId?: string; volumeName?: string }) {
const svc = SelfIdentityService.getInstance();
vi.spyOn(svc, 'isOwnImage').mockImplementation((id: string) => id === opts.imageId);
vi.spyOn(svc, 'isOwnNetwork').mockImplementation((id: string) => id === opts.networkId);
vi.spyOn(svc, 'isOwnContainer').mockImplementation((id: string) => id === opts.containerId);
vi.spyOn(svc, 'isOwnVolume').mockImplementation((id: string) => id === opts.volumeName);
}
function stubDockerControllerNoops() {
const fake = {
removeImage: vi.fn().mockResolvedValue(undefined),
removeNetwork: vi.fn().mockResolvedValue(undefined),
removeVolume: vi.fn().mockResolvedValue(undefined),
removeContainers: vi.fn().mockResolvedValue([]),
};
vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType<typeof DockerController.getInstance>);
return fake;
}
describe('Self-protection on /api/system delete routes', () => {
it('refuses to delete Sencho\'s own image with 423 Locked', async () => {
stubSelfIdentity({ imageId: SELF_IMAGE });
const docker = stubDockerControllerNoops();
const res = await request(app)
.post('/api/system/images/delete')
.set('Authorization', authHeader)
.send({ id: SELF_IMAGE });
expect(res.status).toBe(423);
expect(res.body.error).toMatch(/Cannot delete the running Sencho instance/);
expect(res.body.kind).toBe('image');
expect(docker.removeImage).not.toHaveBeenCalled();
});
it('allows deleting other images', async () => {
stubSelfIdentity({ imageId: SELF_IMAGE });
const docker = stubDockerControllerNoops();
const res = await request(app)
.post('/api/system/images/delete')
.set('Authorization', authHeader)
.send({ id: OTHER_IMAGE });
expect(res.status).toBe(200);
expect(docker.removeImage).toHaveBeenCalledWith(OTHER_IMAGE);
});
it('accepts sha256:-prefixed image IDs (the form Docker returns from /system/images)', async () => {
stubSelfIdentity({ imageId: SELF_IMAGE });
const docker = stubDockerControllerNoops();
const res = await request(app)
.post('/api/system/images/delete')
.set('Authorization', authHeader)
.send({ id: 'sha256:' + OTHER_IMAGE });
expect(res.status).toBe(200);
expect(docker.removeImage).toHaveBeenCalledWith('sha256:' + OTHER_IMAGE);
});
it('refuses to delete Sencho\'s own network with 423', async () => {
stubSelfIdentity({ networkId: SELF_NETWORK });
const docker = stubDockerControllerNoops();
const res = await request(app)
.post('/api/system/networks/delete')
.set('Authorization', authHeader)
.send({ id: SELF_NETWORK });
expect(res.status).toBe(423);
expect(res.body.kind).toBe('network');
expect(docker.removeNetwork).not.toHaveBeenCalled();
});
it('allows deleting other networks', async () => {
stubSelfIdentity({ networkId: SELF_NETWORK });
const docker = stubDockerControllerNoops();
const res = await request(app)
.post('/api/system/networks/delete')
.set('Authorization', authHeader)
.send({ id: OTHER_NETWORK });
expect(res.status).toBe(200);
expect(docker.removeNetwork).toHaveBeenCalledWith(OTHER_NETWORK);
});
it('refuses to delete Sencho\'s own volume with 423', async () => {
stubSelfIdentity({ volumeName: 'sencho_data' });
const docker = stubDockerControllerNoops();
const res = await request(app)
.post('/api/system/volumes/delete')
.set('Authorization', authHeader)
.send({ id: 'sencho_data' });
expect(res.status).toBe(423);
expect(res.body.kind).toBe('volume');
expect(docker.removeVolume).not.toHaveBeenCalled();
});
it('allows deleting other volumes', async () => {
stubSelfIdentity({ volumeName: 'sencho_data' });
const docker = stubDockerControllerNoops();
const res = await request(app)
.post('/api/system/volumes/delete')
.set('Authorization', authHeader)
.send({ id: 'other_volume' });
expect(res.status).toBe(200);
expect(docker.removeVolume).toHaveBeenCalledWith('other_volume');
});
});
describe('Self-protection on /api/system/prune/orphans', () => {
it('filters Sencho\'s own container out silently and reports skipped:self', async () => {
stubSelfIdentity({ containerId: SELF_CONTAINER });
const docker = stubDockerControllerNoops();
docker.removeContainers.mockResolvedValue([{ id: OTHER_CONTAINER, success: true }]);
const res = await request(app)
.post('/api/system/prune/orphans')
.set('Authorization', authHeader)
.send({ containerIds: [SELF_CONTAINER, OTHER_CONTAINER] });
expect(res.status).toBe(200);
expect(res.body.skipped).toBe('self');
expect(docker.removeContainers).toHaveBeenCalledWith([OTHER_CONTAINER]);
});
it('does not flag a request that excludes the self container', async () => {
stubSelfIdentity({ containerId: SELF_CONTAINER });
const docker = stubDockerControllerNoops();
docker.removeContainers.mockResolvedValue([{ id: OTHER_CONTAINER, success: true }]);
const res = await request(app)
.post('/api/system/prune/orphans')
.set('Authorization', authHeader)
.send({ containerIds: [OTHER_CONTAINER] });
expect(res.status).toBe(200);
expect(res.body.skipped).toBeUndefined();
expect(docker.removeContainers).toHaveBeenCalledWith([OTHER_CONTAINER]);
});
});
describe('Self-protection in dev mode (SelfIdentityService empty)', () => {
it('deletes any image when no self identity is configured', async () => {
// No stubbing of isOwn*; the reset in afterEach left the service empty
// and a real isOwnImage on an empty cache returns false.
SelfIdentityService.getInstance().resetForTesting();
const docker = stubDockerControllerNoops();
const res = await request(app)
.post('/api/system/images/delete')
.set('Authorization', authHeader)
.send({ id: SELF_IMAGE });
expect(res.status).toBe(200);
expect(docker.removeImage).toHaveBeenCalledWith(SELF_IMAGE);
});
});