diff --git a/README.md b/README.md index 742b42d..3b3aab1 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,44 @@ -# ContractGuard for VS Code +# contract-guard -ContractGuard is a VS Code extension backed by a Python security analysis core. It scans source trees for schema drift, risky SQL, regex complexity, secrets, PII, insecure configuration, Dockerfile issues, and vulnerable dependencies, then surfaces the results as diagnostics, a findings explorer, a status bar score, and SARIF exports. +`contract-guard` helps you find security and reliability issues in code, configs, queries, Dockerfiles, and dependency files without leaving VS Code. -## What ships in this repository +## Features -- A reusable Python engine in `src/contractguard` with rule-driven analyzers, scoring, findings, history, and SARIF generation. -- A VS Code extension in `vscode-src` that runs the engine in a separate Python process and renders results inside the editor. -- Rules in `rules/` that stay bundled with the extension and CLI. +- Scan the current file +- Scan the full workspace +- Show findings in a dedicated explorer view +- Publish inline diagnostics in the editor +- Export SARIF for external security workflows +- Show an overall security score in the status bar -## Supported analyzers +## What it checks -- JSON schema analysis -- SQL analysis -- Regex complexity analysis -- Secrets detection -- PII detection -- Config security analysis -- Dockerfile linting -- Dependency vulnerability analysis +- JSON schema inconsistencies +- SQL query risks and anti-patterns +- Regex complexity and ReDoS risks +- Hardcoded secrets +- PII exposure +- Insecure configuration +- Dockerfile issues +- Dependency vulnerabilities -## VS Code features +## Commands - `ContractGuard: Scan Workspace` - `ContractGuard: Scan Current File` - `ContractGuard: Export SARIF` - `ContractGuard: Clear Findings` -- Findings tree view grouped by severity -- Inline diagnostics and quick navigation -- Status bar security grade -- Debounced scan-on-save -- Configurable analyzer set and disabled rules +- `ContractGuard: Install Python Runtime Dependencies` -## Runtime requirements +## Requirements -- Python 3.11+ available on the machine running VS Code -- Python packages from `python-requirements.txt` +- Python 3.11 or newer -For local development in this repository: +If the Python runtime dependencies are missing, run: -```powershell -python -m venv .venv -.\.venv\Scripts\Activate.ps1 -pip install -r python-requirements.txt -``` +- `ContractGuard: Install Python Runtime Dependencies` -## Development commands - -Python: - -```powershell -.\.venv\Scripts\python.exe -m pytest -.\.venv\Scripts\python.exe -m contractguard.bridge scan --path . --analyzer all --include-sarif -``` - -Extension: - -```powershell -node .\node_modules\typescript\bin\tsc -p .\tsconfig.json -node .\node_modules\@vscode\vsce\vsce package -``` - -## Settings +## Extension Settings - `contractguard.pythonPath` - `contractguard.scanOnSave` @@ -70,6 +48,7 @@ node .\node_modules\@vscode\vsce\vsce package - `contractguard.rulesDirectory` - `contractguard.sqlExplainDatabase` -## Packaging +## Notes -The extension is packaged from the repository root. The VSIX includes the compiled extension, bundled Python source, rules, and documentation. The output artifact is written to `dist-vsix/`. +- The extension runs analysis locally. +- SARIF export is available for CI and external security tooling. diff --git a/package-lock.json b/package-lock.json index d44c5c1..f87d8bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "contractguard", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "contractguard", - "version": "1.0.0", + "version": "1.1.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.16.1", diff --git a/package.json b/package.json index 578a1e1..3096189 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "contractguard", - "displayName": "ContractGuard", + "name": "contract-guard", + "displayName": "contract-guard", "description": "Security analysis for code, config, Dockerfiles, data payloads, and dependencies.", - "version": "1.0.0", + "version": "1.1.0", "publisher": "BlackplaneSystems", "license": "Apache-2.0", "icon": "media/icon.png", @@ -158,7 +158,7 @@ }, "scripts": { "build": "tsc -p ./tsconfig.json", - "package": "node -e \"require('fs').mkdirSync('dist-vsix',{recursive:true})\" && vsce package --out dist-vsix/contractguard-1.0.0.vsix", + "package": "node -e \"require('fs').mkdirSync('dist-vsix',{recursive:true})\" && vsce package --out dist-vsix/contractguard-1.1.0.vsix", "prepackage": "npm run build" }, "devDependencies": { diff --git a/pyproject.toml b/pyproject.toml index a845ba7..00a2766 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "contractguard" -version = "1.0.0" +version = "1.1.0" description = "ContractGuard security analysis core for VS Code and CI workflows." readme = "README.md" license = {text = "Apache-2.0"} diff --git a/python-requirements.txt b/python-requirements.txt index e34cde3..5a7c49b 100644 --- a/python-requirements.txt +++ b/python-requirements.txt @@ -4,6 +4,3 @@ pyyaml>=6.0 jsonschema>=4.20.0 sqlparse>=0.5.0 jinja2>=3.1.0 -pytest>=7.4.0 -pytest-cov>=4.1.0 -httpx>=0.25.0 diff --git a/src/contractguard/__init__.py b/src/contractguard/__init__.py index 1645bc6..91e3269 100644 --- a/src/contractguard/__init__.py +++ b/src/contractguard/__init__.py @@ -1,3 +1,3 @@ """ContractGuard core package.""" -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/src/contractguard/reporter.py b/src/contractguard/reporter.py index 4c3fa7c..7ba3719 100644 --- a/src/contractguard/reporter.py +++ b/src/contractguard/reporter.py @@ -209,8 +209,8 @@ def render_sarif_report( "tool": { "driver": { "name": "ContractGuard", - "version": "1.0.0", - "informationUri": "https://github.com/contractguard/contractguard", + "version": "1.1.0", + "informationUri": "https://github.com/Blackplane-Systems/contractguard", "rules": rules, } }, diff --git a/vscode-src/extension.ts b/vscode-src/extension.ts index 0d73f58..3d175c0 100644 --- a/vscode-src/extension.ts +++ b/vscode-src/extension.ts @@ -7,6 +7,7 @@ import { installPythonRuntime, runContractGuardScan } from './pythonBridge'; import { Finding, ScanPayload, Severity } from './types'; const sourceName = 'ContractGuard'; +const analyzerIds = ['json', 'sql', 'regex', 'secrets', 'pii', 'config', 'dockerfile', 'deps'] as const; const supportedExtensions = new Set([ '.json', '.sql', @@ -29,7 +30,13 @@ class ContractGuardController implements vscode.Disposable { private readonly statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); private scanTimer: NodeJS.Timeout | undefined; private running = false; - private queuedScan: (() => void) | undefined; + private queuedRequest: + | { targetPath: string; analyzer: string; includeSarif: boolean } + | undefined; + private queuedScanPromise: Promise | undefined; + private resolveQueuedScan: ((payload: ScanPayload) => void) | undefined; + private rejectQueuedScan: ((error: unknown) => void) | undefined; + private scheduledScanAction: (() => Promise) | undefined; private latestPayload: ScanPayload | undefined; constructor(private readonly context: vscode.ExtensionContext) { @@ -59,7 +66,7 @@ class ContractGuardController implements vscode.Disposable { vscode.window.showInformationMessage('ContractGuard requires an open workspace.'); return; } - await this.runScan(workspacePath, 'all', includeSarif); + await this.runWorkspaceScan(workspacePath, includeSarif); } async scanCurrentFile(): Promise { @@ -68,7 +75,13 @@ class ContractGuardController implements vscode.Disposable { vscode.window.showInformationMessage('No active file to scan.'); return; } - await this.runScan(document.uri.fsPath, this.selectAnalyzer(document.uri.fsPath), false); + const filePath = document.uri.fsPath; + const analyzer = this.selectAnalyzer(filePath); + if (analyzer === 'all') { + vscode.window.showInformationMessage(`ContractGuard does not support scanning this file type: ${path.basename(filePath)}`); + return; + } + await this.runScan(filePath, analyzer, false); } clear(): void { @@ -99,18 +112,25 @@ class ContractGuardController implements vscode.Disposable { return; } - fs.writeFileSync(target.fsPath, JSON.stringify(payload.sarif, null, 2), 'utf8'); + await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(JSON.stringify(payload.sarif, null, 2))); vscode.window.showInformationMessage(`ContractGuard SARIF exported to ${target.fsPath}`); } scheduleWorkspaceScan(): void { - const debounceMs = vscode.workspace.getConfiguration('contractguard').get('scanDebounceMs', 600); - if (this.scanTimer) { - clearTimeout(this.scanTimer); + this.scheduleScan(async () => { + await this.scanWorkspace(false); + }); + } + + scheduleFileScan(filePath: string): void { + const analyzer = this.selectAnalyzer(filePath); + if (analyzer === 'all') { + return; } - this.scanTimer = setTimeout(() => { - void this.scanWorkspace(false); - }, debounceMs); + + this.scheduleScan(async () => { + await this.runScan(filePath, analyzer, false); + }); } async openFinding(finding: Finding): Promise { @@ -127,16 +147,30 @@ class ContractGuardController implements vscode.Disposable { } private async collectWorkspaceSarif(workspacePath: string): Promise { - return await this.runScan(workspacePath, 'all', true); + return await this.runWorkspaceScan(workspacePath, true); + } + + private async runWorkspaceScan(workspacePath: string, includeSarif: boolean): Promise { + const analyzers = this.getEnabledAnalyzers(); + const payloads: ScanPayload[] = []; + + for (const analyzer of analyzers) { + payloads.push(await this.runScan(workspacePath, analyzer, includeSarif)); + } + + return this.mergePayloads(workspacePath, payloads, includeSarif); } private async runScan(targetPath: string, analyzer: string, includeSarif: boolean): Promise { if (this.running) { - return await new Promise((resolve) => { - this.queuedScan = () => { - void this.runScan(targetPath, analyzer, includeSarif).then(resolve); - }; - }); + this.queuedRequest = { targetPath, analyzer, includeSarif }; + if (!this.queuedScanPromise) { + this.queuedScanPromise = new Promise((resolve, reject) => { + this.resolveQueuedScan = resolve; + this.rejectQueuedScan = reject; + }); + } + return await this.queuedScanPromise; } this.running = true; @@ -172,14 +206,90 @@ class ContractGuardController implements vscode.Disposable { throw error; } finally { this.running = false; - const queued = this.queuedScan; - this.queuedScan = undefined; - if (queued) { - queued(); + const queuedRequest = this.queuedRequest; + const resolveQueuedScan = this.resolveQueuedScan; + const rejectQueuedScan = this.rejectQueuedScan; + this.queuedRequest = undefined; + this.queuedScanPromise = undefined; + this.resolveQueuedScan = undefined; + this.rejectQueuedScan = undefined; + if (queuedRequest && resolveQueuedScan && rejectQueuedScan) { + void this.runScan(queuedRequest.targetPath, queuedRequest.analyzer, queuedRequest.includeSarif).then( + resolveQueuedScan, + rejectQueuedScan + ); } } } + private scheduleScan(action: () => Promise): void { + const debounceMs = vscode.workspace.getConfiguration('contractguard').get('scanDebounceMs', 600); + this.scheduledScanAction = action; + if (this.scanTimer) { + clearTimeout(this.scanTimer); + } + this.scanTimer = setTimeout(() => { + const scheduledScanAction = this.scheduledScanAction; + this.scheduledScanAction = undefined; + if (scheduledScanAction) { + void scheduledScanAction(); + } + }, debounceMs); + } + + private getEnabledAnalyzers(): string[] { + const configured = vscode.workspace.getConfiguration('contractguard').get('enabledAnalyzers', []); + if (configured.length === 0) { + return [...analyzerIds]; + } + const configuredSet = new Set(configured); + return analyzerIds.filter((analyzer) => configuredSet.has(analyzer)); + } + + private mergePayloads(workspacePath: string, payloads: ScanPayload[], includeSarif: boolean): ScanPayload { + const findings = payloads.flatMap((payload) => payload.findings); + const score = this.recomputeScore( + payloads[0]?.score ?? { + grade: 'A', + score: 100, + total_findings: 0, + block_count: 0, + critical_count: 0, + warning_count: 0, + info_count: 0, + risk_summary: '', + attack_surface: [], + top_risks: [] + }, + findings + ); + const sarif = includeSarif + ? { + version: '2.1.0', + $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json', + runs: payloads.flatMap((payload) => { + const runs = payload.sarif && 'runs' in payload.sarif ? payload.sarif.runs : []; + return Array.isArray(runs) ? runs : []; + }) + } + : null; + + const mergedPayload: ScanPayload = { + target: workspacePath, + analyzer: payloads.length === 1 ? payloads[0].analyzer : 'all', + engine_version: payloads[0]?.engine_version ?? 'unknown', + generated_at: payloads[0]?.generated_at ?? null, + findings, + score, + sarif + }; + this.latestPayload = mergedPayload; + this.publishDiagnostics(findings); + this.tree.setFindings(findings); + this.updateStatusBar(mergedPayload); + return mergedPayload; + } + private filterFindings(findings: Finding[]): Finding[] { const disabledRules = new Set( vscode.workspace.getConfiguration('contractguard').get('disabledRules', []).map((item) => item.trim()) @@ -207,10 +317,28 @@ class ContractGuardController implements vscode.Disposable { warning_count: findings.filter((item) => item.severity === 'warning').length, info_count: findings.filter((item) => item.severity === 'info').length }; + const attackSurface = [...new Set(findings.flatMap((item) => score.attack_surface.includes(item.attack_vector) ? [item.attack_vector] : []))]; + const topRisks = [...new Set(findings.map((item) => `[${item.severity.toUpperCase()}] ${item.description}`))].slice(0, 5); + const scoreValue = Math.max( + 0, + 100 - counts.block_count * 20 - counts.critical_count * 10 - counts.warning_count * 4 - counts.info_count + ); + const grade = + counts.block_count > 0 ? 'F' + : scoreValue >= 90 ? 'A' + : scoreValue >= 75 ? 'B' + : scoreValue >= 55 ? 'C' + : scoreValue >= 35 ? 'D' + : 'F'; + return { ...score, + grade, + score: counts.block_count > 0 ? Math.min(scoreValue, 15) : scoreValue, ...counts, - total_findings: findings.length + total_findings: findings.length, + attack_surface: attackSurface, + top_risks: topRisks }; } @@ -360,7 +488,7 @@ export function activate(context: vscode.ExtensionContext): void { if (document.uri.scheme !== 'file') { return; } - controller.scheduleWorkspaceScan(); + controller.scheduleFileScan(document.uri.fsPath); }), vscode.workspace.onDidChangeConfiguration((event) => { if (event.affectsConfiguration('contractguard')) {