diff --git a/Makefile b/Makefile index 3005983..beb7e67 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,20 @@ IMAGE?=copacetic/copacetic-docker-desktop-extension TAG?=latest +COPA_VERSION?=latest BUILDER=buildx-multi-arch INFO_COLOR = \033[0;36m NO_COLOR = \033[m +# Check if COPA_VERSION is equal to "latest" +ifeq ($(COPA_VERSION),latest) + latest_tag := $(shell curl --retry 5 -s "https://api.github.com/repos/project-copacetic/copacetic/releases/latest" | jq -r '.tag_name') + version := $(subst v,,$(latest_tag)) +else + version := $(COPA_VERSION) +endif + build-extension: ## Build service image to be deployed as a desktop extension docker build --tag=$(IMAGE):$(TAG) . @@ -21,6 +30,9 @@ prepare-buildx: ## Create buildx builder for multi-arch build, if not exists push-extension: prepare-buildx ## Build & Upload extension image to hub. Do not push if tag already exists: make push-extension tag=0.1 docker pull $(IMAGE):$(TAG) && echo "Failure: Tag already exists" || docker buildx build --push --builder=$(BUILDER) --platform=linux/amd64,linux/arm64 --build-arg TAG=$(TAG) --tag=$(IMAGE):$(TAG) . +build-copa-image: + docker build --platform=linux/amd64 --build-arg copa_version=$(version) -t copa-extension container/copa-extension + help: ## Show this help @echo Please specify a build target. The choices are: @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "$(INFO_COLOR)%-30s$(NO_COLOR) %s\n", $$1, $$2}' diff --git a/container/Dockerfile b/container/copa-extension/Dockerfile similarity index 88% rename from container/Dockerfile rename to container/copa-extension/Dockerfile index 6966a73..1467b5f 100644 --- a/container/Dockerfile +++ b/container/copa-extension/Dockerfile @@ -27,8 +27,5 @@ RUN curl --retry 5 -fsSL -o copa.tar.gz https://github.com/project-copacetic/cop tar -zxvf copa.tar.gz && \ cp copa /usr/local/bin/ -# Install Trivy -RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.52.0 - # Code file to execute when the docker container starts up (`entrypoint.sh`) -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] diff --git a/container/entrypoint.sh b/container/copa-extension/entrypoint.sh similarity index 77% rename from container/entrypoint.sh rename to container/copa-extension/entrypoint.sh index d68b87c..e0ca5aa 100755 --- a/container/entrypoint.sh +++ b/container/copa-extension/entrypoint.sh @@ -3,11 +3,12 @@ set -ex; image=$1 -patched_tag=$2 -timeout=$3 -connection_format=$4 -format=$5 -output_file=$6 +report=$2 +patched_tag=$3 +timeout=$4 +connection_format=$5 +format=$6 +output_file=$7 # parse image into image name image_no_tag=$(echo "$image" | cut -d':' -f1) @@ -38,12 +39,8 @@ case "$connection_format" in ;; esac - -# run trivy to generate scan for image -trivy image --vuln-type os --ignore-unfixed -f json -o scan.json $image - # run copa to patch image -if copa patch -i $image -r scan.json -t "$patched_tag" $connection --timeout $timeout $output; +if copa patch -i $image -r output/"$report" -t "$patched_tag" $connection --timeout $timeout $output; then patched_image="$image_no_tag:$patched_tag" echo "patched-image=$patched_image" diff --git a/contribute.md b/contribute.md index 4f48cbe..85f9839 100644 --- a/contribute.md +++ b/contribute.md @@ -3,14 +3,14 @@ Before proceeding, ensure that Docker Desktop is installed on your computer and using the WSL backend if using Windows. ## Building the copa-extenion image -In order to run the extension locally, you need to build the `copa-extension` image specified in the `/container` folder so that the frontend can call `docker run` on the image. +In order to run the extension locally, you need to build the `copa-extension` image image specified in the `/container/copa-extension` folder. -Change your current directory to `/container` and run the following command: +Run the following make command in the root directory to install it: ``` -docker build --platform=linux/amd64 --build-arg copa_version=0.6.2 -t copa-extension . +make build-copa-image ``` -After the command finishes, confirm that an image named `copa-extension` is listed when you run the command `docker images`. +After the command finishes, confirm that the image `copa-extension` is listed when you run the command `docker images`. ## Building the frontend image @@ -22,6 +22,4 @@ make build-extension ``` make install-extension ``` -After following the steps, the extension should be successfully installed in Docker Desktop. If you make any changes to the code, simply run make `update-extension` to see those changes reflected. - - +After following the steps, the extension should be successfully installed in Docker Desktop. If you make any changes to the code, run `make update-extension` to see those changes reflected. \ No newline at end of file diff --git a/ui/src/app.tsx b/ui/src/app.tsx index c422bbd..b6f918a 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -16,18 +16,37 @@ import { DialogActions, IconButton, Grow, - Collapse + Collapse, + Paper, + LinearProgress } from '@mui/material'; import { createDockerDesktopClient } from '@docker/extension-api-client'; import { CopaInput } from './copainput'; import { CommandLine } from './commandline'; +import { VulnerabilityDisplay } from './vulnerabilitydisplay'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +const VULN_UNLOADED = 0; +const VULN_LOADING = 1; +const VULN_LOADED = 2; + +const VOLUME_NAME = "copa-extension-volume"; +const TRIVY_CONTAINER_NAME = "trivy-copa-extension-container"; +const BUSYBOX_CONTAINER_NAME = "busybox-copa-extension-container"; +const COPA_CONTAINER_NAME = "copa-extension-container"; +const JSON_FILE_NAME = "scan.json"; + + export function App() { - const ddClient = createDockerDesktopClient(); + const learnMoreLink = "https://project-copacetic.github.io/copacetic/website/"; + const ddClient = createDockerDesktopClient(); + // The correct image name of the currently selected image. The latest tag is added if there is no tag. + const [imageName, setImageName] = useState(""); + + const [selectedImage, setSelectedImage] = useState(null); const [selectedScanner, setSelectedScanner] = useState("trivy"); const [selectedImageTag, setSelectedImageTag] = useState(undefined); @@ -35,19 +54,62 @@ export function App() { const [totalOutput, setTotalOutput] = useState(""); const [errorText, setErrorText] = useState(""); const [useContainerdChecked, setUseContainerdChecked] = useState(false); - const [jsonFileName, setJsonFileName] = useState("default"); - const [inSettings, setInSettings] = useState(false); const [showPreload, setShowPreload] = useState(true); const [showLoading, setShowLoading] = useState(false); const [showSuccess, setShowSuccess] = useState(false); const [showFailure, setShowFailure] = useState(false); - const [showCopaOutputModal, setShowCopaOutputModal] = useState(false); const [showCommandLine, setShowCommandLine] = useState(false); const baseImageName = selectedImage?.split(':')[0]; + + const [vulnState, setVulnState] = useState(VULN_UNLOADED); + + const [vulnerabilityCount, setVulnerabilityCount] = useState>({ + "UNKNOWN": 0, + "LOW": 0, + "MEDIUM": 0, + "HIGH": 0, + "CRITICAL": 0 + }); + + const getTrivyOutput = async () => { + + const output = await ddClient.docker.cli.exec("run", [ + "-v", + "copa-extension-volume:/data", + "--name", + `${BUSYBOX_CONTAINER_NAME}`, + "busybox", + "cat", + `data/${JSON_FILE_NAME}` + ]); + const data = JSON.parse(output.stdout); + const severityMap: Record = { + "UNKNOWN": 0, + "LOW": 0, + "MEDIUM": 0, + "HIGH": 0, + "CRITICAL": 0 + }; + if (data.Results) { + for (const result of data.Results) { + if (result.Vulnerabilities) { + for (const vulnerability of result.Vulnerabilities) { + severityMap[vulnerability.Severity]++; + } + } + } + } + setVulnerabilityCount(severityMap); + setVulnState(VULN_LOADED); + }; + + // -- Effects -- + + // On app launch, check for containerd useEffect(() => { const checkForContainerd = async () => { let containerdEnabled = await isContainerdEnabled(); @@ -56,7 +118,10 @@ export function App() { checkForContainerd(); }, []); + // ----------- + const patchImage = () => { + setShowPreload(false); setShowLoading(true); triggerCopa(); @@ -67,7 +132,15 @@ export function App() { setSelectedScanner(undefined); setSelectedImageTag(undefined); setSelectedTimeout(undefined); + setVulnerabilityCount({ + "UNKNOWN": 0, + "LOW": 0, + "MEDIUM": 0, + "HIGH": 0, + "CRITICAL": 0 + }); setTotalOutput(""); + setVulnState(VULN_UNLOADED); } const processError = (error: string) => { @@ -80,6 +153,48 @@ export function App() { } } + async function triggerTrivy() { + let stdout = "" + let stderr = "" + + setVulnState(VULN_LOADING); + + // Remove all previous containers. + await ddClient.docker.cli.exec("rm", [ + "-f", + `${TRIVY_CONTAINER_NAME}`, + `${BUSYBOX_CONTAINER_NAME}`, + `${COPA_CONTAINER_NAME}` + ]); + + // Remove old volume + await ddClient.docker.cli.exec("volume", [ + "rm", + "-f", + VOLUME_NAME + ]); + + let commandParts: string[] = [ + "--mount", + "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock", + "-v", + `${VOLUME_NAME}:/output`, + "--name", + `${TRIVY_CONTAINER_NAME}`, + "aquasec/trivy", + "image", + "--vuln-type", + "os", + "--ignore-unfixed", + "--format", + "json", + "-o", + `output/${JSON_FILE_NAME}`, + `${selectedImage}` + ]; + ({ stdout, stderr } = await runTrivy(commandParts, stdout, stderr)); + } + const getImageTag = () => { if (selectedImage === null) { return; @@ -107,9 +222,13 @@ export function App() { let commandParts: string[] = [ "--mount", "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock", - // "--name=copa-extension", + "--name", + `${COPA_CONTAINER_NAME}`, + "-v", + "copa-extension-volume:/output", "copa-extension", `${selectedImage}`, + `${JSON_FILE_NAME}`, `${getImageTag()}`, `${selectedTimeout === undefined ? "5m" : selectedTimeout}`, `${useContainerdChecked ? 'custom-socket' : 'buildx'}`, @@ -119,6 +238,42 @@ export function App() { } } + async function runTrivy(commandParts: string[], stdout: string, stderr: string) { + let tOutput = totalOutput; + await ddClient.docker.cli.exec( + "run", commandParts, + { + stream: { + onOutput(data: any) { + if (data.stdout) { + stdout += data.stdout; + tOutput += data.stdout; + + } + if (data.stderr) { + stderr += data.stderr; + tOutput += data.stderr; + } + setTotalOutput(tOutput); + }, + onClose(exitCode: number) { + if (exitCode == 0) { + ddClient.desktopUI.toast.success(`Trivy scan finished`); + getTrivyOutput(); + } else if (exitCode !== 137) { + setVulnState(VULN_UNLOADED); + ddClient.desktopUI.toast.error(`Trivy scan failed: ${stderr}`); + } + }, + }, + } + ); + return { stdout, stderr }; + } + + + + async function isContainerdEnabled() { const result = await ddClient.docker.cli.exec("info", [ "--format", @@ -131,7 +286,7 @@ export function App() { async function runCopa(commandParts: string[], stdout: string, stderr: string) { let latestStderr: string = ""; - let tOutput = ""; + let tOutput = totalOutput; await ddClient.docker.cli.exec( "run", commandParts, { @@ -154,7 +309,9 @@ export function App() { setShowLoading(false); if (exitCode == 0) { setShowSuccess(true); - ddClient.desktopUI.toast.success(`Copacetic - Created new patched image ${baseImageName}:${getImageTag()}`); + const newImageName = `${baseImageName}:${getImageTag()}`; + ddClient.desktopUI.toast.success(`Copacetic - Created new patched image ${newImageName}`); + setSelectedImage(newImageName); } else { setShowFailure(true); ddClient.desktopUI.toast.error(`Copacetic - Failed to patch image ${selectedImage}`); @@ -167,6 +324,7 @@ export function App() { return { stdout, stderr }; } + const showCommandLineButton = ( { setShowCommandLine(!showCommandLine) }}> {showCommandLine ? : } @@ -179,13 +337,13 @@ export function App() { width={80} > - + {showCommandLineButton} Patching Image... - + @@ -193,30 +351,54 @@ export function App() { ) const successPage = ( - + - Successfully patched image + Created new patched image {showCommandLineButton} {selectedImage}! - - + + + {vulnState !== VULN_UNLOADED && + + Vulnerabilities: + + + } + {vulnState !== VULN_UNLOADED && + < VulnerabilityDisplay + vulnerabilityCount={vulnerabilityCount} + vulnState={vulnState} + setVulnState={setVulnState} + />} + + + + + ); @@ -234,14 +416,14 @@ export function App() { {errorText} + + + - - - ) @@ -252,7 +434,7 @@ export function App() { alignItems="center" minHeight="100vh" > - + } {showLoading && loadingPage} {showSuccess && successPage} diff --git a/ui/src/copainput.tsx b/ui/src/copainput.tsx index c484553..f61b169 100644 --- a/ui/src/copainput.tsx +++ b/ui/src/copainput.tsx @@ -18,13 +18,22 @@ import { CircularProgress, FormControlLabel, Switch, - Tooltip + Tooltip, + LinearProgress, + Skeleton } from '@mui/material'; +import { ClickAwayListener } from '@mui/base'; import InputLabel from '@mui/material/InputLabel'; import FormControl from '@mui/material/FormControl'; import Select, { SelectChangeEvent } from '@mui/material/Select'; import { createDockerDesktopClient } from '@docker/extension-api-client'; import InfoIcon from '@mui/icons-material/Info'; +import { VulnerabilityDisplay } from './vulnerabilitydisplay'; + +const VULN_UNLOADED = 0; +const VULN_LOADING = 1; +const VULN_LOADED = 2; + export function CopaInput(props: any) { const ddClient = createDockerDesktopClient(); @@ -33,6 +42,17 @@ export function CopaInput(props: any) { const [selectedImageHelperText, setSelectedImageHelperText] = useState(""); const [selectImageLabel, setSelectImageLabel] = useState("Remote Images"); + useEffect(() => { + props.setSelectedImage(""); + if (props.useContainerdChecked) { + fetchData(); + setSelectImageLabel("Local Image / Remote Image"); + } else { + setDockerImages([]); + setSelectImageLabel("Remote Image") + } + }, [props.useContainerdChecked]); + const fetchData = async () => { const imagesList = await ddClient.docker.listImages(); const listImages = (imagesList as []).map((images: any) => images.RepoTags) @@ -46,91 +66,91 @@ export function CopaInput(props: any) { setDockerImages(listImages); } - useEffect(() => { - props.setSelectedImage(""); - if (props.useContainerdChecked) { - fetchData(); - setSelectImageLabel("Local Image / Remote Image"); - } else { - setDockerImages([]); - setSelectImageLabel("Remote Image") - } - }, [props.useContainerdChecked]); + const sumValues = (obj: Record): number => { + return Object.values(obj).reduce((accumulator, currentValue) => accumulator + currentValue, 0); + }; const hasWhiteSpace = (s: string) => { return s.indexOf(' ') >= 0; } const validateInput = () => { - let foundError: boolean = false; + let inputValid: boolean = true; if (props.selectedImage === null || props.selectedImage.length === 0) { - foundError = true; + inputValid = false; setSelectedImageHelperText("Image input can not be empty."); } else if (hasWhiteSpace(props.selectedImage)) { - foundError = true; - setSelectedImageHelperText("Image input can not have whitespace.") + inputValid = false; + setSelectedImageHelperText("Image input can not have whitespace."); } else { let seperateSplit = props.selectedImage.split(':'); let numColons = seperateSplit.length - 1; if (numColons > 1) { - foundError = true; + inputValid = false; setSelectedImageHelperText("Image input can only have one colon."); } else { if (seperateSplit[0].length === 0) { - foundError = true; - setSelectedImageHelperText("Image input can not be a tag only.") + inputValid = false; + setSelectedImageHelperText("Image input can not be a tag only."); } } } - if (foundError) { - setSelectedImageError(true); - } else { - setSelectedImageError(false); - props.patchImage(); + if (inputValid) { + setSelectedImageHelperText(""); } - + setSelectedImageError(!inputValid); + return inputValid; } const handleLocalImageSwitchChecked = (event: React.ChangeEvent) => { props.setUseContainerdChecked(event.target.checked); }; + const handleSelectedImageChange = (event: any, newValue: string | null) => { + props.setSelectedImage(newValue); + if (newValue !== null) { + let str1 = newValue.split(':').join('.'); + let str2 = str1.split("/").join('.'); + } + } + return ( - + - - { - props.setSelectedImage(newValue); - }} - id="image-select-combo-box" - options={dockerImages} - sx={{ width: 300 }} - renderInput={(params) => - - - - - { - ddClient.host.openExternal("https://docs.docker.com/desktop/containerd/") - }}> - Containerd image store not enabled - - } - />} - /> - + { + props.setVulnState(VULN_UNLOADED); + }} + sx={{ width: 300 }} + disabled={props.vulnState === VULN_LOADING} + renderInput={(params) => + } + /> + {!props.useContainerdChecked && + + + + + { + ddClient.host.openExternal("https://docs.docker.com/desktop/containerd/") + }}> + Containerd image store not enabled + + } Scanner @@ -168,8 +188,35 @@ export function CopaInput(props: any) { + + Vulnerabilities + - + + diff --git a/ui/src/vulnerabilitydisplay.tsx b/ui/src/vulnerabilitydisplay.tsx new file mode 100644 index 0000000..61499f1 --- /dev/null +++ b/ui/src/vulnerabilitydisplay.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { Box, Typography, Stack, Tooltip, LinearProgress } from '@mui/material'; + +const VULN_UNLOADED = 0; +const VULN_LOADING = 1; +const VULN_LOADED = 2; + +const WHITE_COLOR = "#FFFFFF"; + +export function VulnerabilityDisplay(props: any) { + + function VulnerabilityBox(props: any) { + + const boxWidth = 2; + const boxHeight = 0.20; + + const getRadius = (roundedSide: string) => { + if (roundedSide === "left") { + return '3px 0px 0px 3px'; + } else if (roundedSide === "right") { + return '0px 3px 3px 0px'; + } else { + return '0px 0px 0px 0px' + } + } + + const getColor = () => { + if (props.value === 0) { + return '#987285'; + } else if (props.renderWhiteText) { + return '#fff' + } else { + return '#000' + } + } + + + return ( + + + {props.value} + + + ) + } + + const unloadedPage = Nothing analyzed + const loadingPage = + + const loadedPage = + + + + + + + + + return ( + + {(props.vulnState === VULN_UNLOADED) && unloadedPage} + {(props.vulnState === VULN_LOADING) && loadingPage} + {(props.vulnState === VULN_LOADED) && loadedPage} + + ); +} +