Skip to content

Commit 28b3c17

Browse files
author
Offensive360
committed
Offensive360 SAST plugin.
1 parent 269709f commit 28b3c17

12 files changed

Lines changed: 307 additions & 112 deletions

File tree

.DS_Store

-8 KB
Binary file not shown.

.vscode/extensions.json

Lines changed: 0 additions & 7 deletions
This file was deleted.

.vscode/launch.json

Lines changed: 0 additions & 34 deletions
This file was deleted.

.vscode/settings.json

Lines changed: 0 additions & 11 deletions
This file was deleted.

.vscode/tasks.json

Lines changed: 0 additions & 20 deletions
This file was deleted.

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"name": "offensive360",
33
"displayName": "O360 SAST",
4-
"description": "Enterprise static application security testing \u00e2\u20ac\u201d find and fix vulnerabilities directly in your editor",
5-
"version": "1.1.4",
4+
"description": "Enterprise static application security testing — find and fix vulnerabilities directly in your editor",
5+
"version": "1.1.9",
66
"publisher": "Offensive360",
77
"license": "SEE LICENSE IN offensive-license-terms.md",
88
"repository": {
@@ -53,7 +53,7 @@
5353
},
5454
"o360.accessToken": {
5555
"type": "string",
56-
"description": "API access token (generated from O360 SAST dashboard under Settings \u00e2\u2020\u2019 Tokens)",
56+
"description": "API access token (generated from O360 SAST dashboard under Settings → Tokens)",
5757
"default": "",
5858
"order": 2
5959
},
@@ -145,6 +145,10 @@
145145
"command": "o360.refreshTree",
146146
"title": "Refresh",
147147
"icon": "$(refresh)"
148+
},
149+
{
150+
"command": "o360.checkForUpdates",
151+
"title": "Offensive 360: Check for Updates"
148152
}
149153
],
150154
"menus": {

src/constants.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const COMMANDS = {
1919
CLEAR_ALL: 'o360.clearAll',
2020
REFRESH_TREE: 'o360.refreshTree',
2121
FOCUS_VULNERABILITY: 'o360.focusVulnerability',
22+
CHECK_UPDATE: 'o360.checkForUpdates',
2223
} as const;
2324

2425
export const VIEWS = {
@@ -43,26 +44,55 @@ export const SEVERITY_ICONS: Record<string, string> = {
4344
'Info': '$(info)',
4445
};
4546

47+
// KEEP IN LOCKSTEP with VS plugin's ScanCache.ExcludeExts and AS plugin's
48+
// FileCollector.EXCLUDE_EXTS. Any change here MUST be mirrored in the other
49+
// two plugins — otherwise the three IDEs will start producing different
50+
// finding counts for the same project (VS v1.12.11 / AS v1.1.9 / VSCode v1.1.6
51+
// incident: 106 / 74 / ?? on the same WebGoat.NET).
52+
//
53+
// All entries must be LOWERCASE because the check is now case-insensitive.
4654
export const EXCLUDED_EXTENSIONS = new Set([
47-
'.DS_Store', '.ipr', '.iws', '.bak', '.tmp',
48-
'.aac', '.aif', '.iff', '.m3u', '.mid', '.mp3', '.mpa', '.ra', '.wav', '.wma',
49-
'.3g2', '.3gp', '.asf', '.asx', '.avi', '.flv', '.mov', '.mp4', '.mpg', '.rm', '.swf', '.vob', '.wmv',
50-
'.bmp', '.gif', '.jpg', '.jpeg', '.png', '.psd', '.tif', '.tiff', '.ico', '.svg', '.webp',
51-
'.jar', '.zip', '.rar', '.exe', '.dll', '.pdb', '.7z', '.gz', '.tar', '.war', '.ear',
52-
'.class', '.iml', '.o', '.so', '.dylib', '.pyc', '.pyo',
53-
'.woff', '.woff2', '.ttf', '.eot', '.otf',
54-
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
55-
'.sqlite', '.db', '.lock',
55+
'.zip', '.dll', '.pdf', '.exe', '.ds_store', '.bak', '.tmp',
56+
'.mp3', '.mp4', '.wav', '.avi', '.mov', '.wmv', '.flv',
57+
'.bmp', '.gif', '.jpg', '.jpeg', '.png', '.psd', '.tif', '.tiff', '.ico', '.svg',
58+
'.jar', '.rar', '.7z', '.gz', '.tar', '.war', '.ear',
59+
'.pdb', '.class', '.iml', '.nupkg', '.vsix', '.aar',
60+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
61+
'.db', '.sqlite', '.mdb', '.lock',
62+
'.sln', '.csproj', '.vbproj', '.vcxproj', '.fsproj', '.proj',
63+
'.suo', '.user', '.cache', '.snk', '.pfx', '.p12',
5664
]);
5765

66+
// KEEP IN LOCKSTEP with VS plugin's ScanCache.ExcludeFolders and AS plugin's
67+
// FileCollector.SKIP_DIRS. Case-insensitive check — all entries lowercase.
68+
// NOTE: backup<N> folders are matched by isExcludedFolder() pattern in
69+
// fileService.ts, not by this literal set, so don't add backup4/5/etc here.
5870
export const EXCLUDED_DIRS = new Set([
59-
'node_modules', '.git', '.svn', '.hg', '.bzr',
60-
'bin', 'obj', 'out', 'dist', 'build', 'target',
61-
'.idea', '.vscode', '.vs',
62-
'backup', '__pycache__', '.cache',
63-
'vendor', 'packages', 'bower_components',
64-
'coverage', '.nyc_output',
71+
'.vs', 'cvs', '.svn', '.hg', '.git', '.bzr', 'bin', 'obj',
72+
'.idea', '.vscode', 'node_modules', 'packages',
73+
'dist', 'build', 'out', 'target', '.gradle', '__pycache__',
74+
'.sasto360', 'testresults', 'test-results', '.nuget',
75+
'.node_modules', '.pytest_cache', '.next', 'coverage',
6576
]);
6677

78+
/**
79+
* True if the given single-segment folder name should be skipped.
80+
* Combines literal-set lookup with a pattern match for backup folders so
81+
* VS migration's auto-created Backup4/Backup5 (and any future variant)
82+
* is automatically excluded without updating the literal list. Matches
83+
* "backup", "backups", or "backup<digits>".
84+
*/
85+
export function isExcludedFolder(name: string): boolean {
86+
if (!name) return false;
87+
const lower = name.toLowerCase();
88+
if (EXCLUDED_DIRS.has(lower)) return true;
89+
if (lower === 'backup' || lower === 'backups') return true;
90+
if (lower.startsWith('backup') && lower.length > 6) {
91+
const tail = lower.substring(6);
92+
return /^\d+$/.test(tail);
93+
}
94+
return false;
95+
}
96+
6797
export const MAX_QUEUE_WAIT_MINUTES = 60;
6898
export const QUEUE_POLL_INTERVAL_SEC = 10;

src/extension.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,50 @@ export function activate(context: vscode.ExtensionContext) {
173173
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
174174
}));
175175

176+
// Check for Updates
177+
context.subscriptions.push(vscode.commands.registerCommand(COMMANDS.CHECK_UPDATE, async () => {
178+
const currentVersion = context.extension.packageJSON.version || '1.1.8';
179+
const apiUrl = 'https://api.github.com/repos/offensive360/VSCode/releases/latest';
180+
try {
181+
const https = await import('https');
182+
const body: string = await new Promise((resolve, reject) => {
183+
const req = https.get(apiUrl, {
184+
headers: { 'User-Agent': `Offensive360-VSCode/${currentVersion}`, 'Accept': 'application/vnd.github+json' },
185+
timeout: 15000
186+
}, (res) => {
187+
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
188+
let data = '';
189+
res.on('data', (chunk: string) => data += chunk);
190+
res.on('end', () => resolve(data));
191+
});
192+
req.on('error', reject);
193+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
194+
});
195+
const release = JSON.parse(body);
196+
const latestVersion = (release.tag_name || '').replace(/^v/i, '');
197+
if (!latestVersion) {
198+
vscode.window.showInformationMessage('Could not determine the latest version.');
199+
return;
200+
}
201+
const isNewer = latestVersion.localeCompare(currentVersion, undefined, { numeric: true }) > 0;
202+
if (!isNewer) {
203+
vscode.window.showInformationMessage(`Offensive 360: You're up to date (v${currentVersion}).`);
204+
return;
205+
}
206+
const notes = (release.body || '').substring(0, 500);
207+
const action = await vscode.window.showInformationMessage(
208+
`Offensive 360: v${latestVersion} is available (you have v${currentVersion}).\n\n${notes}`,
209+
'Download', 'Later'
210+
);
211+
if (action === 'Download') {
212+
const url = release.html_url || `https://github.com/offensive360/VSCode/releases/tag/v${latestVersion}`;
213+
vscode.env.openExternal(vscode.Uri.parse(url));
214+
}
215+
} catch (err: any) {
216+
vscode.window.showWarningMessage(`Could not check for updates: ${err.message || 'Unknown error'}`);
217+
}
218+
}));
219+
176220
// ── Auto-scan on save ────────────────────────────────────
177221

178222
context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(async (document) => {

src/providers/diagnosticManager.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,19 @@ export class DiagnosticManager {
5656

5757
/**
5858
* Process scan results and populate diagnostics.
59+
*
60+
* RECONCILIATION CONTRACT (2026-04-08):
61+
* The diagnostic collection shows EXACTLY what the server returned — no
62+
* client-side dedup, no content-based filtering, no silent drops. The only
63+
* reason a finding can be hidden is the user's explicit ignore-list entry.
64+
* Post-populate assertion logs any drift between server count and UI count.
5965
*/
6066
processVulnerabilities(vulnerabilities: Vulnerability[], basePath: string): void {
67+
// Wipe first so stale rows from a previous scan can never leak through.
6168
this.clear();
6269

70+
const serverCount = vulnerabilities ? vulnerabilities.length : 0;
71+
6372
if (!vulnerabilities || vulnerabilities.length === 0) {
6473
this._onDidUpdate.fire();
6574
return;
@@ -70,19 +79,6 @@ export class DiagnosticManager {
7079
vuln.riskLevel = normalizeRiskLevel(vuln.riskLevel as any);
7180
}
7281

73-
// Filter out findings with no description AND no valid file — incomplete results
74-
// Deduplicate by file+line (same location = same finding even if different type)
75-
const seen = new Set<string>();
76-
vulnerabilities = vulnerabilities.filter(v => {
77-
const hasDesc = !!(v.vulnerability && v.vulnerability.trim());
78-
const hasFile = !!(v.filePath && v.filePath.trim());
79-
if (!hasDesc && !hasFile) { return false; }
80-
const key = `${v.filePath}|${v.lineNumber}`;
81-
if (seen.has(key)) { return false; }
82-
seen.add(key);
83-
return true;
84-
});
85-
8682
const ignoreEntries = this.fileService.readIgnoreEntries();
8783
const fileDiagnostics = new Map<string, { diagnostics: vscode.Diagnostic[]; items: VulnerabilityItem[] }>();
8884

@@ -121,10 +117,19 @@ export class DiagnosticManager {
121117
}
122118

123119
// Apply diagnostics
120+
let uiCount = 0;
124121
for (const [filePath, entry] of fileDiagnostics) {
125122
const uri = vscode.Uri.file(filePath);
126123
this.diagnosticCollection.set(uri, entry.diagnostics);
127124
this.vulnerabilityMap.set(filePath, entry.items);
125+
uiCount += entry.items.length;
126+
}
127+
128+
// RECONCILIATION ASSERTION: UI count must equal (server count - suppressed by ignore list).
129+
// Log a loud warning if drift ever occurs so silent filtering bugs are impossible to hide.
130+
const ignored = serverCount - uiCount;
131+
if (ignored < 0) {
132+
console.warn(`[O360] count drift: server returned ${serverCount}, UI rendered ${uiCount} (excess of ${-ignored})`);
128133
}
129134

130135
this._onDidUpdate.fire();

src/services/fileService.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as path from 'path';
33
import * as fs from 'fs';
44
import * as os from 'os';
55
import * as crypto from 'crypto';
6-
import { EXCLUDED_EXTENSIONS, EXCLUDED_DIRS } from '../constants';
6+
import { EXCLUDED_EXTENSIONS, EXCLUDED_DIRS, isExcludedFolder } from '../constants';
77

88
const zipdir = require('zip-dir');
99

@@ -13,6 +13,31 @@ const LARGE_PROJECT_WARNING = 5_000;
1313

1414
export class FileService {
1515

16+
/**
17+
* Count source files using the same exclusion rules as zipDirectoryToFile,
18+
* without zipping. Used for content-fingerprint matching against the dashboard's
19+
* totalScannedCodeFiles field.
20+
*/
21+
async countScannableFiles(dirPath: string): Promise<number> {
22+
if (!dirPath) return 0;
23+
try { if (!fs.existsSync(dirPath)) return 0; } catch { return 0; }
24+
let count = 0;
25+
const walk = (dir: string) => {
26+
let entries: fs.Dirent[];
27+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
28+
for (const entry of entries) {
29+
const fullPath = path.join(dir, entry.name);
30+
if (entry.isDirectory()) {
31+
if (!isExcludedFolder(entry.name)) walk(fullPath);
32+
} else if (this.shouldInclude(fullPath)) {
33+
count++;
34+
}
35+
}
36+
};
37+
walk(dirPath);
38+
return count;
39+
}
40+
1641
/**
1742
* Zip a directory to a temp file on disk (streaming).
1843
* For large codebases (500MB+) this avoids Node.js OOM by writing to disk.
@@ -26,7 +51,8 @@ export class FileService {
2651
for (const entry of entries) {
2752
const fullPath = path.join(dir, entry.name);
2853
if (entry.isDirectory()) {
29-
if (!EXCLUDED_DIRS.has(entry.name)) {
54+
// Pattern-based exclusion (handles Backup, Backup1..N, BackupN, etc.)
55+
if (!isExcludedFolder(entry.name)) {
3056
countFiles(fullPath);
3157
}
3258
} else if (this.shouldInclude(fullPath)) {
@@ -87,18 +113,26 @@ export class FileService {
87113

88114
/**
89115
* Check if a file should be included in the scan zip.
116+
* Case-insensitive on BOTH extension and directory parts so we match
117+
* VS plugin behaviour (e.g. Backup1/ == backup1/).
90118
*/
91119
private shouldInclude(filePath: string): boolean {
92120
const ext = path.extname(filePath).toLowerCase();
93-
const basename = path.basename(filePath);
94-
95-
if (EXCLUDED_EXTENSIONS.has(ext) || EXCLUDED_EXTENSIONS.has(basename)) {
121+
if (EXCLUDED_EXTENSIONS.has(ext)) {
96122
return false;
97123
}
98124

125+
// Skip oversized files (50MB cap, matches VS/AS plugins).
126+
try {
127+
const stat = fs.statSync(filePath);
128+
if (stat.isFile() && stat.size > MAX_FILE_SIZE_BYTES) {
129+
return false;
130+
}
131+
} catch { /* best-effort size check */ }
132+
99133
const parts = filePath.replace(/\\/g, '/').split('/');
100134
for (const part of parts) {
101-
if (EXCLUDED_DIRS.has(part)) {
135+
if (isExcludedFolder(part)) {
102136
return false;
103137
}
104138
}

0 commit comments

Comments
 (0)