feat: pre-deploy scan visibility and pinned scanner version (#1378)

* feat: pre-deploy scan visibility and pinned scanner version

Pin managed Trivy installs and add an opt-in pre-deploy scan advisory so a
manual deploy can surface each image's latest scan before it runs.

- Managed Trivy now installs a pinned, known-good version by default for
  reproducible installs. Auto-update still tracks the latest release, and an
  explicit update always pulls the latest.
- Add an opt-in pre-deploy scan advisory: when enabled, deploying a stack from
  the editor first shows each image's latest cached scan severity for review.
  It is visibility only and never blocks; deploy enforcement is unchanged.
- Backend: pre_deploy_scan_advisory setting, PUT
  /security/pre-deploy-scan-advisory, a cache-only GET
  /security/stacks/:name/pre-deploy-summary, and a node-scoped
  getLatestVulnScanByDigestForNode lookup.
- Frontend: advisory toggle on the Security page scanner setup, and a
  PreDeployScanDialog wired into the editor deploy flow that fails open when the
  summary is unavailable.
- Docs: scanner configuration, version pinning, and the advisory.

* fix: harden pre-deploy advisory guard, toggle visibility, and installer busy state

Addresses review findings on the pre-deploy advisory.

- Block a second editor deploy during the async advisory window with a
  synchronous pending ref, cleared on cancel and in the deploy's finally, so a
  double-click can no longer start two deploys.
- Keep the pre-deploy advisory toggle visible to admins whenever the setting is
  on, so it can still be turned off after the scanner becomes unavailable.
- Resolve the managed Trivy version inside the install lock so the busy state and
  serialization cover the latest-version fetch and the managed-install check.
This commit is contained in:
Anso
2026-06-16 00:42:33 -04:00
committed by GitHub
parent 770bead889
commit 7ce045accb
21 changed files with 1093 additions and 23 deletions
@@ -11,6 +11,7 @@ import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
import type { NotificationItem } from '../../dashboard/types';
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
import type { PreDeployScanImage } from '@/types/security';
interface RunResult {
ok: boolean;
@@ -118,6 +119,38 @@ interface UseStackActionsOptions {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const PRE_DEPLOY_SUMMARY_TIMEOUT_MS = 5000;
/**
* Fetch the pre-deploy scan advisory for a manual deploy. Returns the image
* list when the advisory is enabled and the backend answers in time, or null to
* mean "no advisory, deploy normally" for every other case (setting off,
* timeout, an older node without the route, or any error). Failing open is
* deliberate: the advisory is visibility, it must never block a deploy. Bound to
* the captured node so it targets the same node the deploy will hit.
*/
export async function fetchPreDeployAdvisory(
stackName: string,
opNodeId: number | null,
): Promise<PreDeployScanImage[] | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PRE_DEPLOY_SUMMARY_TIMEOUT_MS);
try {
const res = await apiFetch(
`/security/stacks/${encodeURIComponent(stackName)}/pre-deploy-summary`,
{ nodeId: opNodeId, signal: controller.signal },
);
if (!res.ok) return null;
const data: unknown = await res.json();
if (!isRecord(data) || data.enabled !== true || !Array.isArray(data.images)) return null;
return data.images as PreDeployScanImage[];
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
const parseStackOpInProgress = (rawBody: string): StackOpInProgressInfo | null => {
try {
const parsed: unknown = JSON.parse(rawBody);
@@ -202,6 +235,9 @@ export function useStackActions(options: UseStackActionsOptions) {
const pendingStackLoadRef = useRef<string | null>(null);
const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null);
const checkUpdatesIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// True from a deploy click through the async pre-deploy advisory phase until
// the deploy starts or is cancelled, so a double-click cannot start two deploys.
const deployPendingRef = useRef(false);
// Aborts the most recent loadFile sequence (compose GET, envs GET, env content
// GET, containers GET, backup GET). A node switch, an unmount, or a second
// loadFile call before the first finishes all cancel the in-flight fetches so
@@ -723,22 +759,63 @@ export function useStackActions(options: UseStackActionsOptions) {
const deployStack = async (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile))
// deployPendingRef blocks a second deploy from the moment of the click
// through the async advisory phase, before setStackAction marks the stack
// busy. Without it, a double-click during the advisory fetch window could
// launch two deploys. Cleared on cancel and in the deploy's finally.
if (
!stackListState.selectedFile ||
stackListState.isStackBusy(stackListState.selectedFile) ||
deployPendingRef.current
)
return;
const stackFile = stackListState.selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
stackListState.setStackAction(stackFile, 'deploy');
// Snapshot the node once so the request stays bound to it even if the active
// node changes while the operation is in flight.
// Snapshot the node once so the advisory fetch and the deploy stay bound to
// it even if the active node changes while the advisory dialog is open.
const opNodeId = activeNode?.id ?? null;
try {
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
runDeploy(stackName, stackFile, false, started, ds, opNodeId),
);
} finally {
stackListState.clearStackAction(stackFile);
stackListState.refreshStacks(true);
deployPendingRef.current = true;
// The actual deploy, pulled out so the optional pre-deploy advisory can gate
// it without duplicating the action lifecycle. The stack action is set here
// (not before the advisory) so cancelling the advisory leaves no stuck state.
const runDeployFlow = async () => {
stackListState.setStackAction(stackFile, 'deploy');
try {
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
runDeploy(stackName, stackFile, false, started, ds, opNodeId),
);
} finally {
stackListState.clearStackAction(stackFile);
stackListState.refreshStacks(true);
deployPendingRef.current = false;
}
};
// Advisory runs before the deploy log opens (fails open: a null result means
// setting off / timeout / older node / error, so the deploy proceeds).
const advisoryImages = await fetchPreDeployAdvisory(stackName, opNodeId);
if (advisoryImages && advisoryImages.length > 0) {
let settled = false;
overlayState.setPreDeployAdvisory({
stackName,
images: advisoryImages,
proceed: () => {
if (settled) return;
settled = true;
overlayState.setPreDeployAdvisory(null);
void runDeployFlow();
},
cancel: () => {
if (settled) return;
settled = true;
overlayState.setPreDeployAdvisory(null);
deployPendingRef.current = false;
},
});
return;
}
await runDeployFlow();
};
const handleSaveAndDeploy = async (e: React.MouseEvent) => {