mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +00:00
feat: node-scoped Networking operator page (#1603)
* feat: add node-scoped Networking operator page Adds a Networking view with overview, topology, inventory, and findings. Shared aggregate reads back the page; Resources keeps prune and redirects here. Includes fail-closed network delete guards, operator docs, and /nodes/:slug/networking routing. * fix: rename unused variable n to _n to satisfy no-unused-vars lint * fix: keep top bar search clickable when nav grows * feat: complete Compose-first Networking Phase 2 operator assistant * fix: move networking action visibility helper out of component module * feat(networking): complete Compose-first Networking operator page Finish the node-scoped Networking page (Overview, Networks, Topology, Findings) with design-system parity and correct finding semantics. - Rebuild detail sheets on SystemSheet/SheetSection; align the tab band, masthead, and mobile tone with Fleet and Security. - Encode the host-mode and exposure severity matrix; fix collision counts so intentional shared externals are not flagged; add one typed drift predicate shared by inventory, topology, badges, and overview counts. - Preserve per-container attachments and IPs on topology node clicks; drawer-only click with an explicit logs action; ownership and boolean filters; bound large graphs before layout. - Aggregate cached Compose Doctor findings into the Findings tab with honest source labels, structural merge and dedupe, staleness reconciliation, and a shared exposure-context helper both engines use. - Networks tab: privacy-safe service search, precise ownership counts, schema v3 with version-2 adapters on every endpoint, pre-confirm delete reasons, and the shared sortable table with an internal scroll region. - Interop: Fleet node-card networking signal with pending-intent navigation, stack-to-node backlink, and Dossier/Drift deep links. - Enrich sanitized inspect with an allowlisted connected-container list; fetch topology once and filter client-side. - Docs and tests across every new finding kind, adapter, and flow. * fix(networking): correct drift count, exposure fail-soft, and inspect crash paths Address code-review findings on the Networking page implementation: - Fix the Overview drift count to use the shared drift-kind predicate instead of a hardcoded list that omitted external-network-missing. - Gate Compose Doctor's unclassified-exposure and reverse-proxy-undocumented rules on exposure-context availability, so a DB read failure no longer fabricates findings (mirrors the live engine's existing fail-soft behavior). - Guard the per-stack exposure-intent read in topology aggregation so a transient DB failure degrades to unknown intent instead of failing the whole response. - Harden the network detail drawer against a partial inspect payload from an older remote node, and log the real error instead of a bare catch. - Remove now-duplicated severity-rank and drift-kind helpers in favor of the shared modules; drop dead backend-only exports; widen the frontend schema version type to a plain number instead of casting past a literal type. - Add coverage for the delete-guard precedence, the full host-mode severity matrix, the schema-2 compatibility adapter, and the sanitized connected- container allowlist; tighten two tests that were not exercising the behavior they claimed to. * fix: add missing onOpenNodeNetworking prop to FleetView experimental test The added required prop on FleetViewProps broke the merge-build when the test file (on main but not on this branch) was compiled against the updated FleetView interface.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Doctor networking findings adapter: reads ONLY cached Compose Doctor reports
|
||||
* (never triggers a fresh preflight run), merges overlapping findings into the
|
||||
* matching live card, and surfaces Doctor-only rules as standalone findings.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { ComposeDoctorService } from '../services/ComposeDoctorService';
|
||||
import { applyDoctorNetworkingFindings } from '../services/network/doctorNetworkingFindings';
|
||||
import type { NetworkingFinding } from '../services/network/networkingTypes';
|
||||
import type { StackNetworkFacts } from '../services/network/types';
|
||||
import type { PreflightReport } from '../services/preflight/types';
|
||||
|
||||
function stubReport(overrides: Partial<PreflightReport> & { findings: PreflightReport['findings'] }): PreflightReport {
|
||||
return {
|
||||
stack: 'stack1',
|
||||
ranAt: Date.now(),
|
||||
ranBy: 'admin',
|
||||
renderable: true,
|
||||
renderError: null,
|
||||
status: 'high',
|
||||
highestSeverity: 'high',
|
||||
sourceHash: 'h', renderedHash: 'h',
|
||||
activeStatus: 'high', activeHighestSeverity: 'high', activeCount: overrides.findings.length, acknowledgedCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function stubFacts(overrides: Partial<StackNetworkFacts> = {}): StackNetworkFacts {
|
||||
return {
|
||||
stack: 'stack1',
|
||||
renderable: true,
|
||||
renderError: null,
|
||||
runtime: 'available',
|
||||
networks: [],
|
||||
services: [{ name: 'web', networks: [], publishedPorts: [], extraHosts: [] }],
|
||||
drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function liveHostFinding(stack: string, service: string): NetworkingFinding {
|
||||
return {
|
||||
id: 'live-host-1',
|
||||
kind: 'network-mode-host',
|
||||
severity: 'medium',
|
||||
title: 'Host network mode',
|
||||
message: `Service "${service}" uses network_mode: host.`,
|
||||
stack,
|
||||
service,
|
||||
evidence: [],
|
||||
recommendedActions: [{ kind: 'open-stack-networking', label: 'Open stack networking', stack }],
|
||||
sources: ['live'],
|
||||
doctorFindings: [],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('applyDoctorNetworkingFindings', () => {
|
||||
it('never calls runPreflight; reads only getLatest', () => {
|
||||
const getLatest = vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({ stack: 'stack1', findings: [] })),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
applyDoctorNetworkingFindings([], { nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null });
|
||||
|
||||
expect(getLatest).toHaveBeenCalled();
|
||||
const instance = getLatest.mock.results[0].value as { getLatest: unknown; runPreflight?: unknown };
|
||||
expect(instance.runPreflight).toBeUndefined();
|
||||
});
|
||||
|
||||
it('merges a Doctor host-mode finding into the matching live card instead of duplicating it', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [{
|
||||
ruleId: 'network-mode-host', severity: 'high', title: 'Host mode', message: 'uses host networking',
|
||||
service: 'web', sourcePath: 'services.web.network_mode',
|
||||
}],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const live = [liveHostFinding('stack1', 'web')];
|
||||
const result = applyDoctorNetworkingFindings(live, {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sources).toEqual(['live', 'doctor']);
|
||||
expect(result[0].doctorFindings).toHaveLength(1);
|
||||
expect(result[0].severity).toBe('medium'); // canonical severity stays the live one
|
||||
expect(result[0].doctorFindings[0].severity).toBe('high'); // Doctor's own severity is preserved
|
||||
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-doctor')).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces a Doctor-only rule (no live counterpart) as a standalone finding', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [{
|
||||
ruleId: 'sensitive-service-broad-exposure', severity: 'high',
|
||||
title: 'Sensitive service broadly exposed', message: 'db is broadly exposed', service: 'db',
|
||||
}],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'],
|
||||
stackFacts: [stubFacts({ services: [{ name: 'db', networks: [], publishedPorts: [], extraHosts: [] }] })],
|
||||
snapshot: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].sources).toEqual(['doctor']);
|
||||
expect(result[0].kind).toBe('sensitive-service-broad-exposure');
|
||||
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-doctor')).toBe(true);
|
||||
expect(result[0].recommendedActions.some((a) => a.kind === 'open-stack-networking')).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses two occurrences on the SAME service into one merged card (one-to-many)', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [
|
||||
{ ruleId: 'port-conflict-internal', severity: 'warning', title: 'Port conflict', message: 'port 80 conflicts', service: 'web' },
|
||||
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 443 conflicts', service: 'web' },
|
||||
],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].doctorFindings).toHaveLength(2);
|
||||
// Mixed severities: the merged card takes the worst (blocker -> critical).
|
||||
expect(result[0].severity).toBe('critical');
|
||||
});
|
||||
|
||||
it('gives distinct services distinct cards, never collapsing them together', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [
|
||||
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 80 conflicts', service: 'web' },
|
||||
{ ruleId: 'port-conflict-internal', severity: 'blocker', title: 'Port conflict', message: 'port 90 conflicts', service: 'api' },
|
||||
],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'],
|
||||
stackFacts: [stubFacts({ services: [
|
||||
{ name: 'web', networks: [], publishedPorts: [], extraHosts: [] },
|
||||
{ name: 'api', networks: [], publishedPorts: [], extraHosts: [] },
|
||||
] })],
|
||||
snapshot: null,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(new Set(result.map((f) => f.id)).size).toBe(2);
|
||||
});
|
||||
|
||||
it('excludes acknowledged findings', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [{
|
||||
ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm',
|
||||
service: 'db', acknowledged: true,
|
||||
}],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('discards a stale finding when the referenced service no longer exists', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({
|
||||
stack: 'stack1',
|
||||
findings: [{
|
||||
ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm', service: 'removed-service',
|
||||
}],
|
||||
})),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is fail-soft: a getLatest() failure for one stack does not throw and other stacks still contribute', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockImplementation((_nodeId: number, stack: string) => {
|
||||
if (stack === 'broken') throw new Error('db unavailable');
|
||||
return stubReport({
|
||||
stack,
|
||||
findings: [{ ruleId: 'sensitive-service-broad-exposure', severity: 'high', title: 't', message: 'm', service: 'web' }],
|
||||
});
|
||||
}),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
expect(() => applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['broken', 'stack1'], stackFacts: [stubFacts({ stack: 'stack1' })], snapshot: null,
|
||||
})).not.toThrow();
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['broken', 'stack1'], stackFacts: [stubFacts({ stack: 'stack1' })], snapshot: null,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].stack).toBe('stack1');
|
||||
});
|
||||
|
||||
it('never-run stacks are absent (no "never ran" nagging finding)', () => {
|
||||
vi.spyOn(ComposeDoctorService, 'getInstance').mockReturnValue({
|
||||
getLatest: vi.fn().mockReturnValue(stubReport({ stack: 'stack1', status: 'never-run', findings: [] })),
|
||||
} as unknown as ComposeDoctorService);
|
||||
|
||||
const result = applyDoctorNetworkingFindings([], {
|
||||
nodeId: 1, stackNames: ['stack1'], stackFacts: [stubFacts()], snapshot: null,
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Complete host-mode severity matrix as a pure-function unit test, so a
|
||||
* refactor that inverts one intent's severity is caught without the full route.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hostModeSeverity } from '../services/network/networkingFindings';
|
||||
import type { ExposureIntent } from '../services/network/types';
|
||||
|
||||
describe('hostModeSeverity matrix', () => {
|
||||
// Contradiction rows: high regardless of whether the Dossier documents access.
|
||||
const contradiction: (ExposureIntent | null)[] = ['internal', 'same-node', 'unknown', null];
|
||||
for (const intent of contradiction) {
|
||||
it(`${intent ?? 'unset'} is high with and without documentation`, () => {
|
||||
expect(hostModeSeverity(intent, false)).toBe('high');
|
||||
expect(hostModeSeverity(intent, true)).toBe('high');
|
||||
});
|
||||
}
|
||||
|
||||
// Deliberate-exposure rows: medium undocumented, downgraded to info once a
|
||||
// Dossier access URL documents the exposure.
|
||||
const deliberate: ExposureIntent[] = ['lan', 'public', 'reverse-proxy', 'temporary'];
|
||||
for (const intent of deliberate) {
|
||||
it(`${intent} is medium undocumented and info once documented`, () => {
|
||||
expect(hostModeSeverity(intent, false)).toBe('medium');
|
||||
expect(hostModeSeverity(intent, true)).toBe('info');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Node networking operator routes: auth boundaries, aggregate reads, delete guards.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { evaluateNetworkDeleteGuard } from '../services/network/networkDeleteGuards';
|
||||
import SelfIdentityService from '../services/SelfIdentityService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
const STACK = 'netop';
|
||||
|
||||
function token(username: string, role: string, tokenVersion = 1): string {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(username);
|
||||
return `Bearer ${jwt.sign(
|
||||
{ username, role, tokenVersion: user?.token_version ?? tokenVersion },
|
||||
TEST_JWT_SECRET,
|
||||
{ expiresIn: '5m' },
|
||||
)}`;
|
||||
}
|
||||
|
||||
const NET_ID = 'abcdefabcdef';
|
||||
|
||||
function stubAggregate() {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({
|
||||
containers: [],
|
||||
networks: [
|
||||
{ id: NET_ID, name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null },
|
||||
],
|
||||
volumes: [],
|
||||
}),
|
||||
inspectNetwork: vi.fn().mockResolvedValue({
|
||||
Id: NET_ID,
|
||||
Name: 'orphan_net',
|
||||
Driver: 'bridge',
|
||||
Scope: 'local',
|
||||
Labels: { 'com.example.key': 'secret-value' },
|
||||
Containers: {},
|
||||
}),
|
||||
removeNetwork: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as DockerController);
|
||||
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({
|
||||
rendered: JSON.stringify({
|
||||
name: STACK,
|
||||
services: { web: { image: 'nginx:latest', networks: { backend: null } } },
|
||||
networks: { backend: { name: `${STACK}_backend` } },
|
||||
volumes: {},
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
timedOut: false,
|
||||
}),
|
||||
} as unknown as ComposeService);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authHeader = token(TEST_USERNAME, 'admin');
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('networking operator routes', () => {
|
||||
let stackDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
|
||||
stubAggregate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps GET /api/networking/summary auth-only for viewers', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addUser({ username: 'net-viewer', password_hash: 'x', role: 'viewer' });
|
||||
const viewerHeader = token('net-viewer', 'viewer');
|
||||
const res = await request(app).get('/api/networking/summary').set('Authorization', viewerHeader);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('requires authentication on new overview route and allows stack:read roles', async () => {
|
||||
expect((await request(app).get('/api/networking/overview')).status).toBe(401);
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
db.addUser({ username: 'net-viewer2', password_hash: 'x', role: 'viewer' });
|
||||
const viewerRes = await request(app).get('/api/networking/overview').set('Authorization', token('net-viewer2', 'viewer'));
|
||||
expect(viewerRes.status).toBe(200);
|
||||
|
||||
const ok = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.schemaVersion).toBe(3);
|
||||
expect(ok.body.runtimeAvailable).toBe(true);
|
||||
expect(ok.body.overview).toBeDefined();
|
||||
expect(Array.isArray(ok.body.networks)).toBe(true);
|
||||
expect(Array.isArray(ok.body.findings)).toBe(true);
|
||||
});
|
||||
|
||||
it('sanitized network inspect returns label keys only', async () => {
|
||||
const res = await request(app).get(`/api/networking/networks/${NET_ID}`).set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.schemaVersion).toBe(3);
|
||||
expect(res.body.network.labelKeys).toEqual(['com.example.key']);
|
||||
expect(JSON.stringify(res.body)).not.toContain('secret-value');
|
||||
});
|
||||
|
||||
it('returns a degraded schema envelope when Docker networking is unavailable', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockRejectedValue(new Error('runtime unavailable')),
|
||||
} as unknown as DockerController);
|
||||
|
||||
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
schemaVersion: 3,
|
||||
runtimeAvailable: false,
|
||||
networks: [],
|
||||
});
|
||||
expect(res.body.findings).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'runtime-unavailable', severity: 'info' }),
|
||||
]));
|
||||
expect(res.body.findings.every((finding: { severity: string }) => !['warning', 'error'].includes(finding.severity))).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks admin delete when network is attached', async () => {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({
|
||||
containers: [{ id: 'c1', name: 'web', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'nginx', networks: [{ name: 'orphan_net', id: NET_ID, ip: '' }], volumes: [], ports: [] }],
|
||||
networks: [{ id: NET_ID, name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
|
||||
volumes: [],
|
||||
}),
|
||||
removeNetwork: vi.fn(),
|
||||
} as unknown as DockerController);
|
||||
|
||||
const removeSpy = vi.spyOn(DockerController.getInstance(1), 'removeNetwork');
|
||||
const res = await request(app)
|
||||
.post('/api/system/networks/delete')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ id: NET_ID });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('attached');
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifySnapshotNetworks', () => {
|
||||
it('preserves composeProject on external ownership rows', () => {
|
||||
const snapshot = {
|
||||
containers: [],
|
||||
networks: [{
|
||||
id: 'ext1',
|
||||
name: 'external_shared',
|
||||
driver: 'bridge',
|
||||
scope: 'local',
|
||||
isSystem: false,
|
||||
composeProject: 'other-project',
|
||||
stack: null,
|
||||
}],
|
||||
volumes: [],
|
||||
};
|
||||
const rows = DockerController.classifySnapshotNetworks(snapshot, ['localstack']);
|
||||
expect(rows[0].composeProject).toBe('other-project');
|
||||
expect(rows[0].stack).toBeNull();
|
||||
expect(rows[0].ownership).toBe('compose-managed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluateNetworkDeleteGuard', () => {
|
||||
it('fails closed with stack-declaration-unknown when stacks are unrenderable', () => {
|
||||
const snapshot = {
|
||||
containers: [],
|
||||
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
|
||||
volumes: [],
|
||||
};
|
||||
const guard = evaluateNetworkDeleteGuard('n1', snapshot, [
|
||||
{ stack: STACK, renderable: false, renderError: 'x', runtime: 'available', networks: [], services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] } },
|
||||
]);
|
||||
expect(guard.blocked).toBe(true);
|
||||
expect(guard.code).toBe('stack-declaration-unknown');
|
||||
});
|
||||
|
||||
it('blocks a system network ahead of every other reason', () => {
|
||||
const snapshot = {
|
||||
containers: [], volumes: [],
|
||||
networks: [{ id: 'sys', name: 'bridge', driver: 'bridge', scope: 'local', isSystem: true, composeProject: null, stack: null }],
|
||||
};
|
||||
expect(evaluateNetworkDeleteGuard('sys', snapshot, []).code).toBe('system-network');
|
||||
});
|
||||
|
||||
it('blocks a Sencho-owned network', () => {
|
||||
const spy = vi.spyOn(SelfIdentityService.getInstance(), 'isOwnNetwork').mockReturnValue(true);
|
||||
try {
|
||||
const snapshot = {
|
||||
containers: [], volumes: [],
|
||||
networks: [{ id: 'own', name: 'sencho_mesh', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
|
||||
};
|
||||
expect(evaluateNetworkDeleteGuard('own', snapshot, []).code).toBe('sencho-owned');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks a network that still has an attached container', () => {
|
||||
const snapshot = {
|
||||
volumes: [],
|
||||
containers: [{ id: 'c1', name: 'web', service: 'web', composeProject: STACK, stack: STACK, state: 'running', image: 'img', networks: [{ name: 'app_net', id: 'n1', ip: '' }], volumes: [], ports: [] }],
|
||||
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
|
||||
};
|
||||
expect(evaluateNetworkDeleteGuard('n1', snapshot, []).code).toBe('attached');
|
||||
});
|
||||
|
||||
it('blocks a network a renderable stack declares', () => {
|
||||
const snapshot = {
|
||||
containers: [], volumes: [],
|
||||
networks: [{ id: 'n1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: STACK, stack: STACK }],
|
||||
};
|
||||
const guard = evaluateNetworkDeleteGuard('n1', snapshot, [
|
||||
{ stack: STACK, renderable: true, renderError: null, runtime: 'available',
|
||||
networks: [{ key: 'app_net', name: 'app_net', external: false, internal: false, createdByStack: true }],
|
||||
services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] } },
|
||||
]);
|
||||
expect(guard.code).toBe('stack-declared');
|
||||
});
|
||||
|
||||
it('allows deleting an unattached, undeclared network', () => {
|
||||
const snapshot = {
|
||||
containers: [], volumes: [],
|
||||
networks: [{ id: 'n1', name: 'orphan_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }],
|
||||
};
|
||||
expect(evaluateNetworkDeleteGuard('n1', snapshot, [])).toEqual({ blocked: false });
|
||||
});
|
||||
|
||||
it('does not block when the network is absent from the snapshot', () => {
|
||||
expect(evaluateNetworkDeleteGuard('ghost', { containers: [], networks: [], volumes: [] }, [])).toEqual({ blocked: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Severity matrix and collision correctness for the live Networking findings
|
||||
* engine: host-mode severity across exposure intents (with/without a
|
||||
* documented Dossier access URL), and the network-name-collision fix that
|
||||
* must not flag intentional shared-external-network declarations.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authHeader: string;
|
||||
|
||||
function renderedModel(stack: string, opts: { networkMode?: string; hostIp?: string } = {}) {
|
||||
const service: Record<string, unknown> = { image: 'nginx:latest' };
|
||||
if (opts.networkMode) {
|
||||
service.network_mode = opts.networkMode;
|
||||
} else {
|
||||
service.ports = [{ published: '8080', target: '80', host_ip: opts.hostIp ?? '' }];
|
||||
}
|
||||
return {
|
||||
rendered: JSON.stringify({
|
||||
name: stack,
|
||||
services: { web: service },
|
||||
networks: {},
|
||||
volumes: {},
|
||||
}),
|
||||
stderr: '',
|
||||
code: 0,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
function stubEmptySnapshot() {
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({ containers: [], networks: [], volumes: [] }),
|
||||
} as unknown as DockerController);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function writeStack(stack: string) {
|
||||
const dir = path.join(process.env.COMPOSE_DIR as string, stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx:latest\n');
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function findingsFor(stack: string): Promise<Array<{ kind: string; severity: string; stack?: string }>> {
|
||||
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
return (res.body.findings as Array<{ kind: string; severity: string; stack?: string }>)
|
||||
.filter((f) => f.stack === stack);
|
||||
}
|
||||
|
||||
describe('networking host-mode severity matrix', () => {
|
||||
const STACK = 'sevhost';
|
||||
let stackDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
stackDir = writeStack(STACK);
|
||||
stubEmptySnapshot();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
DatabaseService.getInstance().deleteStackExposureIntents(1, STACK);
|
||||
DatabaseService.getInstance().deleteStackDossier(1, STACK);
|
||||
fs.rmSync(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('unset intent is high severity for network_mode: host', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
|
||||
} as unknown as ComposeService);
|
||||
const findings = await findingsFor(STACK);
|
||||
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
|
||||
expect(hostFinding?.severity).toBe('high');
|
||||
});
|
||||
|
||||
it('internal intent is high severity for network_mode: host regardless of documentation', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
|
||||
} as unknown as ComposeService);
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'internal', null);
|
||||
DatabaseService.getInstance().upsertStackDossier(1, STACK, {
|
||||
purpose: '', owner: '', access_urls: 'http://localhost:8080', static_ip: '', vlan: '',
|
||||
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '',
|
||||
recovery_notes: '', custom_notes: '',
|
||||
});
|
||||
const findings = await findingsFor(STACK);
|
||||
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
|
||||
expect(hostFinding?.severity).toBe('high');
|
||||
});
|
||||
|
||||
it('lan intent without documented access is medium severity', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
|
||||
} as unknown as ComposeService);
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'lan', null);
|
||||
const findings = await findingsFor(STACK);
|
||||
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
|
||||
expect(hostFinding?.severity).toBe('medium');
|
||||
});
|
||||
|
||||
it('lan intent with a documented Dossier access URL downgrades to info', async () => {
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue(renderedModel(STACK, { networkMode: 'host' })),
|
||||
} as unknown as ComposeService);
|
||||
DatabaseService.getInstance().setStackExposureIntent(1, STACK, '', 'lan', null);
|
||||
DatabaseService.getInstance().upsertStackDossier(1, STACK, {
|
||||
purpose: '', owner: '', access_urls: 'http://localhost:8080', static_ip: '', vlan: '',
|
||||
firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', upgrade_notes: '',
|
||||
recovery_notes: '', custom_notes: '',
|
||||
});
|
||||
const findings = await findingsFor(STACK);
|
||||
const hostFinding = findings.find((f) => f.kind === 'network-mode-host');
|
||||
expect(hostFinding?.severity).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('networking collision correctness', () => {
|
||||
const STACK_A = 'colla';
|
||||
const STACK_B = 'collb';
|
||||
let dirA: string;
|
||||
let dirB: string;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(dirA, { recursive: true, force: true });
|
||||
fs.rmSync(dirB, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('two stacks declaring the SAME external network are not a name collision', async () => {
|
||||
dirA = writeStack(STACK_A);
|
||||
dirB = writeStack(STACK_B);
|
||||
stubEmptySnapshot();
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockImplementation((stack: string) => Promise.resolve({
|
||||
rendered: JSON.stringify({
|
||||
name: stack,
|
||||
services: { web: { image: 'nginx:latest' } },
|
||||
networks: { edge: { name: 'edge-net', external: true } },
|
||||
volumes: {},
|
||||
}),
|
||||
stderr: '', code: 0, timedOut: false,
|
||||
})),
|
||||
} as unknown as ComposeService);
|
||||
|
||||
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
const findings = res.body.findings as Array<{ kind: string; network?: string }>;
|
||||
expect(findings.some((f) => f.kind === 'network-name-collision' && f.network === 'edge-net')).toBe(false);
|
||||
});
|
||||
|
||||
it('two stacks declaring a non-external network with the same forced literal name IS a collision', async () => {
|
||||
dirA = writeStack(STACK_A);
|
||||
dirB = writeStack(STACK_B);
|
||||
stubEmptySnapshot();
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockImplementation((stack: string) => Promise.resolve({
|
||||
rendered: JSON.stringify({
|
||||
name: stack,
|
||||
services: { web: { image: 'nginx:latest' } },
|
||||
networks: { app: { name: 'shared-literal' } },
|
||||
volumes: {},
|
||||
}),
|
||||
stderr: '', code: 0, timedOut: false,
|
||||
})),
|
||||
} as unknown as ComposeService);
|
||||
|
||||
const res = await request(app).get('/api/networking/overview').set('Authorization', authHeader);
|
||||
expect(res.status).toBe(200);
|
||||
const findings = res.body.findings as Array<{ kind: string; network?: string }>;
|
||||
expect(findings.some((f) => f.kind === 'network-name-collision' && f.network === 'shared-literal')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ function ctx(over: Partial<PreflightContext> = {}): PreflightContext {
|
||||
nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(),
|
||||
existingContainers: [], nodeStateAvailable: true, bindChecks: [],
|
||||
stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false,
|
||||
exposureAvailable: true,
|
||||
isSelfStack: false, ...over,
|
||||
};
|
||||
}
|
||||
@@ -354,6 +355,15 @@ describe('exposure-intent rules', () => {
|
||||
it('does not warn unclassified when no port is published', () => {
|
||||
expect(ids(runRules(ctx({ model: model([svc()]), stackIntent: null })), 'exposure-unclassified')).toHaveLength(0);
|
||||
});
|
||||
it('does not fabricate intent findings when the exposure context is unavailable', () => {
|
||||
// A DB read failure leaves every intent null and no access URLs; the
|
||||
// interpretation rules must stay silent rather than read that as unclassified
|
||||
// or undocumented.
|
||||
const rp = model([svc({ name: 'web', labelKeys: ['traefik.enable'], ports: [{ startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }] })]);
|
||||
const f = runRules(ctx({ model: rp, exposureAvailable: false }));
|
||||
expect(ids(f, 'exposure-unclassified')).toHaveLength(0);
|
||||
expect(ids(f, 'reverse-proxy-undocumented')).toHaveLength(0);
|
||||
});
|
||||
it('flags a published port absent from the documented access URLs', () => {
|
||||
const f = runRules(ctx({ model: withPort(), hasAccessUrls: true, accessUrlPorts: new Set([443]) }));
|
||||
expect(ids(f, 'exposure-port-vs-dossier')).toHaveLength(1);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The connected-container section of the sanitized inspect DTO must expose only
|
||||
* the allowlisted fields and must never leak label values, MAC addresses, or
|
||||
* endpoint IDs from the raw Docker inspect payload.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeNetworkInspect } from '../services/network/sanitizeNetworkInspect';
|
||||
import type { DependencySnapshot } from '../services/DockerController';
|
||||
|
||||
describe('sanitizeNetworkInspect connected containers', () => {
|
||||
it('exposes only name/service/stack/ipv4 (CIDR stripped) and no raw label values or MAC/endpoint data', () => {
|
||||
const snapshot: DependencySnapshot = {
|
||||
networks: [{ id: 'net1', name: 'app_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'app', stack: 'app' }],
|
||||
volumes: [],
|
||||
containers: [{
|
||||
id: 'c1', name: 'app-web-1', service: 'web', composeProject: 'app', stack: 'app', state: 'running', image: 'nginx',
|
||||
networks: [{ name: 'app_net', id: 'net1', ip: '172.20.0.5/16' }], volumes: [], ports: [],
|
||||
}],
|
||||
};
|
||||
const raw = {
|
||||
Id: 'net1', Name: 'app_net', Driver: 'bridge', Scope: 'local',
|
||||
Labels: { 'com.docker.compose.project': 'app', 'secret.token': 'do-not-leak' },
|
||||
Containers: { c1: { Name: 'app-web-1', MacAddress: '02:42:ac:14:00:05', EndpointID: 'endpoint-xyz', IPv4Address: '172.20.0.5/16' } },
|
||||
};
|
||||
|
||||
const result = sanitizeNetworkInspect(raw, snapshot.networks[0], snapshot);
|
||||
|
||||
expect(result.connectedContainers).toEqual([
|
||||
{ name: 'app-web-1', service: 'web', stack: 'app', ipv4: '172.20.0.5' },
|
||||
]);
|
||||
// Label keys are exposed, values never are.
|
||||
expect(result.labelKeys).toContain('secret.token');
|
||||
const serialized = JSON.stringify(result);
|
||||
expect(serialized).not.toContain('do-not-leak');
|
||||
expect(serialized).not.toContain('02:42:ac:14:00:05');
|
||||
expect(serialized).not.toContain('endpoint-xyz');
|
||||
});
|
||||
|
||||
it('yields an empty connected list when no snapshot is provided', () => {
|
||||
const result = sanitizeNetworkInspect({ Id: 'net1', Name: 'app_net' }, undefined, undefined);
|
||||
expect(result.connectedContainers).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,13 @@ function stubDockerControllerNoops() {
|
||||
removeNetwork: vi.fn().mockResolvedValue(undefined),
|
||||
removeVolume: vi.fn().mockResolvedValue(undefined),
|
||||
removeContainers: vi.fn().mockResolvedValue([]),
|
||||
getDependencySnapshot: vi.fn().mockResolvedValue({
|
||||
containers: [],
|
||||
networks: [
|
||||
{ id: OTHER_NETWORK, name: 'other_net', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null },
|
||||
],
|
||||
volumes: [],
|
||||
}),
|
||||
};
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
return fake;
|
||||
|
||||
Reference in New Issue
Block a user