Add process posture check (#378)

* Add process posture check

* Add support for separate linux and mac paths
This commit is contained in:
Eduard Gert
2024-06-12 16:32:10 +02:00
committed by GitHub
parent 5caeab118b
commit 79164e9dd5
8 changed files with 504 additions and 10 deletions

View File

@@ -13,6 +13,7 @@ export interface InputProps
icon?: React.ReactNode;
error?: string;
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
}
const inputVariants = cva("", {
@@ -49,6 +50,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
maxWidthClass = "",
error,
errorTooltip = false,
errorTooltipPosition = "top",
...props
},
ref,
@@ -105,9 +107,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
{error && errorTooltip && (
<div
className={
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center"
}
className={cn(
errorTooltipPosition == "top" &&
"absolute right-0 top-2 h-[0px] w-full flex items-center pr-3 justify-center",
errorTooltipPosition == "top-right" &&
"absolute -right-6 top-2 h-[0px] w-full flex items-center pr-3 justify-end",
)}
>
<FullTooltip
content={
@@ -120,7 +125,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</div>
}
interactive={false}
align={"center"}
align={errorTooltipPosition == "top" ? "center" : "end"}
side={"top"}
keepOpen={true}
>

View File

@@ -10,6 +10,7 @@ export interface PostureCheck {
os_version_check?: OperatingSystemVersionCheck;
geo_location_check?: GeoLocationCheck;
peer_network_range_check?: PeerNetworkRangeCheck;
process_check?: ProcessCheck;
};
policies?: Policy[];
active?: boolean;
@@ -53,6 +54,17 @@ export interface PeerNetworkRangeCheck {
action: "allow" | "deny";
}
export interface ProcessCheck {
processes: Process[];
}
export interface Process {
id: string;
linux_path?: string;
mac_path?: string;
windows_path?: string;
}
export const windowsKernelVersions: SelectOption[] = [
{ value: "5.0", label: "Windows 2000" },
{ value: "5.1", label: "Windows XP" },

View File

@@ -0,0 +1,310 @@
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { ModalClose, ModalFooter } from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import { cn, validator } from "@utils/helpers";
import { isEmpty, uniqueId } from "lodash";
import {
ExternalLinkIcon,
MinusCircleIcon,
PlusCircle,
ServerCogIcon,
TerminalIcon,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import AppleIcon from "@/assets/icons/AppleIcon";
import WindowsIcon from "@/assets/icons/WindowsIcon";
import { Process, ProcessCheck } from "@/interfaces/PostureCheck";
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
type Props = {
value?: ProcessCheck;
onChange: (value: ProcessCheck | undefined) => void;
};
export const PostureCheckProcess = ({ value, onChange }: Props) => {
const [open, setOpen] = useState(false);
return (
<PostureCheckCard
open={open}
setOpen={setOpen}
key={open ? 1 : 0}
active={value?.processes && value?.processes?.length > 0}
title={"Process"}
description={
"Restrict access in your network based on running processes of a peer."
}
icon={<ServerCogIcon size={18} />}
iconClass={"bg-gradient-to-tr from-nb-gray-500 to-nb-gray-300"}
modalWidthClass={"max-w-xl"}
onReset={() => onChange(undefined)}
>
<CheckContent
value={value}
onChange={(v) => {
onChange(v);
setOpen(false);
}}
/>
</PostureCheckCard>
);
};
const CheckContent = ({ value, onChange }: Props) => {
const [processes, setProcesses] = useState<Process[]>(
value?.processes
? value.processes.map((p) => {
return {
id: uniqueId("process"),
linux_path: p?.linux_path || "",
mac_path: p?.mac_path || "",
windows_path: p?.windows_path || "",
};
})
: [
{
id: uniqueId("process"),
linux_path: "",
mac_path: "",
windows_path: "",
},
],
);
const handleProcessChange = (
id: string,
linux_path: string,
mac_path: string,
windows_path: string,
) => {
const newProcesses = processes.map((p) =>
p.id === id ? { ...p, linux_path, mac_path, windows_path } : p,
);
setProcesses(newProcesses);
};
const removeProcess = (id: string) => {
const newProcesses = processes.filter((p) => p.id !== id);
setProcesses(newProcesses);
};
const addProcess = () => {
setProcesses([
...processes,
{
id: uniqueId("process"),
linux_path: "",
mac_path: "",
windows_path: "",
},
]);
};
const pathErrors = useMemo(() => {
if (processes && processes.length > 0) {
return processes.map((p) => {
return {
id: p.id,
errorMacPath: p?.mac_path
? validator.isValidUnixFilePath(p?.mac_path || "")
? ""
: "Please enter a valid macOS file path"
: "",
errorLinuxPath: p?.linux_path
? validator.isValidUnixFilePath(p?.linux_path || "")
? ""
: "Please enter a valid Unix file path"
: "",
errorWindowsPath: p?.windows_path
? validator.isValidWindowsFilePath(p?.windows_path || "")
? ""
: "Please enter a valid Windows file path"
: "",
};
});
} else {
return [];
}
}, [processes]);
const hasErrorsOrIsEmpty = useMemo(() => {
if (processes.length === 0) return true;
const hasOnlyEmptyPaths = processes.some(
(p) => p.linux_path === "" && p.mac_path === "" && p.windows_path === "",
);
const hasPathErrors = pathErrors.some(
(e) =>
e.errorLinuxPath !== "" ||
e.errorMacPath !== "" ||
e.errorWindowsPath !== "",
);
return hasOnlyEmptyPaths || hasPathErrors;
}, [processes, pathErrors]);
return (
<>
<div className={"flex flex-col px-8 gap-2 pb-6"}>
<div className={"flex justify-between items-start gap-10 mt-2"}>
<div>
<Label>Processes</Label>
<HelpText className={""}>
Add the path of an executable file of the process. You can define
a path for Linux, macOS and Windows. Peers will only be allowed to
connect if the process is running on their system.
</HelpText>
</div>
</div>
{processes.length > 0 && (
<div className={"mb-2 flex flex-col gap-4 w-full "}>
{processes.map((p) => {
return (
<div key={p.id} className={"flex gap-2 items-center"}>
<div className={"w-full flex flex-col gap-1.5"}>
<Input
customPrefix={<TerminalIcon size={16} />}
placeholder={"/usr/local/bin/netbird"}
value={p.linux_path}
error={
pathErrors.find((e) => e.id === p.id)?.errorLinuxPath
}
errorTooltip={true}
errorTooltipPosition={"top-right"}
className={"w-full"}
onChange={(e) =>
handleProcessChange(
p.id,
e.target.value,
p?.mac_path || "",
p?.windows_path || "",
)
}
/>
<Input
customPrefix={
<AppleIcon
size={16}
className={cn(
pathErrors.find((e) => e.id === p.id)
?.errorMacPath && "fill-red-500",
)}
/>
}
placeholder={
"/Applications/NetBird.app/Contents/MacOS/netbird"
}
value={p.mac_path}
error={
pathErrors.find((e) => e.id === p.id)?.errorMacPath
}
errorTooltip={true}
errorTooltipPosition={"top-right"}
className={"w-full"}
onChange={(e) =>
handleProcessChange(
p.id,
p?.linux_path || "",
e.target.value,
p?.windows_path || "",
)
}
/>
<Input
customPrefix={
<WindowsIcon
size={16}
className={cn(
pathErrors.find((e) => e.id === p.id)
?.errorWindowsPath && "fill-red-500",
)}
/>
}
placeholder={`C:\\ProgramData\\NetBird\\netbird.exe`}
value={p.windows_path}
errorTooltip={true}
errorTooltipPosition={"top-right"}
error={
pathErrors.find((e) => e.id === p.id)?.errorWindowsPath
}
className={"w-full"}
onChange={(e) =>
handleProcessChange(
p.id,
p?.linux_path || "",
p?.mac_path || "",
e.target.value,
)
}
/>
</div>
<Button
className={"h-[42px]"}
variant={"default-outline"}
onClick={() => removeProcess(p.id)}
>
<MinusCircleIcon size={15} />
</Button>
</div>
);
})}
</div>
)}
<Button
variant={"dotted"}
size={"sm"}
onClick={addProcess}
className={"mt-1"}
>
<PlusCircle size={16} />
Add Process
</Button>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/manage-posture-checks#process-check"
}
target={"_blank"}
>
Process Check
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
disabled={hasErrorsOrIsEmpty}
onClick={() => {
if (isEmpty(processes)) {
onChange(undefined);
} else {
onChange({
processes: processes.filter(
(p) =>
p.linux_path !== "" ||
p.mac_path !== "" ||
p.windows_path !== "",
),
});
}
}}
>
Save
</Button>
</div>
</ModalFooter>
</>
);
};

View File

@@ -0,0 +1,112 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { ScrollArea } from "@components/ScrollArea";
import { tryGetProcessNameFromPath } from "@utils/helpers";
import { TerminalIcon } from "lucide-react";
import * as React from "react";
import AppleIcon from "@/assets/icons/AppleIcon";
import WindowsIcon from "@/assets/icons/WindowsIcon";
import { ProcessCheck } from "@/interfaces/PostureCheck";
type Props = {
check?: ProcessCheck;
children?: React.ReactNode;
};
export const ProcessTooltip = ({ check, children }: Props) => {
return check ? (
<FullTooltip
className={"w-full min-w-0"}
interactive={true}
contentClassName={"p-0"}
content={
<div
className={
"text-neutral-300 text-sm max-w-xs flex flex-col gap-1 min-w-0"
}
>
<div className={"px-4 pt-3"}>
<span>
<span className={"text-green-500 font-semibold"}>Allow only</span>{" "}
peers which are running the following processes
</span>
</div>
<ScrollArea
className={
"max-h-[275px] overflow-y-auto flex flex-col px-4 min-w-0"
}
>
<div className={"flex flex-col gap-3 mt-1 text-xs mb-3.5 min-w-0"}>
{check.processes.map((p, index) => {
return (
<div className={"flex-col flex gap-1 min-w-0"} key={index}>
{p?.linux_path && (
<Badge
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<TerminalIcon size={12} />
</span>
<span
className={"truncate inline-block "}
title={p?.linux_path}
>
{tryGetProcessNameFromPath(p?.linux_path) ||
"Unknown path"}
</span>
</Badge>
)}
{p?.mac_path && (
<Badge
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<AppleIcon size={12} />
</span>
<span
className={"truncate inline-block "}
title={p?.mac_path}
>
{tryGetProcessNameFromPath(p?.mac_path) ||
"Unknown path"}
</span>
</Badge>
)}
{p?.windows_path && (
<Badge
variant={"gray"}
useHover={false}
className={"justify-start font-medium text-xs min-w-0"}
>
<span className={"mr-1.5"}>
<WindowsIcon size={12} />
</span>
<span
className={"truncate inline-block"}
title={p?.windows_path}
>
{tryGetProcessNameFromPath(p?.windows_path) ||
"Unknown path"}
</span>
</Badge>
)}
</div>
);
})}
</div>
</ScrollArea>
</div>
}
>
{children}
</FullTooltip>
) : (
children
);
};

View File

@@ -24,6 +24,7 @@ import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/Posture
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
import { PostureCheckPeerNetworkRange } from "@/modules/posture-checks/checks/PostureCheckPeerNetworkRange";
import { PostureCheckProcess } from "@/modules/posture-checks/checks/PostureCheckProcess";
type Props = {
open: boolean;
@@ -58,6 +59,9 @@ export default function PostureCheckModal({
const [peerNetworkRangeCheck, setPeerNetworkRangeCheck] = useState(
postureCheck?.checks.peer_network_range_check || undefined,
);
const [processCheck, setProcessCheck] = useState(
postureCheck?.checks.process_check || undefined,
);
const validateOSCheck = (osCheck?: OperatingSystemVersionCheck) => {
if (!osCheck) return;
@@ -98,6 +102,7 @@ export default function PostureCheckModal({
geo_location_check: validateLocationCheck(geoLocationCheck),
os_version_check: validateOSCheck(osVersionCheck),
peer_network_range_check: peerNetworkRangeCheck,
process_check: processCheck,
},
};
@@ -133,7 +138,8 @@ export default function PostureCheckModal({
!!nbVersionCheck ||
!!geoLocationCheck ||
!!osVersionCheck ||
!!peerNetworkRangeCheck;
!!peerNetworkRangeCheck ||
!!processCheck;
const canCreate = !isEmpty(name) && isAtLeastOneCheckEnabled;
const [tab, setTab] = useState("checks");
@@ -187,13 +193,17 @@ export default function PostureCheckModal({
value={geoLocationCheck}
onChange={setGeoLocationCheckCheck}
/>
<PostureCheckPeerNetworkRange
value={peerNetworkRangeCheck}
onChange={setPeerNetworkRangeCheck}
/>
<PostureCheckOperatingSystem
value={osVersionCheck}
onChange={setOsVersionCheck}
/>
<PostureCheckPeerNetworkRange
value={peerNetworkRangeCheck}
onChange={setPeerNetworkRangeCheck}
<PostureCheckProcess
value={processCheck}
onChange={setProcessCheck}
/>
</>
</TabsContent>

View File

@@ -1,5 +1,5 @@
import { cn } from "@utils/helpers";
import { Disc3Icon, FlagIcon, NetworkIcon } from "lucide-react";
import { Disc3Icon, FlagIcon, NetworkIcon, ServerCogIcon } from "lucide-react";
import * as React from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { PostureCheck } from "@/interfaces/PostureCheck";
@@ -7,6 +7,7 @@ import { GeoLocationTooltip } from "@/modules/posture-checks/checks/tooltips/Geo
import { NetBirdVersionTooltip } from "@/modules/posture-checks/checks/tooltips/NetBirdVersionTooltip";
import { OperatingSystemTooltip } from "@/modules/posture-checks/checks/tooltips/OperatingSystemTooltip";
import { PeerNetworkRangeTooltip } from "@/modules/posture-checks/checks/tooltips/PeerNetworkRangeTooltip";
import { ProcessTooltip } from "@/modules/posture-checks/checks/tooltips/ProcessTooltip";
type Props = {
check: PostureCheck;
@@ -71,6 +72,18 @@ export const PostureCheckChecksCell = ({ check }: Props) => {
</div>
</PeerNetworkRangeTooltip>
)}
{check.checks.process_check && (
<ProcessTooltip check={check.checks.process_check}>
<div
className={cn(
"bg-gradient-to-tr from-nb-gray-500 to-nb-gray-300 h-8 w-8 rounded-full flex items-center justify-center relative z-[8] hover:scale-[1.1] transition-all",
)}
>
<ServerCogIcon size={14} />
</div>
</ProcessTooltip>
)}
</div>
</div>
</div>

View File

@@ -49,7 +49,10 @@ export const PostureCheckPolicyUsageCell = ({ check }: Props) => {
interactive={false}
>
<Badge
onClick={() => router.push("/access-control")}
onClick={(e) => {
e.stopPropagation();
router.push("/access-control");
}}
variant={"gray"}
useHover={!!(check.policies && check.policies?.length > 0)}
className={cn(

View File

@@ -74,8 +74,37 @@ export const validator = {
/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
return semverRegex.test(version);
},
isValidUnixFilePath: (path: string) => {
const endsWithSlash = path.endsWith("/");
const unixPathRegex = /^\/(?:[^/]+\/)*[^/]+$/;
const isValid = unixPathRegex.test(path);
return isValid && !endsWithSlash;
},
isValidWindowsFilePath: (path: string) => {
const endsWithBackSlash = path.endsWith("\\");
const windowsPathRegex =
/^[a-zA-Z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*$/;
const isValid = windowsPathRegex.test(path);
return isValid && !endsWithBackSlash;
},
};
export function isInt(n: number) {
return n % 1 === 0;
}
export function tryGetProcessNameFromPath(path: string) {
try {
const canSplitByForwardSlash = path.includes("/");
const canSplitByBackSlash = path.includes("\\");
const byForwardSlash = canSplitByForwardSlash
? path.split("/").pop()
: undefined;
const byBackSlash = canSplitByBackSlash
? path.split("\\").pop()
: undefined;
return byForwardSlash || byBackSlash || path;
} catch (e) {
return path;
}
}