Skip to content

Commit

Permalink
✨ Add Support for Uploading Local Tool Results Instead of Fetching Re…
Browse files Browse the repository at this point in the history
…sults

When using `file` with any of the tools that support automatically
fetching results, the action would ignore the `file` input and instead
attempt to automatically fetch the results. The intention is for the
`file` input to replace the automatic fetching.

This causes some friction with Sonar, since we introduced two separate
result schemas for Sonar (issues and hotspots). When using `file`, the
user must specify whether the local file is a Sonar issues file or Sonar
security hotspots file.

/close #work
  • Loading branch information
gilday committed Jul 9, 2024
1 parent 238f1ae commit d9ce08e
Show file tree
Hide file tree
Showing 7 changed files with 147 additions and 83 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Detailed description of the inputs exposed by the
tool:
# Token for authenticating requests to Sonar.
# Required, when tool is "sonar" and "file" has not been set. Only required for private repository.
# Required, when tool is "sonar". Only required for private repository.
sonar-token:
# Key identifying the Sonar component to be analyzed. Only necessary if deviating from Sonar's established convention.
Expand Down Expand Up @@ -142,8 +142,9 @@ Detailed description of the inputs exposed by the
# Default: https://api.pixee.ai
pixee-api-url:

# Path to the tool's results file to upload to Pixeebot. This does not apply to Sonar integration, because the action retrieves the results directly from Sonar.
# Required, when `tool` is not "sonar"
# Path to the tool's results file to upload to Pixeebot.
# This is required when using a `tool` that does not support automatically fetching results. Contrast, Sonar, and DefectDojo integrations support automatically fetching results. When this input is used with those tools, the given file will be uploaded _instead of_ automatically fetching results.
# Note: for Sonar results, the tool must be `sonar_hotspots` or `sonar_issues` instead of `sonar` when using this input.
file:
```
Expand Down
109 changes: 79 additions & 30 deletions __tests__/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ import { run } from "../src/action";
import * as pixee from "../src/pixee-platform";
import * as sonar from "../src/sonar";
import * as github from "../src/github";
import * as contrast from "../src/contrast";
import * as defectdojo from "../src/defect-dojo";

let getInputMock: jest.SpiedFunction<typeof core.getInput>;
let getGitHubContextMock: jest.SpiedFunction<typeof github.getGitHubContext>;
let getTempDir: jest.SpiedFunction<typeof github.getTempDir>;
let uploadInputFileMock: jest.SpiedFunction<typeof pixee.uploadInputFiles>;
let retrieveContrastResultsMock: jest.SpiedFunction<
typeof contrast.retrieveContrastResults
>;
let retrieveDefectDojoResultsMock: jest.SpiedFunction<
typeof defectdojo.retrieveDefectDojoResults
>;
let retrieveSonarIssuesMock: jest.SpiedFunction<
typeof sonar.retrieveSonarIssues
>;
Expand All @@ -35,6 +43,12 @@ describe("action", () => {
triggerPrAnalysisMock = jest
.spyOn(pixee, "triggerPrAnalysis")
.mockImplementation();
retrieveContrastResultsMock = jest
.spyOn(contrast, "retrieveContrastResults")
.mockImplementation();
retrieveDefectDojoResultsMock = jest
.spyOn(defectdojo, "retrieveDefectDojoResults")
.mockImplementation();
retrieveSonarIssuesMock = jest
.spyOn(sonar, "retrieveSonarIssues")
.mockImplementation();
Expand All @@ -58,7 +72,7 @@ describe("action", () => {
getInputMock.mockImplementation((name: string) => {
switch (name) {
case "tool":
return "sonar";
return "sonar_issues";
case "file":
return "file.json";
default:
Expand All @@ -77,30 +91,80 @@ describe("action", () => {
});

describe("when the file input is not empty", () => {
it("should upload the given sonar file", async () => {
beforeEach(() => {
getGitHubContextMock.mockReturnValue({
owner: "owner",
repo: "repo",
sha: "sha",
});
getRepositoryInfoMock.mockReturnValue({
owner: "owner",
repo: "repo",
});
});

it("should upload the given file instead of automatically fetching Sonar results", async () => {
getInputMock.mockImplementation((name: string) => {
switch (name) {
case "tool":
return "sonar";
return "sonar_issues";
case "file":
return "file.json";
default:
return "";
}
});
getGitHubContextMock.mockReturnValue({
owner: "owner",
repo: "repo",
sha: "sha",

await run();

expect(uploadInputFileMock).toHaveBeenCalledWith(
"sonar_issues",
new Array("file.json"),
);
expect(retrieveSonarIssuesMock).not.toHaveBeenCalled();
expect(retrieveSonarHotspotsMock).not.toHaveBeenCalled();
});

it("should upload the given file instead of automatically fetching Contrast results", async () => {
getInputMock.mockImplementation((name: string) => {
switch (name) {
case "tool":
return "contrast";
case "file":
return "file.json";
default:
return "";
}
});
getRepositoryInfoMock.mockReturnValue({
owner: "owner",
repo: "repo",

await run();

expect(uploadInputFileMock).toHaveBeenCalledWith(
"contrast",
new Array("file.json"),
);
expect(retrieveContrastResultsMock).not.toHaveBeenCalled();
});

it("should upload the given file instead of automatically fetching DefectDojo results", async () => {
getInputMock.mockImplementation((name: string) => {
switch (name) {
case "tool":
return "defectdojo";
case "file":
return "file.json";
default:
return "";
}
});

await run();

expect(uploadInputFileMock).toHaveBeenCalledWith("sonar_issues", new Array("file.json"));
expect(uploadInputFileMock).toHaveBeenCalledWith(
"defectdojo",
new Array("file.json"),
);
expect(retrieveDefectDojoResultsMock).not.toHaveBeenCalled();
});

it("should upload the given semgrep file", async () => {
Expand All @@ -114,19 +178,13 @@ describe("action", () => {
return "";
}
});
getGitHubContextMock.mockReturnValue({
owner: "owner",
repo: "repo",
sha: "sha",
});
getRepositoryInfoMock.mockReturnValue({
owner: "owner",
repo: "repo",
});

await run();

expect(uploadInputFileMock).toHaveBeenCalledWith("semgrep", new Array("file.json"));
expect(uploadInputFileMock).toHaveBeenCalledWith(
"semgrep",
new Array("file.json"),
);
});

it("should upload the given snyk sarif file", async () => {
Expand All @@ -140,15 +198,6 @@ describe("action", () => {
return "";
}
});
getGitHubContextMock.mockReturnValue({
owner: "owner",
repo: "repo",
sha: "sha",
});
getRepositoryInfoMock.mockReturnValue({
owner: "owner",
repo: "repo",
});

await run();

Expand Down
12 changes: 6 additions & 6 deletions __tests__/sonar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("sonar", () => {
path,
queryParamKey: "componentKeys",
pageSize: 500,
page: 1
page: 1,
});

expect(result).toBe(
Expand All @@ -58,7 +58,7 @@ describe("sonar", () => {
path,
queryParamKey: "componentKeys",
pageSize: 500,
page: 1
page: 1,
});

expect(result).toBe(
Expand All @@ -74,7 +74,7 @@ describe("sonar", () => {
path,
queryParamKey: "projectKey",
pageSize: 500,
page: 1
page: 1,
});

expect(result).toBe(
Expand All @@ -97,7 +97,7 @@ describe("sonar", () => {
path,
queryParamKey: "componentKeys",
pageSize: 500,
page: 1
page: 1,
});

expect(result).toBe(
Expand All @@ -120,7 +120,7 @@ describe("sonar", () => {
path,
queryParamKey: "componentKeys",
pageSize: 500,
page: 1
page: 1,
});

expect(result).toBe(
Expand All @@ -144,7 +144,7 @@ describe("sonar", () => {
path,
queryParamKey: "projectKey",
pageSize: 500,
page: 1
page: 1,
});

expect(result).toBe(
Expand Down
84 changes: 47 additions & 37 deletions src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { getContrastInputs, retrieveContrastResults } from "./contrast";

interface SonarResults {
totalResults: number;
results: any
results: any;
}

const MAX_PAGE_SIZE = 500;
Expand All @@ -29,34 +29,41 @@ const MAX_PAGE_SIZE = 500;
export async function run() {
const tool = getTool();

switch (tool) {
case "contrast":
const contrastFile = await fetchOrLocateContrastResultsFile();
await uploadInputFiles(tool, new Array(contrastFile));
core.info(`Uploaded ${contrastFile} to Pixeebot for analysis`);
break;
case "defectdojo":
const file = await fetchOrLocateDefectDojoResultsFile();
await uploadInputFiles(tool, new Array(file));
core.info(`Uploaded ${file} to Pixeebot for analysis`);
break;
case "sonar":
const issuesfiles = await fetchOrLocateSonarResultsFile("issues");
await uploadInputFiles("sonar_issues", issuesfiles);
core.info(`Uploaded ${issuesfiles} to Pixeebot for analysis`);

const hotspotFiles = await fetchOrLocateSonarResultsFile("hotspots");
await uploadInputFiles("sonar_hotspots", hotspotFiles);
core.info(`Uploaded ${hotspotFiles} to Pixeebot for analysis`);
break;
default:
const inputFile = core.getInput("file");
if (!inputFile) {
// if the file input is provided, upload the file
const inputFile = core.getInput("file");
if (inputFile) {
if (tool === "sonar") {
throw new Error(
`Tool "sonar" is too imprecise to use with a file input. Please use "sonar_issues" or "sonar_hotspots" instead.`,
);
}
await uploadInputFiles(tool, new Array(inputFile));
core.info(`Uploaded ${inputFile} for ${tool} to Pixeebot for analysis`);
} else {
// if the file input is not provided, automatically fetch the results and upload them, if supported
switch (tool) {
case "contrast":
const contrastFile = await fetchOrLocateContrastResultsFile();
await uploadInputFiles(tool, new Array(contrastFile));
core.info(`Uploaded ${contrastFile} to Pixeebot for analysis`);
break;
case "defectdojo":
const file = await fetchOrLocateDefectDojoResultsFile();
await uploadInputFiles(tool, new Array(file));
core.info(`Uploaded ${file} to Pixeebot for analysis`);
break;
case "sonar":
const issuesfiles = await fetchOrLocateSonarResultsFile("issues");
await uploadInputFiles("sonar_issues", issuesfiles);
core.info(`Uploaded ${issuesfiles} to Pixeebot for analysis`);

const hotspotFiles = await fetchOrLocateSonarResultsFile("hotspots");
await uploadInputFiles("sonar_hotspots", hotspotFiles);
core.info(`Uploaded ${hotspotFiles} to Pixeebot for analysis`);
break;
default:
throw new Error(`Tool "${tool}" requires a file input`);
}

await uploadInputFiles(tool, new Array(inputFile));
core.info(`Uploaded ${inputFile} for ${tool} to Pixeebot for analysis`);
}
}

const { prNumber } = getGitHubContext();
Expand All @@ -81,9 +88,8 @@ async function fetchOrLocateContrastResultsFile() {
}

async function fetchOrLocateSonarResultsFile(
resultType: SONAR_RESULT
resultType: SONAR_RESULT,
): Promise<Array<string>> {

let page = 1;
const files = new Array();
let isAllResults = false;
Expand All @@ -95,7 +101,11 @@ async function fetchOrLocateSonarResultsFile(
: await fetchSonarHotspots(MAX_PAGE_SIZE, page);
let fileName = `sonar-${resultType}-${page}.json`;

let file = await fetchOrLocateResultsFile("sonar", sonarResults.results, fileName);
let file = await fetchOrLocateResultsFile(
"sonar",
sonarResults.results,
fileName,
);

let total = sonarResults.totalResults;

Expand Down Expand Up @@ -133,8 +143,8 @@ async function fetchOrLocateResultsFile(

async function fetchSonarIssues(
pageSize: number,
page: number
) : Promise<SonarResults>{
page: number,
): Promise<SonarResults> {
const sonarInputs = getSonarInputs();
const results = await retrieveSonarIssues(sonarInputs, pageSize, page);

Expand All @@ -147,20 +157,20 @@ async function fetchSonarIssues(
);
}

return {results, totalResults: results.total};
return { results, totalResults: results.total };
}

async function fetchSonarHotspots(
pageSize: number,
page: number
) : Promise<SonarResults>{
page: number,
): Promise<SonarResults> {
const sonarInputs = getSonarInputs();
const results = await retrieveSonarHotspots(sonarInputs, pageSize, page);
core.info(
`Found ${results.paging.total} Sonar hotspots for component ${sonarInputs.componentKey}`,
);

return {results, totalResults: results.paging.total};
return { results, totalResults: results.paging.total };
}

async function fetchDefectDojoFindings() {
Expand Down
Loading

0 comments on commit d9ce08e

Please sign in to comment.