Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add java application files collection #636

Closed
wants to merge 1 commit into from
Closed
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
88 changes: 72 additions & 16 deletions lib/analyzer/applications/java.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import * as admzip from "adm-zip";
import * as path from "path";
import { bufferToSha1 } from "../../buffer-utils";
import { JarFingerprintsFact } from "../../facts";
import { ApplicationFilesFact, JarFingerprintsFact } from "../../facts";
import { Identity } from "../../types";
import { JarFingerprint } from "../types";
import { AggregatedJars, JarBuffer, JarCoords, JarInfo } from "./types";
import {
AggregatedJars,
ApplicationFiles,
JarBuffer,
JarCoords,
JarInfo,
} from "./types";
import { AppDepsScanResultWithoutTarget, FilePathToBuffer } from "./types";

/**
Expand Down Expand Up @@ -34,6 +41,7 @@ export async function jarFilesToScannedResults(
filePathToContent: FilePathToBuffer,
targetImage: string,
desiredLevelsOfUnpacking: number,
collectApplicationFiles: boolean,
): Promise<AppDepsScanResultWithoutTarget[]> {
const mappedResult = groupJarFingerprintsByPath(filePathToContent);
const scanResults: AppDepsScanResultWithoutTarget[] = [];
Expand All @@ -43,11 +51,17 @@ export async function jarFilesToScannedResults(
continue;
}

const fingerprints = await getFingerprints(
const [fingerprints, classFiles] = await getFingerprints(
desiredLevelsOfUnpacking,
mappedResult[path],
collectApplicationFiles,
);

const identity: Identity = {
type: "maven",
targetFile: path,
};

const jarFingerprintsFact: JarFingerprintsFact = {
type: "jarFingerprints",
data: {
Expand All @@ -58,11 +72,19 @@ export async function jarFilesToScannedResults(
};
scanResults.push({
facts: [jarFingerprintsFact],
identity: {
type: "maven",
targetFile: path,
},
identity,
});

if (collectApplicationFiles && classFiles.length) {
const applicationFilesFact: ApplicationFilesFact = {
type: "applicationFiles",
data: classFiles,
};
scanResults.push({
facts: [applicationFilesFact],
identity,
});
}
}

return scanResults;
Expand All @@ -71,12 +93,14 @@ export async function jarFilesToScannedResults(
async function getFingerprints(
desiredLevelsOfUnpacking: number,
jarBuffers: JarBuffer[],
): Promise<JarFingerprint[]> {
const fingerprints: JarFingerprint[] = await unpackJars(
collectApplicationFiles: boolean,
): Promise<[JarFingerprint[], ApplicationFiles[]]> {
const [fingerprints, classFiles] = await unpackJars(
jarBuffers,
desiredLevelsOfUnpacking,
collectApplicationFiles,
);
return Array.from(new Set(fingerprints));
return [Array.from(new Set(fingerprints)), Array.from(new Set(classFiles))];
}

/**
Expand Down Expand Up @@ -104,16 +128,26 @@ function unpackJar({
desiredLevelsOfUnpacking,
requiredLevelsOfUnpacking,
unpackedLevels,
collectApplicationFiles,
}: {
jarBuffer: Buffer;
jarPath: string;
desiredLevelsOfUnpacking: number;
requiredLevelsOfUnpacking: number;
unpackedLevels: number;
collectApplicationFiles: boolean;
}): JarInfo {
const dependencies: JarCoords[] = [];
const nestedJars: JarBuffer[] = [];
const classFiles: ApplicationFiles = {
language: "java",
jarPath,
fileHierarchy: [],
};
let coords: JarCoords | null = null;
// Don't collect for nested jars
const shouldCollectClassFiles =
collectApplicationFiles && unpackedLevels <= 1;

// TODO: consider switching to node-stream-zip that supports streaming
let zip: admzip;
Expand Down Expand Up @@ -151,6 +185,11 @@ function unpackJar({
dependencies.push(entryCoords);
}
}
} else if (
shouldCollectClassFiles &&
zipEntry.entryName.endsWith(".class")
) {
classFiles.fileHierarchy.push({ path: zipEntry.entryName });
}

// We only want to include JARs found at this level if the user asked for
Expand All @@ -171,30 +210,37 @@ function unpackJar({
}
}

return {
const result: JarInfo = {
location: jarPath,
buffer: jarBuffer,
coords,
dependencies,
nestedJars,
};

if (shouldCollectClassFiles) {
result.classFiles = classFiles;
}

return result;
}

/**
* Manages the unpacking an array of JarBuffer objects and returns the resulting
* fingerprints. Recursion to required depth is handled here when the returned
* fingerprints and classFiles (if requested). Recursion to required depth is handled here when the returned
* info from each JAR that is unpacked has nestedJars.
*
* @param {JarBuffer[]} jarBuffers
* @param {number} desiredLevelsOfUnpacking
* @param {number} unpackedLevels
* @returns JarFingerprint[]
* @returns [JarFingerprint[], ApplicationFiles[]]
*/
async function unpackJars(
jarBuffers: JarBuffer[],
desiredLevelsOfUnpacking: number,
collectApplicationFiles: boolean,
unpackedLevels: number = 0,
): Promise<JarFingerprint[]> {
): Promise<[JarFingerprint[], ApplicationFiles[]]> {
// We have to unpack jars to get the pom.properties manifest which
// we use to support shaded jars and get the package coords (if exists)
// to reduce the dependency on maven search and support private jars.
Expand All @@ -206,6 +252,7 @@ async function unpackJars(
// requiredLevelsOfUnpacking = implementation control variable
const requiredLevelsOfUnpacking = desiredLevelsOfUnpacking + 1;
const fingerprints: JarFingerprint[] = [];
const classFiles: ApplicationFiles[] = [];

// jarBuffers is the array of JARS found in the image layers;
// this represents the 1st "level" which we will unpack by
Expand All @@ -219,6 +266,7 @@ async function unpackJars(
unpackedLevels: unpackedLevels + 1,
desiredLevelsOfUnpacking,
requiredLevelsOfUnpacking,
collectApplicationFiles,
});

// we only care about JAR fingerprints. Other Java archive files are not
Expand All @@ -239,16 +287,24 @@ async function unpackJars(
if (jarInfo.nestedJars.length > 0) {
// this is an uber/fat JAR so we need to unpack the nested JARs to
// analyze them for coords and further nested JARs (depth flag allowing)
const nestedJarFingerprints = await unpackJars(
const [nestedJarFingerprints, newClassFiles] = await unpackJars(
jarInfo.nestedJars,
desiredLevelsOfUnpacking,
collectApplicationFiles,
unpackedLevels + 1,
);
fingerprints.push(...nestedJarFingerprints);
if (collectApplicationFiles && newClassFiles?.length) {
classFiles.push(...newClassFiles);
}
}

if (collectApplicationFiles && jarInfo.classFiles) {
classFiles.push(jarInfo.classFiles);
}
}

return fingerprints;
return [fingerprints, classFiles];
}

/**
Expand Down
2 changes: 2 additions & 0 deletions lib/analyzer/applications/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface JarInfo extends JarBuffer {
coords: JarCoords | null;
dependencies: JarCoords[];
nestedJars: JarBuffer[];
classFiles?: ApplicationFiles;
}
export interface JarBuffer {
location: string;
Expand All @@ -37,6 +38,7 @@ export interface ApplicationFileInfo {
export interface ApplicationFiles {
fileHierarchy: ApplicationFileInfo[];
moduleName?: string;
jarPath?: string;
language: string;
}

Expand Down
1 change: 1 addition & 0 deletions lib/analyzer/static-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export async function analyze(
getBufferContent(extractedLayers, getJarFileContentAction.actionName),
targetImage,
desiredLevelsOfUnpacking,
collectApplicationFiles,
);

const goModulesScanResult = await goModulesToScannedProjects(
Expand Down
1 change: 1 addition & 0 deletions lib/analyzer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface JarFingerprint {
parentName?: string;
name?: string;
version?: string;
classFiles?: string[];
dependencies: JarCoords[];
}
export interface StaticAnalysis {
Expand Down
Binary file added test/fixtures/maven/nested-jars-fixture.jar
Binary file not shown.
37 changes: 37 additions & 0 deletions test/lib/analyzer/java.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe("jarFilesToScannedResults function", () => {
filePathToContent,
"image-name",
0, // we don't want to include any nested JARs
false, // no collect-application-files wanted
);

// Assert
Expand All @@ -33,6 +34,41 @@ describe("jarFilesToScannedResults function", () => {
expect(result[0].identity.targetFile).toEqual("/lib/test");
});

it("should extract only top level class files", async () => {
// Arrange
const buffered = readFileSync(
"test/fixtures/maven/nested-jars-fixture.jar",
);
const filePathToContent = {
"/lib/test/nested-jars-fixture.jar": buffered,
};

// Act
const result = await jarFilesToScannedResults(
filePathToContent,
"image-name",
10, // we don't want to include any nested JARs
true, // collect-application-files wanted
);

// Assert
expect(result.length).toEqual(2);
const jarFilesCount = result[0].facts[0].data.fingerprints;
expect(jarFilesCount.length).toEqual(35);
expect(result[0].facts[0].type).toEqual("jarFingerprints");
expect(result[0].facts[0].data.fingerprints[0].location).toEqual(
"/lib/test/nested-jars-fixture.jar",
);
expect(result[1].facts[0].data.length).toEqual(1);
expect(result[1].facts[0].data[0].fileHierarchy.length).toEqual(55);
expect(result[1].facts[0].data[0].jarPath).toEqual(
"/lib/test/nested-jars-fixture.jar",
);
expect(result[1].facts[0].data[0].language).toEqual("java");
expect(result[1].identity.type).toEqual("maven");
expect(result[1].identity.targetFile).toEqual("/lib/test");
});

it("should catch errors with admzip and continue", async () => {
// Arrange
const bufferedDigest = Buffer.from(
Expand All @@ -53,6 +89,7 @@ describe("jarFilesToScannedResults function", () => {
filePathToContent,
"image-name",
0, // we always unpack so will still "trip" admzip
false, // no collect-application-files wanted
);

// Assert
Expand Down
32 changes: 32 additions & 0 deletions test/system/application-scans/java.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,4 +468,36 @@ describe("jar binaries scanning", () => {
},
);
});

it("should handle --collect-application-files", async () => {
const fixturePath = getFixture(
"docker-archives/docker-save/java-uberjar.tar",
);
const imageNameAndTag = `docker-archive:${fixturePath}`;

const resultWithoutApplicationFilesFlag = await scan({
path: imageNameAndTag,
"app-vulns": true,
});
const resultWithApplicationFilesFlagSetToTrue = await scan({
path: imageNameAndTag,
"app-vulns": true,
"collect-application-files": "true",
});

expect(resultWithoutApplicationFilesFlag.scanResults).toHaveLength(2);
expect(resultWithApplicationFilesFlagSetToTrue.scanResults).toHaveLength(3);

const appFiles =
resultWithApplicationFilesFlagSetToTrue.scanResults[2].facts.find(
(fact) => fact.type === "applicationFiles",
)!.data;
expect(appFiles.length).toEqual(2);
expect(appFiles[0].fileHierarchy.length).toEqual(1);
expect(appFiles[0].jarPath).toEqual("/uberjar.jar");
expect(appFiles[0].language).toEqual("java");
expect(appFiles[1].fileHierarchy.length).toEqual(12);
expect(appFiles[1].jarPath).toEqual("/j2objc-annotations-1.3.jar");
expect(appFiles[1].language).toEqual("java");
});
});