Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 6 additions & 92 deletions lib/analyzer/image-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as path from "path";

import { Docker, DockerOptions } from "../docker";
import { ImageName } from "../extractor/image";
import { parseImageReference } from "../image-reference";

import type { DockerPullResult } from "@snyk/snyk-docker-pull";
import type {
Expand Down Expand Up @@ -265,102 +266,15 @@ async function getImageArchive(
}
}

function isImagePartOfURL(targetImage): boolean {
// Based on the Docker spec, if the image contains a hostname, then the hostname should contain
// a `.` or `:` before the first instance of a `/`. ref: https://stackoverflow.com/a/37867949
if (!targetImage.includes("/")) {
return false;
}

const partBeforeFirstForwardSlash = targetImage.split("/")[0];

return (
partBeforeFirstForwardSlash.includes(".") ||
partBeforeFirstForwardSlash.includes(":") ||
partBeforeFirstForwardSlash === "localhost"
);
}

function extractHostnameFromTargetImage(targetImage: string): {
hostname: string;
remainder: string;
} {
// We need to detect if the `targetImage` is part of a URL. If not, the default hostname will be
// used (registry-1.docker.io). ref: https://stackoverflow.com/a/37867949
const defaultHostname = "registry-1.docker.io";

if (!isImagePartOfURL(targetImage)) {
return { hostname: defaultHostname, remainder: targetImage };
}

const dockerFriendlyRegistryHostname = "docker.io/";
if (targetImage.startsWith(dockerFriendlyRegistryHostname)) {
return {
hostname: defaultHostname,
remainder: targetImage.substring(dockerFriendlyRegistryHostname.length),
};
}

const i = targetImage.indexOf("/");
return {
hostname: targetImage.substring(0, i),
remainder: targetImage.substring(i + 1),
};
}

function extractImageNameAndTag(
remainder: string,
targetImage: string,
): { imageName: string; tag: string } {
const defaultTag = "latest";

if (!remainder.includes("@")) {
const [imageName, tag] = remainder.split(":");

return {
imageName: appendDefaultRepoPrefixIfRequired(imageName, targetImage),
tag: tag || defaultTag,
};
}

const [imageName, tag] = remainder.split("@");

function extractImageDetails(targetImage: string): ImageDetails {
const parsed = parseImageReference(targetImage);
return {
imageName: appendDefaultRepoPrefixIfRequired(
dropTagIfSHAIsPresent(imageName),
targetImage,
),
tag: tag || defaultTag,
hostname: parsed.registryForPull,
imageName: parsed.normalizedRepository,
tag: parsed.tailReferenceForPull,
};
}

function appendDefaultRepoPrefixIfRequired(
imageName: string,
targetImage: string,
): string {
const defaultRepoPrefix = "library/";

if (isImagePartOfURL(targetImage) || imageName.includes("/")) {
return imageName;
}

return defaultRepoPrefix + imageName;
}

function dropTagIfSHAIsPresent(imageName: string): string {
if (!imageName.includes(":")) {
return imageName;
}

return imageName.split(":")[0];
}

function extractImageDetails(targetImage: string): ImageDetails {
const { hostname, remainder } = extractHostnameFromTargetImage(targetImage);
const { imageName, tag } = extractImageNameAndTag(remainder, targetImage);
return { hostname, imageName, tag };
}

function isLocalImageSameArchitecture(
platformOption: string,
inspectResultArchitecture: string,
Expand Down
49 changes: 40 additions & 9 deletions lib/dependency-tree/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
import { AnalyzedPackageWithVersion, OSRelease } from "../analyzer/types";
import { parseImageReference } from "../image-reference";
import { DepTree, DepTreeDep } from "../types";

/** @deprecated Should implement a new function to build a dependency graph instead. */
export function buildTree(
targetImage: string,
packageFormat: string,
depInfosList: AnalyzedPackageWithVersion[],
targetOS: OSRelease,
): DepTree {
export function nameAndVersionFromTargetImage(targetImage: string): {
name: string;
version: string;
} {
let imageName: string;
let imageVersion: string;

// TODO: this logic is not sufficient for OCI image references that end
// in .tar (e.g. image:version.tar), which is a valid image name.
// Additionally, the fallback parsing does not fully capture the information
// availble in a valid archive name.
// ref: https://github.com/containers/container-libs/blob/main/image/docs/containers-transports.5.md#containers-storagestorage-specifierimage-iddocker-referenceimage-id
if (!targetImage.endsWith(".tar")) {
try {
const parsed = parseImageReference(targetImage);
return {
name: parsed.fullName,
version: parsed.tag ? parsed.tag : parsed.digest ? "" : "latest",
};
} catch {
// Fallback to manual parsing
}
}

// A tag can only occur in the last section of a docker image name, so
// check any colon separator after the final '/'. If there are no '/',
// which is common when using Docker's official images such as
Expand All @@ -18,8 +36,8 @@ export function buildTree(
targetImage.includes(":");

// Defaults for simple images from dockerhub, like "node" or "centos"
let imageName = targetImage;
let imageVersion = "latest";
imageName = targetImage;
imageVersion = "latest";

// If we have a version, split on the last ':' to avoid the optional
// port on a hostname (i.e. localhost:5000)
Expand All @@ -40,6 +58,19 @@ export function buildTree(
imageVersion = "";
}

return { name: imageName, version: imageVersion };
}

/** @deprecated Should implement a new function to build a dependency graph instead. */
export function buildTree(
targetImage: string,
packageFormat: string,
depInfosList: AnalyzedPackageWithVersion[],
targetOS: OSRelease,
): DepTree {
const { name: imageName, version: imageVersion } =
nameAndVersionFromTargetImage(targetImage);

const root: DepTree = {
// don't use the real image name to avoid scanning it as an issue
name: "docker-image|" + imageName,
Expand Down
70 changes: 22 additions & 48 deletions lib/extractor/image.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseImageReference } from "../image-reference";
import { PluginOptions } from "../types";

export { ImageName, ImageDigest, getImageNames };
Expand All @@ -10,50 +11,14 @@ class ImageName {
targetImage: string,
digests: { manifest?: string; index?: string } = {},
) {
// this regex has been copied from
// https://github.com/distribution/distribution/blob/fb2188868d771aa27e5781a32bf78d4c113c18a6/reference/regexp.go#L101
// (code has been modified to print the regex), and then adjusted to
// Javascript. The required modifications were replacing `[:xdigit:]` with
// `[a-fA-F0-9]` and escaping the slashes.
// Note that the digest matched in this Regex will match digests that have
// uppercase-letters, while the regex used in validateDigest does NOT match
// uppercase-letters. This simply matches the behaviour from the upstream
// `reference` and `go-digest `packages.
//
// we're matching pattern: <registry:port_number>(optional)/<image_name>(mandatory):<image_tag>(optional)@<tag_identifier>(optional)
// This Regex contains three capture groups:
// 1) The repository / image name
// 2) tag
// 3) digest
const re =
/^((?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:(?:[._]|__|[-]*)[a-z0-9]+)+)?(?:(?:\/[a-z0-9]+(?:(?:(?:[._]|__|[-]*)[a-z0-9]+)+)?)+)?)(?::([\w][\w.-]{0,127}))?(?:@([A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*[:][A-Fa-f0-9]{32,}))?$/gi;

const groups = re.exec(targetImage);
if (groups === null) {
if (targetImage === "") {
throw new Error("image name is empty");
}
if (re.exec(targetImage.toLowerCase()) !== null) {
throw new Error("image repository contains uppercase letter");
}
throw new Error("invalid image reference format");
}

const parsedGroups = {
name: groups[1],
tag: groups[2],
digest: groups[3],
};

this.name = parsedGroups.name;

const NameTotalLengthMax = 255;
if (this.name.length > NameTotalLengthMax) {
throw new Error("image repository name is more than 255 characters");
}
const parsed = parseImageReference(targetImage);
this.name = parsed.registry
? parsed.registry + "/" + parsed.repository
: parsed.repository;

this.tag =
parsedGroups.tag || parsedGroups.digest ? parsedGroups.tag : "latest";
// If the image name has a tag, use it. If the image name has
// nether a tag nor a digest, use "latest".
this.tag = parsed.tag || parsed.digest ? parsed.tag : "latest";

this.digests = {};
if (digests.index) {
Expand All @@ -63,11 +28,13 @@ class ImageName {
this.digests.manifest = new ImageDigest(digests.manifest);
}

if (parsedGroups.digest) {
const digest = new ImageDigest(parsedGroups.digest);
if (this.digests.manifest !== digest && this.digests.index !== digest) {
this.digests.unknown = digest;
}
// If the image name has a digest, and it's not the same as the manifest or index digest, add it as the unknown digest.
if (
parsed.digest &&
!this.digests.manifest?.equals(parsed.digest) &&
!this.digests.index?.equals(parsed.digest)
) {
this.digests.unknown = new ImageDigest(parsed.digest);
}
}

Expand Down Expand Up @@ -131,6 +98,13 @@ class ImageDigest {
public toString(): string {
return this.alg + ":" + this.hex;
}

public equals(other: ImageDigest | string): boolean {
if (typeof other === "string") {
return this.toString() === other;
}
return this.alg === other.alg && this.hex === other.hex;
}
}

function getImageNames(
Expand Down
46 changes: 11 additions & 35 deletions lib/extractor/oci-distribution-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseAll } from "@swimlane/docker-reference";
import { isValidDigest, parseImageReference } from "../image-reference";

export interface OCIDistributionMetadata {
// Must be a valid host, including port if one was used to pull the image.
Expand Down Expand Up @@ -32,17 +32,16 @@ export function constructOCIDisributionMetadata({
| OCIDistributionMetadata
| undefined {
try {
const ref = parseAll(imageName);
if (!ref.domain || !ref.repository) {
return;
}

const parsed = parseImageReference(imageName);
// Extract the registry hostname, using "docker.io" as the default for Docker Hub images.
// Note this is different from registryForPull, which defaults to "registry-1.docker.io" for Docker Hub images.
const hostname = parsed.registry ? parsed.registry : "docker.io";
const metadata: OCIDistributionMetadata = {
registryHost: ref.domain,
repository: ref.repository,
registryHost: hostname,
repository: parsed.normalizedRepository,
manifestDigest,
indexDigest,
imageTag: ref.tag,
imageTag: parsed.tag,
};

if (!ociDistributionMetadataIsValid(metadata)) {
Expand All @@ -65,40 +64,17 @@ function ociDistributionMetadataIsValid(

// 2048 byte limit is enforced by Snyk for platform stability.
// Longer strings may be valid, but nothing close to this limit has been observed by Snyk at time of writing.
if (
Buffer.byteLength(data.repository) > 2048 ||
!repositoryNameIsValid(data.repository)
) {
if (Buffer.byteLength(data.repository) > 2048) {
return false;
}

if (!digestIsValid(data.manifestDigest)) {
if (!isValidDigest(data.manifestDigest)) {
return false;
}

if (data.indexDigest && !digestIsValid(data.indexDigest)) {
return false;
}

if (data.imageTag && !tagIsValid(data.imageTag)) {
if (data.indexDigest && !isValidDigest(data.indexDigest)) {
return false;
}

return true;
}

// Regular Expression Source: OCI Distribution Spec V1
// https://github.com/opencontainers/distribution-spec/blob/570d0262abe8ec5e59d8e3fbbd7be4bd784b200e/spec.md?plain=1#L141
const repositoryNameIsValid = (name: string) =>
/^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*$/.test(
name,
);

// Regular Expression Source: OCI Image Spec V1
// https://github.com/opencontainers/image-spec/blob/d60099175f88c47cd379c4738d158884749ed235/descriptor.md?plain=1#L143
const digestIsValid = (digest: string) => /^sha256:[a-f0-9]{64}$/.test(digest);

// Regular Expression Source: OCI Image Spec V1
// https://github.com/opencontainers/distribution-spec/blob/3940529fe6c0a068290b27fb3cd797cf0528bed6/spec.md?plain=1#L160
const tagIsValid = (tag: string) =>
/^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/.test(tag);
Loading
Loading