Skip to content

Commit bf534c9

Browse files
authored
feat: migrate inspect to direct API call (#679)
Migrate docker inspect calls to be direct API calls to the docker daemon instead of subprocess calls that use the docker CLI. This is a cleaner approach and mitigates various potential failure scenarios that arise from the subprocess call.
1 parent 6628139 commit bf534c9

File tree

7 files changed

+158
-9
lines changed

7 files changed

+158
-9
lines changed

lib/analyzer/image-inspector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async function getInspectResult(
2323
targetImage: string,
2424
): Promise<DockerInspectOutput> {
2525
const info = await docker.inspectImage(targetImage);
26-
return JSON.parse(info.stdout)[0];
26+
return info;
2727
}
2828

2929
function cleanupCallback(imageFolderPath: string, imageName: string) {

lib/analyzer/types.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@ export interface AnalyzedPackageWithVersion extends AnalyzedPackage {
2222
}
2323

2424
export interface DockerInspectOutput {
25-
Id: string;
2625
Architecture: string;
27-
RootFS: {
28-
Type: string;
29-
Layers: string[];
30-
};
3126
}
3227

3328
export interface ImageAnalysis {

lib/docker.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as Debug from "debug";
88
import * as Modem from "docker-modem";
99
import { createWriteStream } from "fs";
1010
import { Stream } from "stream";
11+
import { DockerInspectOutput } from "./analyzer/types";
1112
import * as subProcess from "./sub-process";
1213

1314
export { Docker, DockerOptions };
@@ -24,6 +25,18 @@ interface DockerOptions {
2425

2526
const debug = Debug("snyk");
2627

28+
/**
29+
* Type guard to validate that an object conforms to the DockerInspectOutput interface
30+
*/
31+
function isValidDockerInspectOutput(data: any): data is DockerInspectOutput {
32+
return (
33+
data &&
34+
typeof data === "object" &&
35+
!Array.isArray(data) &&
36+
typeof data.Architecture === "string"
37+
);
38+
}
39+
2740
class Docker {
2841
public static async binaryExists(): Promise<boolean> {
2942
try {
@@ -135,7 +148,38 @@ class Docker {
135148
});
136149
}
137150

138-
public async inspectImage(targetImage: string) {
139-
return subProcess.execute("docker", ["inspect", targetImage]);
151+
public async inspectImage(targetImage: string): Promise<DockerInspectOutput> {
152+
const request = {
153+
path: `/images/${targetImage}/json`,
154+
method: "GET",
155+
statusCodes: {
156+
200: true,
157+
404: "not found",
158+
500: "server error",
159+
},
160+
};
161+
162+
debug(`Docker.inspectImage: targetImage: ${targetImage}`);
163+
164+
const modem = new Modem();
165+
166+
return new Promise<DockerInspectOutput>((resolve, reject) => {
167+
modem.dial(request, (err, data) => {
168+
if (err) {
169+
return reject(err);
170+
}
171+
172+
// Validate that the response conforms to DockerInspectOutput interface
173+
if (!isValidDockerInspectOutput(data)) {
174+
return reject(
175+
new Error(
176+
`Invalid Docker inspect response for image ${targetImage}: expected DockerInspectOutput format`,
177+
),
178+
);
179+
}
180+
181+
resolve(data);
182+
});
183+
});
140184
}
141185
}

lib/scan.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getArchivePath, getImageType } from "./image-type";
1212
import { isNumber, isTrue } from "./option-utils";
1313
import * as staticModule from "./static";
1414
import { ImageType, PluginOptions, PluginResponse } from "./types";
15+
import { isValidDockerImageReference } from "./utils";
1516

1617
// Registry credentials may also be provided by env vars. When both are set, flags take precedence.
1718
export function mergeEnvVarsIntoCredentials(
@@ -174,6 +175,13 @@ async function imageIdentifierAnalysis(
174175
dockerfileAnalysis: DockerFileAnalysis | undefined,
175176
options: Partial<PluginOptions>,
176177
): Promise<PluginResponse> {
178+
// Validate Docker image reference format to catch malformed references early. We implement initial validation here
179+
// in lieu of simply sending to the docker daemon since some invalid references can result in unknown or invalid API
180+
// paths to the Docker daemon, sometimes producing confusing error results (like redirects) instead of the not found response.
181+
if (!isValidDockerImageReference(targetImage)) {
182+
throw new Error(`invalid image reference format: ${targetImage}`);
183+
}
184+
177185
const globToFind = {
178186
include: options.globsToFind?.include || [],
179187
exclude: options.globsToFind?.exclude || [],

lib/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Validates a Docker image reference format using the official Docker reference regex.
3+
* @param imageReference The Docker image reference to validate
4+
* @returns true if valid, false if invalid
5+
*/
6+
export function isValidDockerImageReference(imageReference: string): boolean {
7+
// Docker image reference validation regex from the official Docker packages:
8+
// https://github.com/distribution/reference/blob/ff14fafe2236e51c2894ac07d4bdfc778e96d682/regexp.go#L9
9+
// Original regex: ^((?:(?:(?:[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]))*|\[(?:[a-fA-F0-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]*)*[:][[:xdigit:]]{32,}))?$
10+
// Note: Converted [[:xdigit:]] to [a-fA-F0-9] and escaped the forward slashes for JavaScript compatibility.
11+
const dockerImageRegex =
12+
/^((?:(?:(?:[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]))*|\[(?:[a-fA-F0-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,}))?$/;
13+
14+
return dockerImageRegex.test(imageReference);
15+
}

test/lib/analyzer/image-inspector.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ describe("extractImageDetails", () => {
9898
path: imageNameAndTag,
9999
}),
100100
).rejects.toEqual(
101-
new Error("invalid image format"),
101+
new Error(`invalid image reference format: ${imageNameAndTag}`),
102102
);
103103
});
104104
});

test/lib/utils.spec.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { isValidDockerImageReference } from "../../lib/utils";
2+
3+
describe("isValidDockerImageReference", () => {
4+
describe("valid image references", () => {
5+
const validImages = [
6+
"nginx",
7+
"ubuntu",
8+
"alpine",
9+
"nginx:latest",
10+
"ubuntu:20.04",
11+
"alpine:3.14",
12+
"library/nginx",
13+
"library/ubuntu:20.04",
14+
"docker.io/nginx",
15+
"docker.io/library/nginx:latest",
16+
"gcr.io/project-id/image-name",
17+
"gcr.io/project-id/image-name:tag",
18+
"registry.hub.docker.com/library/nginx",
19+
"localhost:5000/myimage",
20+
"localhost:5000/myimage:latest",
21+
"registry.example.com/path/to/image",
22+
"registry.example.com:8080/path/to/image:v1.0",
23+
"my-registry.com/my-namespace/my-image",
24+
"my-registry.com/my-namespace/my-image:v2.1.0",
25+
"nginx@sha256:abcd1234567890abcd1234567890abcd1234567890abcd1234567890abcd1234",
26+
"ubuntu:20.04@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12",
27+
"image_name",
28+
"image.name",
29+
"image-name",
30+
"namespace/image_name.with-dots",
31+
"registry.com/namespace/image__double_underscore",
32+
"127.0.0.1:5000/test",
33+
"[::1]:5000/test",
34+
"registry.com/a/b/c/d/e/f/image",
35+
"a.b.c/namespace/image:tag",
36+
];
37+
38+
it.each(validImages)(
39+
"should return true for valid image reference: %s",
40+
(imageName) => {
41+
expect(isValidDockerImageReference(imageName)).toBe(true);
42+
},
43+
);
44+
});
45+
46+
describe("invalid image references", () => {
47+
const invalidImages = [
48+
"/test:unknown",
49+
"//invalid",
50+
"invalid//path",
51+
"UPPERCASE",
52+
"Invalid:Tag",
53+
"registry.com/UPPERCASE/image",
54+
"registry.com/namespace/UPPERCASE",
55+
"",
56+
"image:",
57+
":tag",
58+
"image::",
59+
"registry.com:",
60+
"registry.com:/image",
61+
"image@",
62+
"image@sha256:",
63+
"image@invalid:digest",
64+
"registry.com//namespace/image",
65+
"registry.com/namespace//image",
66+
".image",
67+
"image.",
68+
"-image",
69+
"image-",
70+
"_image",
71+
"image_",
72+
"registry-.com/image",
73+
"registry.com-/image",
74+
"image:tag@",
75+
"image:tag@sha256",
76+
"registry.com:abc/image",
77+
"registry.com:-1/image",
78+
];
79+
80+
it.each(invalidImages)(
81+
"should return false for invalid image reference: %s",
82+
(imageName) => {
83+
expect(isValidDockerImageReference(imageName)).toBe(false);
84+
},
85+
);
86+
});
87+
});

0 commit comments

Comments
 (0)