mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
8980910153
* 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.
811 lines
32 KiB
TypeScript
811 lines
32 KiB
TypeScript
import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBinding } from './types';
|
|
import type { EffService, EffPortSpec } from './effectiveModel';
|
|
import type { ExposureIntent } from '../network/types';
|
|
import { isLoopback, runtimeResourceName } from '../network/normalize';
|
|
|
|
/** Higher number = more severe. Used to derive a run's overall status. */
|
|
export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warning: 1, high: 2, blocker: 3 };
|
|
|
|
/** The one rule whose message doubles as the report's render error. Shared so the
|
|
* service that reconstructs renderError from it cannot drift from the rule id. */
|
|
export const RENDER_FAILED_RULE_ID = 'render-failed';
|
|
|
|
export interface PreflightRule {
|
|
id: string;
|
|
run(ctx: PreflightContext): PreflightFinding[];
|
|
}
|
|
|
|
// ----- shared helpers -------------------------------------------------------
|
|
|
|
const MAX_RANGE = 256; // cap range expansion so an adversarial 1-65535 spec can't blow up
|
|
|
|
function isAllInterfaces(ip: string): boolean {
|
|
return ip === '' || ip === '0.0.0.0' || ip === '::' || ip === '[::]';
|
|
}
|
|
|
|
function interfaceOverlap(a: string, b: string): boolean {
|
|
return isAllInterfaces(a) || isAllInterfaces(b) || a === b;
|
|
}
|
|
|
|
function portsOf(spec: EffPortSpec): number[] {
|
|
const end = Math.min(spec.endPort, spec.startPort + MAX_RANGE - 1);
|
|
const out: number[] = [];
|
|
for (let p = spec.startPort; p <= end; p++) out.push(p);
|
|
return out;
|
|
}
|
|
|
|
function specLabel(spec: EffPortSpec): string {
|
|
return spec.startPort === spec.endPort ? `${spec.startPort}` : `${spec.startPort}-${spec.endPort}`;
|
|
}
|
|
|
|
/** True when the image reference resolves to a moving `latest` tag. */
|
|
function usesLatestTag(image: string): boolean {
|
|
if (image.includes('@sha256:')) return false; // digest-pinned
|
|
const lastSlash = image.lastIndexOf('/');
|
|
const lastColon = image.lastIndexOf(':');
|
|
if (lastColon > lastSlash) return image.slice(lastColon + 1) === 'latest';
|
|
return true; // no tag → implicit latest
|
|
}
|
|
|
|
const UID_GID_KEYS = new Set(['PUID', 'PGID', 'UID', 'GID']);
|
|
function hasUidGidSignal(svc: EffService): boolean {
|
|
return svc.user !== undefined || svc.envKeys.some(k => UID_GID_KEYS.has(k));
|
|
}
|
|
|
|
// ----- rules ----------------------------------------------------------------
|
|
|
|
const renderFailed: PreflightRule = {
|
|
id: RENDER_FAILED_RULE_ID,
|
|
run(ctx) {
|
|
if (ctx.renderable) return [];
|
|
return [{
|
|
ruleId: RENDER_FAILED_RULE_ID,
|
|
severity: 'blocker',
|
|
title: 'Compose model could not be rendered',
|
|
message: ctx.renderError ?? 'docker compose config failed to produce an effective model.',
|
|
remediation: 'Fix the reported error. Sencho cannot validate a stack it cannot render.',
|
|
}];
|
|
},
|
|
};
|
|
|
|
const envUnset: PreflightRule = {
|
|
id: 'env-unset',
|
|
run(ctx) {
|
|
return ctx.unsetEnvVars.map(name => ({
|
|
ruleId: 'env-unset',
|
|
severity: 'high' as const,
|
|
title: `Unset variable ${name}`,
|
|
message: `"${name}" is referenced by the Compose model but is not set in the environment or any consulted env file. Compose substitutes an empty string, which often breaks the container silently.`,
|
|
sourcePath: name,
|
|
remediation: `Define ${name} in a .env or env_file, or give it a default with \${${name}:-value}. If the value is a literal secret or hash containing \`$\`, escape \`$\` as \`$$\` in Compose YAML or single-quote the value in an env file.`,
|
|
}));
|
|
},
|
|
};
|
|
|
|
const LITERAL_DOLLAR_REMEDIATION =
|
|
'If this was intended as a Compose variable, define it in the project environment or give it a default. '
|
|
+ 'If it is part of a literal secret or hash, escape literal dollar signs as `$$` in Compose YAML or single-quote the value in an env file.';
|
|
|
|
const envLiteralDollar: PreflightRule = {
|
|
id: 'env-literal-dollar',
|
|
run(ctx) {
|
|
return ctx.literalDollarWarnings.map(w => {
|
|
const likelySecret = w.likelySecret;
|
|
const title = likelySecret
|
|
? 'Literal dollar sign in likely secret value may be interpolated'
|
|
: 'Literal dollar sign in environment value may be interpolated';
|
|
const keyHint = w.envKey ? ` for "${w.envKey}"` : '';
|
|
return {
|
|
ruleId: 'env-literal-dollar',
|
|
severity: 'high' as const,
|
|
title,
|
|
message: `Compose treated a literal $ sequence inside an environment value${keyHint} as variable interpolation and may substitute an empty string for part of the value.`,
|
|
sourcePath: w.envKey,
|
|
remediation: LITERAL_DOLLAR_REMEDIATION,
|
|
service: w.service,
|
|
};
|
|
});
|
|
},
|
|
};
|
|
|
|
const envFileMissing: PreflightRule = {
|
|
id: 'env-file-missing',
|
|
run(ctx) {
|
|
return ctx.missingEnvFiles.map(f => ({
|
|
ruleId: 'env-file-missing',
|
|
severity: 'high' as const,
|
|
title: `Missing env file ${f.rawPath}`,
|
|
message: `The Compose file declares env_file "${f.rawPath}"${f.services.length ? ` for service ${f.services.join(', ')}` : ''}, but no such file exists in the stack directory. Compose fails to start the stack when a required env_file is absent.`,
|
|
sourcePath: f.rawPath,
|
|
remediation: `Create ${f.rawPath} in the stack directory, fix the path, or mark the entry optional with "required: false".`,
|
|
service: f.services[0],
|
|
}));
|
|
},
|
|
};
|
|
|
|
const portConflictNode: PreflightRule = {
|
|
id: 'port-conflict-node',
|
|
run(ctx) {
|
|
if (!ctx.model || !ctx.nodeStateAvailable) return [];
|
|
const byPort = new Map<number, NodePortBinding[]>();
|
|
for (const b of ctx.nodePorts) {
|
|
const list = byPort.get(b.publishedPort);
|
|
if (list) list.push(b); else byPort.set(b.publishedPort, [b]);
|
|
}
|
|
const findings: PreflightFinding[] = [];
|
|
for (const svc of ctx.model.services) {
|
|
for (const spec of svc.ports) {
|
|
for (const port of portsOf(spec)) {
|
|
const clash = (byPort.get(port) ?? []).find(b =>
|
|
b.protocol === spec.protocol && interfaceOverlap(spec.hostIp, b.ip) && b.stack !== ctx.stackName);
|
|
if (!clash) continue;
|
|
const owner = clash.stack ? `stack "${clash.stack}"` : 'another container';
|
|
findings.push({
|
|
ruleId: 'port-conflict-node',
|
|
severity: 'blocker',
|
|
title: `Host port ${port} is already in use`,
|
|
message: `Service "${svc.name}" publishes ${port}/${spec.protocol}, but ${owner} already binds that port on this node. The deploy will fail.`,
|
|
sourcePath: svc.name,
|
|
service: svc.name,
|
|
remediation: 'Stop the conflicting workload or publish a different host port.',
|
|
});
|
|
break; // one finding per service+spec is enough
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const portConflictInternal: PreflightRule = {
|
|
id: 'port-conflict-internal',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
const claims = new Map<string, { service: string; hostIp: string }[]>();
|
|
for (const svc of ctx.model.services) {
|
|
for (const spec of svc.ports) {
|
|
for (const port of portsOf(spec)) {
|
|
const key = `${port}/${spec.protocol}`;
|
|
const list = claims.get(key);
|
|
if (list) list.push({ service: svc.name, hostIp: spec.hostIp });
|
|
else claims.set(key, [{ service: svc.name, hostIp: spec.hostIp }]);
|
|
}
|
|
}
|
|
}
|
|
const findings: PreflightFinding[] = [];
|
|
for (const [key, list] of claims) {
|
|
const services = [...new Set(list.map(c => c.service))];
|
|
if (services.length < 2) continue;
|
|
const overlapping = list.some((a, i) => list.slice(i + 1).some(b => b.service !== a.service && interfaceOverlap(a.hostIp, b.hostIp)));
|
|
if (!overlapping) continue;
|
|
findings.push({
|
|
ruleId: 'port-conflict-internal',
|
|
severity: 'blocker',
|
|
title: `Two services publish ${key}`,
|
|
message: `Services ${services.map(s => `"${s}"`).join(' and ')} both publish host port ${key}. Only one can bind it, so the deploy will fail.`,
|
|
remediation: 'Give each service a distinct host port.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const portExposedAllInterfaces: PreflightRule = {
|
|
id: 'port-exposed-all-interfaces',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const svc of ctx.model.services) {
|
|
for (const spec of svc.ports) {
|
|
if (!isAllInterfaces(spec.hostIp)) continue;
|
|
findings.push({
|
|
ruleId: 'port-exposed-all-interfaces',
|
|
severity: 'high',
|
|
title: `Port ${specLabel(spec)} exposed on all interfaces`,
|
|
message: `Service "${svc.name}" publishes ${specLabel(spec)}/${spec.protocol} on all interfaces (0.0.0.0), so it is reachable from every network the host is attached to.`,
|
|
sourcePath: svc.name,
|
|
service: svc.name,
|
|
remediation: `Bind to a specific interface, e.g. 127.0.0.1:${spec.startPort}, if this should not be public.`,
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const bindPathMissing: PreflightRule = {
|
|
id: 'bind-path-missing',
|
|
run(ctx) {
|
|
return ctx.bindChecks
|
|
.filter(b => b.withinBase && !b.exists)
|
|
.map(b => ({
|
|
ruleId: 'bind-path-missing',
|
|
severity: 'high' as const,
|
|
title: 'Bind mount path is missing',
|
|
message: `The host path "${b.source}" for service "${b.service}" does not exist. Docker will create it as a root-owned directory on deploy, which often leaves the container unable to write to it.`,
|
|
sourcePath: b.source,
|
|
service: b.service,
|
|
remediation: 'Create the directory with the ownership the container expects before deploying.',
|
|
}));
|
|
},
|
|
};
|
|
|
|
const bindPathPermission: PreflightRule = {
|
|
id: 'bind-path-permission',
|
|
run(ctx) {
|
|
if (!ctx.model || ctx.platform === 'win32') return [];
|
|
const svcByName = new Map(ctx.model.services.map(s => [s.name, s]));
|
|
const findings: PreflightFinding[] = [];
|
|
for (const b of ctx.bindChecks) {
|
|
if (!b.withinBase || !b.exists || b.ownerUid !== 0) continue;
|
|
const svc = svcByName.get(b.service);
|
|
if (!svc || !hasUidGidSignal(svc)) continue;
|
|
findings.push({
|
|
ruleId: 'bind-path-permission',
|
|
severity: 'warning',
|
|
title: 'Bind mount may have wrong ownership',
|
|
message: `The host path "${b.source}" is owned by root, but service "${b.service}" runs as a non-root user. It may not be able to write there.`,
|
|
sourcePath: b.source,
|
|
service: b.service,
|
|
remediation: 'chown the path to the UID/GID the container runs as.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const dockerSocketMount: PreflightRule = {
|
|
id: 'docker-socket-mount',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const svc of ctx.model.services) {
|
|
const hit = svc.binds.some(b => b.source.includes('docker.sock') || b.target.includes('docker.sock'));
|
|
if (!hit) continue;
|
|
findings.push({
|
|
ruleId: 'docker-socket-mount',
|
|
severity: 'high',
|
|
title: 'Docker socket mounted',
|
|
message: `Service "${svc.name}" mounts the Docker socket, which grants it root-equivalent control over the host.`,
|
|
sourcePath: svc.name,
|
|
service: svc.name,
|
|
remediation: 'Avoid mounting docker.sock unless required; consider a scoped socket proxy.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const privileged: PreflightRule = {
|
|
id: 'privileged',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
return ctx.model.services.filter(s => s.privileged).map(s => ({
|
|
ruleId: 'privileged',
|
|
severity: 'high' as const,
|
|
title: 'Privileged container',
|
|
message: `Service "${s.name}" runs with privileged: true, which disables most container isolation.`,
|
|
sourcePath: s.name,
|
|
service: s.name,
|
|
remediation: 'Drop privileged and grant only the specific capabilities the service needs.',
|
|
}));
|
|
},
|
|
};
|
|
|
|
const networkModeHost: PreflightRule = {
|
|
id: 'network-mode-host',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
return ctx.model.services.filter(s => s.networkMode === 'host').map(s => ({
|
|
ruleId: 'network-mode-host',
|
|
severity: 'high' as const,
|
|
title: 'Host network mode',
|
|
message: `Service "${s.name}" uses network_mode: host. Its ports bypass Docker's network isolation and ignore published-port mappings.`,
|
|
sourcePath: s.name,
|
|
service: s.name,
|
|
remediation: 'Use bridge networking with explicit published ports unless host mode is required.',
|
|
}));
|
|
},
|
|
};
|
|
|
|
const uidGidRisk: PreflightRule = {
|
|
id: 'uid-gid-risk',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
// Only for binds whose ownership Sencho cannot verify (outside the compose
|
|
// base); within-base root-owned binds are covered by bind-path-permission.
|
|
const unverifiableByService = new Set(ctx.bindChecks.filter(b => !b.withinBase).map(b => b.service));
|
|
return ctx.model.services
|
|
.filter(s => hasUidGidSignal(s) && unverifiableByService.has(s.name))
|
|
.map(s => ({
|
|
ruleId: 'uid-gid-risk',
|
|
severity: 'warning' as const,
|
|
title: 'Check UID/GID alignment',
|
|
message: `Service "${s.name}" sets a user/UID and mounts host paths Sencho cannot inspect. Mismatched ownership between the host path and the container user is a common cause of permission errors.`,
|
|
sourcePath: s.name,
|
|
service: s.name,
|
|
remediation: 'Ensure the bind-mount paths are owned by the UID/GID the container runs as.',
|
|
}));
|
|
},
|
|
};
|
|
|
|
const imageLatest: PreflightRule = {
|
|
id: 'image-latest',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
return ctx.model.services
|
|
.filter(s => s.image !== undefined && usesLatestTag(s.image))
|
|
.map(s => ({
|
|
ruleId: 'image-latest',
|
|
severity: 'warning' as const,
|
|
title: 'Image uses a moving tag',
|
|
message: `Service "${s.name}" uses "${s.image}", which resolves to a moving latest tag. Deploys are not reproducible and can change under you.`,
|
|
sourcePath: s.name,
|
|
service: s.name,
|
|
remediation: 'Pin a specific version tag.',
|
|
}));
|
|
},
|
|
};
|
|
|
|
const noRestartPolicy: PreflightRule = {
|
|
id: 'no-restart-policy',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
// `restart: "no"` is Compose's default and means "do not restart", which
|
|
// `docker compose config` may render explicitly, so treat it as no policy.
|
|
return ctx.model.services
|
|
.filter(s => (!s.restart || s.restart === 'no') && !(s.deploy && s.deploy['restart_policy'] !== undefined))
|
|
.map(s => ({
|
|
ruleId: 'no-restart-policy',
|
|
severity: 'warning' as const,
|
|
title: 'No restart policy',
|
|
message: `Service "${s.name}" has no restart policy, so it will not come back after a crash or host reboot.`,
|
|
sourcePath: s.name,
|
|
service: s.name,
|
|
remediation: 'Add restart: unless-stopped.',
|
|
}));
|
|
},
|
|
};
|
|
|
|
const noHealthcheck: PreflightRule = {
|
|
id: 'no-healthcheck',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
return ctx.model.services
|
|
.filter(s => !s.hasHealthcheck)
|
|
.map(s => ({
|
|
ruleId: 'no-healthcheck',
|
|
severity: 'warning' as const,
|
|
title: 'No healthcheck',
|
|
message: `Service "${s.name}" declares no healthcheck, so Docker and Sencho cannot tell when it is actually ready (the image may still define one).`,
|
|
sourcePath: s.name,
|
|
service: s.name,
|
|
remediation: 'Add a healthcheck, or confirm the image provides one.',
|
|
}));
|
|
},
|
|
};
|
|
|
|
const SWARM_ONLY_DEPLOY_KEYS = ['placement', 'update_config', 'rollback_config', 'endpoint_mode'];
|
|
const deploySwarmOnly: PreflightRule = {
|
|
id: 'deploy-swarm-only',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const s of ctx.model.services) {
|
|
if (!s.deploy) continue;
|
|
const present = SWARM_ONLY_DEPLOY_KEYS.filter(k => s.deploy?.[k] !== undefined);
|
|
if (present.length === 0) continue;
|
|
findings.push({
|
|
ruleId: 'deploy-swarm-only',
|
|
severity: 'warning',
|
|
title: 'Swarm-only deploy fields',
|
|
message: `Service "${s.name}" sets deploy.${present.join(', deploy.')}, which standalone Compose ignores (these apply to Swarm).`,
|
|
sourcePath: s.name,
|
|
service: s.name,
|
|
remediation: 'Remove the Swarm-only deploy fields or move equivalent settings to their standalone keys.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const nodeStateUnavailable: PreflightRule = {
|
|
id: 'node-state-unavailable',
|
|
run(ctx) {
|
|
// Emit only when the model rendered but the node's Docker snapshot could not be
|
|
// read: an unrenderable model already raises its own render-failed blocker, so a
|
|
// second advisory there would be noise. The rules that read node state suppress
|
|
// themselves in this state; this finding tells the operator why, so a clean pass
|
|
// during an outage is not mistaken for full coverage. (The info-only
|
|
// new-network / new-volume notices are gated too, but left out of the message
|
|
// below: they preview a deploy action rather than flag a problem.)
|
|
if (!ctx.model || ctx.nodeStateAvailable) return [];
|
|
return [{
|
|
ruleId: 'node-state-unavailable',
|
|
severity: 'info',
|
|
title: 'Node-state checks skipped',
|
|
message: 'The node\'s Docker state could not be read, so the external-resource, host-port, and container_name checks did not run. This result is partial.',
|
|
remediation: 'Confirm the Docker daemon is reachable on this node, then re-run preflight.',
|
|
}];
|
|
},
|
|
};
|
|
|
|
const externalNetworkMissing: PreflightRule = {
|
|
id: 'external-network-missing',
|
|
run(ctx) {
|
|
if (!ctx.model || !ctx.nodeStateAvailable) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const [key, net] of Object.entries(ctx.model.networks)) {
|
|
if (!net.external || ctx.existingNetworkNames.has(net.name)) continue;
|
|
findings.push({
|
|
ruleId: 'external-network-missing',
|
|
severity: 'blocker',
|
|
title: 'External network not found',
|
|
message: `The model requires the external network "${net.name}", which does not exist on this node. The deploy will fail.`,
|
|
sourcePath: `networks.${key}`,
|
|
remediation: `Create it with: docker network create ${net.name}`,
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const externalVolumeMissing: PreflightRule = {
|
|
id: 'external-volume-missing',
|
|
run(ctx) {
|
|
if (!ctx.model || !ctx.nodeStateAvailable) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const [key, vol] of Object.entries(ctx.model.volumes)) {
|
|
if (!vol.external || ctx.existingVolumeNames.has(vol.name)) continue;
|
|
findings.push({
|
|
ruleId: 'external-volume-missing',
|
|
severity: 'blocker',
|
|
title: 'External volume not found',
|
|
message: `The model requires the external volume "${vol.name}", which does not exist on this node. The deploy will fail.`,
|
|
sourcePath: `volumes.${key}`,
|
|
remediation: `Create it with: docker volume create ${vol.name}`,
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const newNetwork: PreflightRule = {
|
|
id: 'new-network',
|
|
run(ctx) {
|
|
if (!ctx.model || !ctx.nodeStateAvailable) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const [key, net] of Object.entries(ctx.model.networks)) {
|
|
if (net.external || key === 'default') continue;
|
|
const expected = runtimeResourceName(ctx.model.projectName, key, net.name);
|
|
if (ctx.existingNetworkNames.has(expected)) continue;
|
|
findings.push({
|
|
ruleId: 'new-network',
|
|
severity: 'info',
|
|
title: 'New network will be created',
|
|
message: `Deploying will create the network "${expected}".`,
|
|
sourcePath: `networks.${key}`,
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const newVolume: PreflightRule = {
|
|
id: 'new-volume',
|
|
run(ctx) {
|
|
if (!ctx.model || !ctx.nodeStateAvailable) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const [key, vol] of Object.entries(ctx.model.volumes)) {
|
|
if (vol.external) continue;
|
|
const expected = runtimeResourceName(ctx.model.projectName, key, vol.name);
|
|
if (ctx.existingVolumeNames.has(expected)) continue;
|
|
findings.push({
|
|
ruleId: 'new-volume',
|
|
severity: 'info',
|
|
title: 'New volume will be created',
|
|
message: `Deploying will create the named volume "${expected}".`,
|
|
sourcePath: `volumes.${key}`,
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const anonymousVolume: PreflightRule = {
|
|
id: 'anonymous-volume',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const svc of ctx.model.services) {
|
|
const anon = (svc.storageMounts ?? []).filter(m => m.type === 'anonymous');
|
|
if (anon.length === 0) continue;
|
|
const targets = anon.map(m => m.target).filter(Boolean);
|
|
findings.push({
|
|
ruleId: 'anonymous-volume',
|
|
severity: 'info',
|
|
title: 'Anonymous volume in use',
|
|
message: `Service "${svc.name}" mounts ${anon.length > 1 ? `${anon.length} anonymous volumes` : 'an anonymous volume'}${targets.length ? ` at ${targets.join(', ')}` : ''}. Anonymous volumes have no name, so they are easy to miss when backing up and are orphaned when the container is recreated.`,
|
|
sourcePath: svc.name,
|
|
service: svc.name,
|
|
remediation: 'Give the volume a name so it can be referenced, backed up, and reattached.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const containerNameInternalDup: PreflightRule = {
|
|
id: 'container-name-internal-dup',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
const byName = new Map<string, string[]>();
|
|
for (const s of ctx.model.services) {
|
|
if (!s.containerName) continue;
|
|
const list = byName.get(s.containerName);
|
|
if (list) list.push(s.name); else byName.set(s.containerName, [s.name]);
|
|
}
|
|
const findings: PreflightFinding[] = [];
|
|
for (const [name, services] of byName) {
|
|
if (services.length < 2) continue;
|
|
findings.push({
|
|
ruleId: 'container-name-internal-dup',
|
|
severity: 'blocker',
|
|
title: 'Duplicate container_name',
|
|
message: `Services ${services.map(s => `"${s}"`).join(' and ')} both set container_name "${name}". Docker requires unique names, so the deploy will fail.`,
|
|
remediation: 'Give each service a unique container_name, or remove it and let Compose name them.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const containerNameCollision: PreflightRule = {
|
|
id: 'container-name-collision',
|
|
run(ctx) {
|
|
if (!ctx.model || !ctx.nodeStateAvailable) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const s of ctx.model.services) {
|
|
if (!s.containerName) continue;
|
|
const clash = ctx.existingContainers.find(c => c.name === s.containerName && c.stack !== ctx.stackName);
|
|
if (!clash) continue;
|
|
const owner = clash.stack ? `stack "${clash.stack}"` : 'an unmanaged container';
|
|
findings.push({
|
|
ruleId: 'container-name-collision',
|
|
severity: 'blocker',
|
|
title: 'container_name already in use',
|
|
message: `container_name "${s.containerName}" for service "${s.name}" is already used by ${owner} on this node. The deploy will fail with a name conflict.`,
|
|
sourcePath: s.name,
|
|
service: s.name,
|
|
remediation: 'Choose a different container_name or remove the conflicting container.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const effectiveModelExpanded: PreflightRule = {
|
|
id: 'effective-model-expanded',
|
|
run(ctx) {
|
|
// Skip when the source could not be read: an empty source-service set then
|
|
// means "unknown", not "zero services", and would flag every service.
|
|
if (!ctx.model || !ctx.sourceReadable) return [];
|
|
const source = new Set(ctx.sourceServiceNames);
|
|
const extra = ctx.model.services.map(s => s.name).filter(n => !source.has(n));
|
|
if (extra.length === 0) return [];
|
|
return [{
|
|
ruleId: 'effective-model-expanded',
|
|
severity: 'info',
|
|
title: 'Effective model adds services',
|
|
message: `The effective model includes ${extra.map(s => `"${s}"`).join(', ')}, which are not in this file (pulled in via include, extends, or profiles). What deploys differs from what you see here.`,
|
|
remediation: 'Review the included files to confirm this is intended.',
|
|
}];
|
|
},
|
|
};
|
|
|
|
const selfManagedStack: PreflightRule = {
|
|
id: 'self-managed-stack',
|
|
run(ctx) {
|
|
if (!ctx.isSelfStack) return [];
|
|
return [{
|
|
ruleId: 'self-managed-stack',
|
|
severity: 'warning',
|
|
title: 'This stack is the running Sencho instance',
|
|
message: 'Sencho discovered its own compose project as a managed stack. Generic deploy, update, stop, down, and delete actions are blocked here because they would recreate or remove the dashboard you are using.',
|
|
remediation: 'Update Sencho via Fleet -> Node Update. To manage it as a normal stack, move its compose project outside COMPOSE_DIR.',
|
|
}];
|
|
},
|
|
};
|
|
|
|
// ----- exposure-intent rules ------------------------------------------------
|
|
// These read the user's stored exposure classification (resolved per service)
|
|
// and the dossier's documented access URLs from the context, plus a sensitivity
|
|
// heuristic on the image name for the broad-exposure rule.
|
|
|
|
/** Image-name hints for a database or admin service that should rarely be broadly exposed. */
|
|
const SENSITIVE_IMAGE_HINTS = [
|
|
'postgres', 'mysql', 'mariadb', 'mongo', 'redis', 'memcached', 'elasticsearch',
|
|
'adminer', 'phpmyadmin', 'portainer', 'docker-socket-proxy',
|
|
];
|
|
/** Reverse-proxy label-key base names; matched as the key itself or a `base.` prefix (case-insensitive). */
|
|
const REVERSE_PROXY_LABEL_HINTS = ['traefik', 'caddy', 'virtual.host'];
|
|
|
|
/** A service's effective intent: its own override, else the stack-level intent. */
|
|
function effectiveIntent(ctx: PreflightContext, service: string): ExposureIntent | null {
|
|
return ctx.serviceIntents[service] ?? ctx.stackIntent;
|
|
}
|
|
|
|
const exposureInternalPublished: PreflightRule = {
|
|
id: 'exposure-internal-published',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const svc of ctx.model.services) {
|
|
const intent = effectiveIntent(ctx, svc.name);
|
|
if (intent !== 'internal' && intent !== 'same-node') continue;
|
|
// same-node tolerates a loopback binding; internal tolerates no host port.
|
|
const offending = svc.ports.filter(p => intent === 'internal' || !isLoopback(p.hostIp));
|
|
if (offending.length === 0) continue;
|
|
findings.push({
|
|
ruleId: 'exposure-internal-published',
|
|
severity: 'high',
|
|
title: `"${svc.name}" is classified ${intent} but publishes a host port`,
|
|
message: `Service "${svc.name}" is classified as ${intent} exposure, but it publishes ${offending.map(specLabel).join(', ')} to the host, which contradicts that intent.`,
|
|
sourcePath: svc.name,
|
|
service: svc.name,
|
|
remediation: intent === 'same-node'
|
|
? 'Bind the port to loopback (127.0.0.1), remove it, or reclassify the exposure intent.'
|
|
: 'Remove the published port or reclassify the exposure intent.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const exposureUnclassified: PreflightRule = {
|
|
id: 'exposure-unclassified',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
// A read failure leaves every intent null; do not read that as "unclassified".
|
|
if (!ctx.exposureAvailable) return [];
|
|
const publishing = ctx.model.services.filter(s => s.ports.length > 0);
|
|
if (publishing.length === 0) return [];
|
|
// Fire only when a publishing service is still effectively unclassified: a
|
|
// service-level intent suppresses the warning for that service even when the
|
|
// stack itself is unset.
|
|
const unclassified = publishing.some(s => {
|
|
const intent = effectiveIntent(ctx, s.name);
|
|
return intent === null || intent === 'unknown';
|
|
});
|
|
if (!unclassified) return [];
|
|
return [{
|
|
ruleId: 'exposure-unclassified',
|
|
severity: 'warning',
|
|
title: 'Stack publishes ports without an exposure intent',
|
|
message: 'This stack publishes one or more host ports but has no exposure intent set. Classifying it (internal, LAN, reverse proxy, public) lets Sencho flag mismatches later.',
|
|
remediation: 'Set the stack exposure intent in the Networking tab.',
|
|
}];
|
|
},
|
|
};
|
|
|
|
const exposurePortVsDossier: PreflightRule = {
|
|
id: 'exposure-port-vs-dossier',
|
|
run(ctx) {
|
|
if (!ctx.model || !ctx.hasAccessUrls) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const svc of ctx.model.services) {
|
|
const undocumented = [...new Set(svc.ports.flatMap(portsOf).filter(p => !ctx.accessUrlPorts.has(p)))];
|
|
if (undocumented.length === 0) continue;
|
|
findings.push({
|
|
ruleId: 'exposure-port-vs-dossier',
|
|
severity: 'warning',
|
|
title: 'Published port is not in the documented access URLs',
|
|
message: `Service "${svc.name}" publishes ${undocumented.join(', ')}, which ${undocumented.length > 1 ? 'are' : 'is'} not referenced by the dossier's documented access URLs. The documentation may be stale.`,
|
|
sourcePath: svc.name,
|
|
service: svc.name,
|
|
remediation: 'Update the access URLs in the Stack Dossier, or change the published port.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const reverseProxyUndocumented: PreflightRule = {
|
|
id: 'reverse-proxy-undocumented',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
// A read failure hides both the intent and any documented access URLs; do not
|
|
// interpret that absence as an undocumented reverse proxy.
|
|
if (!ctx.exposureAvailable) return [];
|
|
// Already documented or intentionally reverse-proxied at the stack level.
|
|
if (ctx.hasAccessUrls || ctx.stackIntent === 'reverse-proxy') return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const svc of ctx.model.services) {
|
|
if (effectiveIntent(ctx, svc.name) === 'reverse-proxy') continue;
|
|
const hasRpLabel = svc.labelKeys.some(k => {
|
|
const lk = k.toLowerCase();
|
|
return REVERSE_PROXY_LABEL_HINTS.some(h => lk === h || lk.startsWith(`${h}.`));
|
|
});
|
|
if (!hasRpLabel) continue;
|
|
findings.push({
|
|
ruleId: 'reverse-proxy-undocumented',
|
|
severity: 'warning',
|
|
title: `"${svc.name}" has reverse-proxy labels but no documented URL`,
|
|
message: `Service "${svc.name}" carries reverse-proxy labels, but the stack has no documented access URL or reverse-proxy intent, so how it is reached is unclear.`,
|
|
sourcePath: svc.name,
|
|
service: svc.name,
|
|
remediation: 'Document the access URL in the Stack Dossier or set the exposure intent to reverse proxy.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
const sensitiveServiceBroadExposure: PreflightRule = {
|
|
id: 'sensitive-service-broad-exposure',
|
|
run(ctx) {
|
|
if (!ctx.model) return [];
|
|
const findings: PreflightFinding[] = [];
|
|
for (const svc of ctx.model.services) {
|
|
if (svc.image === undefined) continue;
|
|
const image = svc.image.toLowerCase();
|
|
if (!SENSITIVE_IMAGE_HINTS.some(h => image.includes(h))) continue;
|
|
const broad = svc.ports.filter(p => isAllInterfaces(p.hostIp));
|
|
if (broad.length === 0) continue;
|
|
findings.push({
|
|
ruleId: 'sensitive-service-broad-exposure',
|
|
severity: 'high',
|
|
title: `Sensitive service "${svc.name}" is exposed on all interfaces`,
|
|
message: `Service "${svc.name}" looks like a database or admin service (${svc.image}) and publishes ${broad.map(specLabel).join(', ')} on all interfaces. Broadly exposing it is a common source of compromise.`,
|
|
sourcePath: svc.name,
|
|
service: svc.name,
|
|
remediation: 'Bind it to a specific interface such as 127.0.0.1, or keep it on an internal network only.',
|
|
});
|
|
}
|
|
return findings;
|
|
},
|
|
};
|
|
|
|
/** The ordered registry. Order is the display order within a severity group. */
|
|
export const PREFLIGHT_RULES: PreflightRule[] = [
|
|
renderFailed,
|
|
envUnset,
|
|
envLiteralDollar,
|
|
envFileMissing,
|
|
portConflictNode,
|
|
portConflictInternal,
|
|
portExposedAllInterfaces,
|
|
bindPathMissing,
|
|
bindPathPermission,
|
|
dockerSocketMount,
|
|
privileged,
|
|
networkModeHost,
|
|
uidGidRisk,
|
|
imageLatest,
|
|
noRestartPolicy,
|
|
noHealthcheck,
|
|
deploySwarmOnly,
|
|
nodeStateUnavailable,
|
|
externalNetworkMissing,
|
|
externalVolumeMissing,
|
|
newNetwork,
|
|
newVolume,
|
|
anonymousVolume,
|
|
containerNameInternalDup,
|
|
containerNameCollision,
|
|
exposureInternalPublished,
|
|
sensitiveServiceBroadExposure,
|
|
exposureUnclassified,
|
|
exposurePortVsDossier,
|
|
reverseProxyUndocumented,
|
|
effectiveModelExpanded,
|
|
selfManagedStack,
|
|
];
|
|
|
|
export const RULE_IDS: readonly string[] = PREFLIGHT_RULES.map(r => r.id);
|
|
|
|
/** Run every rule and concatenate findings. */
|
|
export function runRules(ctx: PreflightContext): PreflightFinding[] {
|
|
return PREFLIGHT_RULES.flatMap(rule => rule.run(ctx));
|
|
}
|