Skip to content

Commit 92db287

Browse files
committed
Add scan cleanup and result retry for server compatibility
- Added deleteProject method to clean up scan artifacts from server dashboard - Added retry mechanism for result fetching (handles delayed result population) - Fallback scan path now auto-deletes project after retrieving results - Removed KeepInvisibleAndDeletePostScan from scanFileUpload
1 parent c6376ab commit 92db287

2 files changed

Lines changed: 193 additions & 28 deletions

File tree

src/api.ts

Lines changed: 134 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios, { AxiosInstance, AxiosError } from 'axios';
2+
import * as https from 'https';
23
import * as vscode from 'vscode';
34
import FormData from 'form-data';
45
import * as fs from 'fs';
@@ -106,6 +107,34 @@ export interface LicenseScanResult {
106107
riskLevel: string;
107108
}
108109

110+
/**
111+
* Response from /app/api/ExternalScan — returns all results immediately.
112+
* The project is ephemeral (auto-deleted), so no polling is needed.
113+
*/
114+
export interface ExternalScanResponse {
115+
projectId: string;
116+
status: number;
117+
vulnerabilities: ExternalScanVuln[] | null;
118+
malwares: any[] | null;
119+
licenses: any[] | null;
120+
dependencyVulnerabilities: any[] | null;
121+
}
122+
123+
export interface ExternalScanVuln {
124+
id: string;
125+
fileName: string;
126+
filePath: string;
127+
lineNumber: string; // "line,column" format e.g. "2,1"
128+
codeSnippet: string; // base64-encoded
129+
type: string;
130+
riskLevel: number;
131+
vulnerability: string;
132+
title: string;
133+
effect: string;
134+
references: string;
135+
recommendation: string;
136+
}
137+
109138
export const SCAN_STATUS: Record<number, string> = {
110139
0: 'Queued',
111140
1: 'Running',
@@ -135,6 +164,7 @@ export class SastApi {
135164
private client: AxiosInstance;
136165
private token: string = '';
137166
private baseUrl: string = '';
167+
private httpsAgent: https.Agent | undefined;
138168

139169
constructor() {
140170
this.client = axios.create({ timeout: 600000 });
@@ -145,10 +175,22 @@ export class SastApi {
145175
const config = vscode.workspace.getConfiguration('o360');
146176
this.baseUrl = (config.get<string>('endpoint') || 'https://sast.offensive360.com').replace(/\/+$/, '');
147177
this.token = config.get<string>('accessToken') || '';
148-
this.client.defaults.baseURL = this.baseUrl;
149-
if (this.token) {
150-
this.client.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
178+
const allowSelfSigned = config.get<boolean>('allowSelfSignedCerts') || false;
179+
180+
if (allowSelfSigned) {
181+
this.httpsAgent = new https.Agent({ rejectUnauthorized: false });
182+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
183+
} else {
184+
this.httpsAgent = undefined;
185+
delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
151186
}
187+
188+
this.client = axios.create({
189+
baseURL: this.baseUrl,
190+
timeout: 600000,
191+
httpsAgent: this.httpsAgent,
192+
headers: this.token ? { 'Authorization': `Bearer ${this.token}` } : {}
193+
});
152194
}
153195

154196
isAuthenticated(): boolean {
@@ -236,12 +278,21 @@ export class SastApi {
236278
return response.data;
237279
}
238280

281+
/**
282+
* Deletes a project from the server to avoid leaving scan artifacts in the dashboard.
283+
*/
284+
async deleteProject(projectId: string): Promise<void> {
285+
try {
286+
await this.client.delete(`/app/api/Project/${projectId}`);
287+
} catch {
288+
// best-effort cleanup
289+
}
290+
}
291+
239292
async scanFileUpload(zipPath: string, projectName: string): Promise<any> {
240293
const form = new FormData();
241294
form.append('FileSource', fs.createReadStream(zipPath));
242295
form.append('Name', projectName);
243-
// KeepInvisibleAndDeletePostScan disabled until server-side delayed deletion is fixed
244-
// form.append('KeepInvisibleAndDeletePostScan', 'True');
245296
form.append('ExternalScanSourceType', 'VsCodeExtension');
246297

247298
const response = await this.client.post('/app/api/Project/scanProjectFile', form, {
@@ -251,11 +302,71 @@ export class SastApi {
251302
},
252303
maxContentLength: Infinity,
253304
maxBodyLength: Infinity,
254-
timeout: 600000
305+
timeout: 600000,
306+
httpsAgent: this.httpsAgent
307+
});
308+
return response.data;
309+
}
310+
311+
/**
312+
* Upload files to /app/api/ExternalScan.
313+
* Returns all results immediately — no polling needed.
314+
* The project is ephemeral and auto-deleted by the server.
315+
*/
316+
async externalScan(zipPath: string, projectName: string, sourceType: string = 'VsCodeExtension'): Promise<ExternalScanResponse> {
317+
const form = new FormData();
318+
form.append('fileSource', fs.createReadStream(zipPath));
319+
form.append('Name', projectName);
320+
form.append('KeepInvisibleAndDeletePostScan', 'True');
321+
form.append('ExternalScanSourceType', sourceType);
322+
323+
const response = await this.client.post('/app/api/ExternalScan', form, {
324+
headers: {
325+
...form.getHeaders(),
326+
'Authorization': `Bearer ${this.token}`
327+
},
328+
maxContentLength: Infinity,
329+
maxBodyLength: Infinity,
330+
timeout: 600000,
331+
httpsAgent: this.httpsAgent
255332
});
256333
return response.data;
257334
}
258335

336+
/**
337+
* Convert ExternalScan vulnerabilities to LangScanResult format
338+
* for compatibility with diagnostics and tree views.
339+
*/
340+
static convertExternalVulns(vulns: ExternalScanVuln[]): LangScanResult[] {
341+
return vulns.map(v => {
342+
const parts = (v.lineNumber || '0,0').split(',');
343+
const lineNo = parseInt(parts[0]) || 0;
344+
const columnNo = parseInt(parts[1]) || 0;
345+
346+
let snippet = '';
347+
if (v.codeSnippet) {
348+
try { snippet = Buffer.from(v.codeSnippet, 'base64').toString('utf8'); } catch { snippet = v.codeSnippet; }
349+
}
350+
351+
return {
352+
id: v.id,
353+
fileName: v.fileName,
354+
filePath: v.filePath,
355+
lineNo,
356+
columnNo,
357+
codeSnippet: snippet,
358+
type: v.type,
359+
riskLevel: v.riskLevel,
360+
vulnerability: v.vulnerability || v.title,
361+
references: v.references || '',
362+
isTagged: false,
363+
// Preserve extra fields for richer display
364+
effect: (v as any).effect,
365+
recommendation: (v as any).recommendation
366+
} as LangScanResult & { effect?: string; recommendation?: string };
367+
});
368+
}
369+
259370
async scanGitRepo(repoUrl: string, projectName: string, branch?: string): Promise<any> {
260371
const body: any = {
261372
Name: projectName,
@@ -340,6 +451,23 @@ export class SastApi {
340451
malware: MalwareScanResult[];
341452
license: LicenseScanResult[];
342453
}> {
454+
// Retry up to 3 times with 5s delay — some servers need time to populate results after scan completes
455+
for (let attempt = 0; attempt < 3; attempt++) {
456+
const [lang, dep, malware, license] = await Promise.all([
457+
this.getLanguageResults(projectId),
458+
this.getDependencyResults(projectId),
459+
this.getMalwareResults(projectId),
460+
this.getLicenseResults(projectId),
461+
]);
462+
const total = lang.length + dep.length + malware.length + license.length;
463+
if (total > 0) {
464+
return { lang, dep, malware, license };
465+
}
466+
if (attempt < 2) {
467+
await new Promise(resolve => setTimeout(resolve, 5000));
468+
}
469+
}
470+
// Final attempt
343471
const [lang, dep, malware, license] = await Promise.all([
344472
this.getLanguageResults(projectId),
345473
this.getDependencyResults(projectId),

src/scanner.ts

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as vscode from 'vscode';
22
import * as path from 'path';
33
import * as fs from 'fs';
44
import * as archiver from 'archiver';
5-
import { SastApi, SCAN_STATUS, LangScanResult, DepScanResult, MalwareScanResult, LicenseScanResult } from './api';
5+
import { SastApi, SCAN_STATUS, LangScanResult, DepScanResult, MalwareScanResult, LicenseScanResult, ExternalScanResponse, ExternalScanVuln } from './api';
66

77
export interface ScanOutput {
88
projectId: string;
@@ -45,17 +45,8 @@ export class Scanner {
4545
await this.zipFolder(folderPath, zipPath);
4646
progress.report({ message: 'Uploading to SAST server...', increment: 30 });
4747

48-
const result = await this.api.scanFileUpload(zipPath, projectName);
49-
const projectId = result?.id || result?.projectId || result;
50-
51-
progress.report({ message: 'Scan queued, waiting for results...', increment: 20 });
52-
53-
if (projectId) {
54-
// Poll until complete, then fetch results immediately (before server deletes ephemeral project)
55-
return await this.pollScanAndFetchResults(projectId, progress);
56-
}
57-
58-
return null;
48+
// Try ExternalScan first (immediate results, no project saved)
49+
return await this.tryExternalScanWithFallback(zipPath, projectName, progress);
5950
} catch (error: any) {
6051
const statusCode = error?.response?.status;
6152
const responseBody = error?.response?.data;
@@ -94,16 +85,8 @@ export class Scanner {
9485
await this.zipFolder(workspacePath, zipPath);
9586
progress.report({ message: 'Uploading to SAST server...', increment: 30 });
9687

97-
const result = await this.api.scanFileUpload(zipPath, projectName);
98-
const projectId = result?.id || result?.projectId || result;
99-
100-
progress.report({ message: 'Scan queued, waiting for results...', increment: 20 });
101-
102-
if (projectId) {
103-
return await this.pollScanAndFetchResults(projectId, progress);
104-
}
105-
106-
return null;
88+
// Try ExternalScan first (immediate results, no project saved)
89+
return await this.tryExternalScanWithFallback(zipPath, projectName, progress);
10790
} catch (error: any) {
10891
const statusCode = error?.response?.status;
10992
const responseBody = error?.response?.data;
@@ -145,6 +128,60 @@ export class Scanner {
145128
}
146129
}
147130

131+
/**
132+
* Try ExternalScan (immediate results). If it fails (403/404), fall back to
133+
* scanProjectFile + polling. ExternalScan is the preferred path for External tokens.
134+
*/
135+
private async tryExternalScanWithFallback(zipPath: string, projectName: string, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise<ScanOutput | null> {
136+
try {
137+
progress.report({ message: 'Scanning (ExternalScan)...' });
138+
const resp = await this.api.externalScan(zipPath, projectName);
139+
140+
// ExternalScan returns all results immediately
141+
const langResults = resp.vulnerabilities
142+
? SastApi.convertExternalVulns(resp.vulnerabilities)
143+
: [];
144+
145+
const totalVulns = langResults.length
146+
+ (resp.dependencyVulnerabilities?.length || 0)
147+
+ (resp.malwares?.length || 0)
148+
+ (resp.licenses?.length || 0);
149+
150+
progress.report({ message: `Scan complete! Found ${totalVulns} issue(s).`, increment: 40 });
151+
152+
vscode.window.showInformationMessage(
153+
`Offensive360: Scan complete — ${langResults.length} vulnerability(ies) found.`
154+
);
155+
156+
return {
157+
projectId: resp.projectId || '',
158+
results: {
159+
lang: langResults,
160+
dep: (resp.dependencyVulnerabilities as any[]) || [],
161+
malware: (resp.malwares as any[]) || [],
162+
license: (resp.licenses as any[]) || []
163+
}
164+
};
165+
} catch (extErr: any) {
166+
const status = extErr?.response?.status;
167+
// 403 = no permission, 404 = endpoint missing, 500 = server error — fall back to scanProjectFile
168+
if (status === 403 || status === 404 || status === 500) {
169+
progress.report({ message: 'Uploading for scan...' });
170+
const result = await this.api.scanFileUpload(zipPath, projectName);
171+
const projectId = result?.id || result?.projectId || result;
172+
if (projectId) {
173+
progress.report({ message: 'Scan queued, waiting for results...', increment: 20 });
174+
const scanOutput = await this.pollScanAndFetchResults(projectId, progress);
175+
// Clean up: delete the project from the server after fetching results
176+
await this.api.deleteProject(projectId);
177+
return scanOutput;
178+
}
179+
return null;
180+
}
181+
throw extErr; // Re-throw other errors
182+
}
183+
}
184+
148185
/**
149186
* Polls until scan completes, then immediately fetches all results before
150187
* the server deletes the ephemeral project (KeepInvisibleAndDeletePostScan).

0 commit comments

Comments
 (0)