diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 950cdd5..c7b2473 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: exclude: ^(ggshield-internal) - repo: https://github.com/gitguardian/ggshield - rev: v1.43.0 + rev: v1.50.3 hooks: - id: ggshield language_version: python3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fccd69..1072f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### Added - Quota view now shows the connected GitGuardian instance and exposes a title-bar action to open the instance URL setting. The view refreshes automatically when `gitguardian.apiUrl` changes. +- New "Findings" tree view in the GitGuardian sidebar listing detected secrets grouped by file. + +### Fixed + +- Diagnostics for a file are now cleared when a re-scan finds no secrets, so previously reported findings no longer linger after they have been resolved. ### Changed diff --git a/package.json b/package.json index 434e601..739210f 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,15 @@ "dark": "images/refresh-dark.svg" } }, + { + "command": "gitguardian.refreshFindings", + "title": "Refresh Findings", + "category": "GitGuardian", + "icon": { + "light": "images/refresh-light.svg", + "dark": "images/refresh-dark.svg" + } + }, { "command": "gitguardian.openInstanceSettings", "title": "Configure instance URL", @@ -125,6 +134,12 @@ }, "views": { "gitguardian": [ + { + "type": "tree", + "id": "gitguardianFindingsView", + "name": "Findings", + "when": "isAuthenticated == true" + }, { "type": "webview", "id": "gitguardianView", @@ -150,6 +165,10 @@ { "view": "gitguardianView", "contents": "To get started with GitGuardian, please authenticate.\n[Authenticate](command:gitguardian.authenticate)" + }, + { + "view": "gitguardianFindingsView", + "contents": "No secrets detected yet.\nSave a file to scan it for secrets." } ], "menus": { @@ -163,6 +182,11 @@ "command": "gitguardian.openInstanceSettings", "group": "navigation@2", "when": "view == gitguardianQuotaView" + }, + { + "command": "gitguardian.refreshFindings", + "group": "navigation@1", + "when": "view == gitguardianFindingsView" } ] } diff --git a/src/extension.ts b/src/extension.ts index 22e4cab..aac8ca0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,6 +31,7 @@ import { } from "./gitguardian-interface/gitguardian-hover-provider"; import { GitGuardianQuotaWebviewProvider } from "./ggshield-webview/gitguardian-quota-webview"; import { GitGuardianRemediationMessageWebviewProvider } from "./ggshield-webview/gitguardian-remediation-message-view"; +import { GitGuardianFindingsProvider } from "./gitguardian-interface/gitguardian-findings-tree"; import { AuthenticationStatus, loginGGShield, @@ -111,6 +112,13 @@ export async function activate(context: ExtensionContext) { ggshieldQuotaViewProvider, ); + const findingsProvider = new GitGuardianFindingsProvider(); + const findingsTreeView = window.createTreeView("gitguardianFindingsView", { + treeDataProvider: findingsProvider, + showCollapseAll: true, + }); + context.subscriptions.push(findingsProvider, findingsTreeView); + createStatusBarItem(context); //generic commands to open correct view on status bar click @@ -125,6 +133,9 @@ export async function activate(context: ExtensionContext) { "gitguardian.apiUrl", ), ), + commands.registerCommand("gitguardian.refreshFindings", () => + findingsProvider.refresh(), + ), ); context.subscriptions.push( @@ -169,6 +180,17 @@ export async function activate(context: ExtensionContext) { // Start scanning documents on activation events // (i.e. when a new document is opened or when the document is saved) createDiagnosticCollection(context); + + // Clean up diagnostics when a tracked file is renamed. Deletes are handled by + // per-URI watchers registered in ggshield-api when diagnostics are produced. + context.subscriptions.push( + workspace.onDidRenameFiles((event) => { + for (const { oldUri } of event.files) { + cleanUpFileDiagnostics(oldUri); + } + }), + ); + context.subscriptions.push( { dispose: cancelInFlightScans }, workspace.onDidSaveTextDocument((textDocument) => { @@ -190,9 +212,6 @@ export async function activate(context: ExtensionContext) { }); } }), - workspace.onDidCloseTextDocument((textDocument) => - cleanUpFileDiagnostics(textDocument.uri), - ), commands.registerCommand("gitguardian.quota", async () => { try { await showAPIQuota(ggshieldResolver.configuration); diff --git a/src/gitguardian-interface/gitguardian-findings-tree.ts b/src/gitguardian-interface/gitguardian-findings-tree.ts new file mode 100644 index 0000000..a0b5b85 --- /dev/null +++ b/src/gitguardian-interface/gitguardian-findings-tree.ts @@ -0,0 +1,172 @@ +import * as vscode from "vscode"; +import { GitGuardianDiagnostic } from "../lib/ggshield-results-parser"; + +type FindingsNode = FileNode | SecretNode | MatchNode; + +class FileNode extends vscode.TreeItem { + constructor( + public readonly uri: vscode.Uri, + public readonly diagnostics: GitGuardianDiagnostic[], + ) { + super( + vscode.workspace.asRelativePath(uri), + vscode.TreeItemCollapsibleState.Expanded, + ); + this.resourceUri = uri; + const secretCount = new Set(diagnostics.map((d) => d.secretSha)).size; + this.description = `${secretCount} secret${secretCount === 1 ? "" : "s"}`; + this.contextValue = "gitguardianFindingsFile"; + } +} + +class SecretNode extends vscode.TreeItem { + constructor( + public readonly uri: vscode.Uri, + public readonly diagnostics: GitGuardianDiagnostic[], + ) { + const first = diagnostics[0]; + const isSingle = diagnostics.length === 1; + super( + first.detector || "Secret", + isSingle + ? vscode.TreeItemCollapsibleState.None + : vscode.TreeItemCollapsibleState.Collapsed, + ); + this.iconPath = new vscode.ThemeIcon( + "warning", + new vscode.ThemeColor("editorWarning.foreground"), + ); + this.contextValue = "gitguardianFindingsItem"; + if (isSingle) { + this.description = `Line ${first.range.start.line + 1}`; + const tooltip = new vscode.MarkdownString(); + tooltip.appendCodeblock(first.message, "text"); + this.tooltip = tooltip; + this.command = { + command: "vscode.open", + title: "Open Finding", + arguments: [uri, { selection: first.range }], + }; + } else { + this.description = `${diagnostics.length} matches`; + } + } +} + +class MatchNode extends vscode.TreeItem { + constructor( + public readonly uri: vscode.Uri, + public readonly diagnostic: GitGuardianDiagnostic, + ) { + super( + diagnostic.matchType || "match", + vscode.TreeItemCollapsibleState.None, + ); + this.description = `Line ${diagnostic.range.start.line + 1}`; + const tooltip = new vscode.MarkdownString(); + tooltip.appendCodeblock(diagnostic.message, "text"); + this.tooltip = tooltip; + this.command = { + command: "vscode.open", + title: "Open Finding", + arguments: [uri, { selection: diagnostic.range }], + }; + this.contextValue = "gitguardianFindingsItem"; + } +} + +function groupBySecret( + diagnostics: GitGuardianDiagnostic[], +): GitGuardianDiagnostic[][] { + const groups = new Map(); + for (const d of diagnostics) { + const key = d.secretSha; + const group = groups.get(key); + if (group) { + group.push(d); + } else { + groups.set(key, [d]); + } + } + return Array.from(groups.values()).map((group) => + group.slice().sort((a, b) => a.range.start.line - b.range.start.line), + ); +} + +export class GitGuardianFindingsProvider + implements vscode.TreeDataProvider, vscode.Disposable +{ + private readonly _onDidChangeTreeData = new vscode.EventEmitter< + FindingsNode | undefined | void + >(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private readonly diagnosticsListener: vscode.Disposable; + // Tracks URIs that currently have GitGuardian diagnostics so we can detect + // when GG diagnostics are removed (the post-change `getDiagnostics(uri)` call + // alone can't tell us a GG diagnostic just disappeared from this URI). + private readonly ggUris = new Set(); + + constructor() { + this.diagnosticsListener = vscode.languages.onDidChangeDiagnostics((e) => { + let relevant = false; + for (const uri of e.uris) { + const key = uri.toString(); + const hasGG = vscode.languages + .getDiagnostics(uri) + .some((d) => d.source?.trim() === "gitguardian"); + const hadGG = this.ggUris.has(key); + if (hasGG) { + this.ggUris.add(key); + } else if (hadGG) { + this.ggUris.delete(key); + } + if (hasGG || hadGG) { + relevant = true; + } + } + if (relevant) { + this._onDidChangeTreeData.fire(); + } + }); + } + + refresh(): void { + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: FindingsNode): vscode.TreeItem { + return element; + } + + getChildren(element?: FindingsNode): FindingsNode[] { + if (!element) { + return vscode.languages + .getDiagnostics() + .map(([uri, diagnostics]) => { + const ggDiagnostics = diagnostics.filter( + (d) => d.source?.trim() === "gitguardian", + ) as GitGuardianDiagnostic[]; + return ggDiagnostics.length > 0 + ? new FileNode(uri, ggDiagnostics) + : undefined; + }) + .filter((node): node is FileNode => node !== undefined) + .sort((a, b) => (a.label as string).localeCompare(b.label as string)); + } + if (element instanceof FileNode) { + return groupBySecret(element.diagnostics) + .sort((a, b) => a[0].range.start.line - b[0].range.start.line) + .map((group) => new SecretNode(element.uri, group)); + } + if (element instanceof SecretNode && element.diagnostics.length > 1) { + return element.diagnostics.map((d) => new MatchNode(element.uri, d)); + } + return []; + } + + dispose(): void { + this.diagnosticsListener.dispose(); + this._onDidChangeTreeData.dispose(); + } +} diff --git a/src/lib/ggshield-api.ts b/src/lib/ggshield-api.ts index 7413af2..c1f414b 100644 --- a/src/lib/ggshield-api.ts +++ b/src/lib/ggshield-api.ts @@ -1,11 +1,15 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import * as path from "path"; import { window, + Disposable, DiagnosticCollection, ExtensionContext, languages, + RelativePattern, Uri, Diagnostic, + workspace, } from "vscode"; import { GGShieldConfiguration } from "./ggshield-configuration"; import { isFileGitignored } from "../utils"; @@ -21,6 +25,60 @@ import { parseGGShieldResults } from "./ggshield-results-parser"; */ export let diagnosticCollection: DiagnosticCollection; +// Per-URI delete watchers, registered only for files we currently track +// diagnostics for. +const fileWatchers = new Map(); + +function watchUriForRemoval(fileUri: Uri): void { + const key = fileUri.toString(); + if (fileWatchers.has(key)) { + return; + } + const folder = workspace.getWorkspaceFolder(fileUri); + if (!folder) { + return; + } + const relativePath = path.relative(folder.uri.fsPath, fileUri.fsPath); + if (!relativePath || relativePath.startsWith("..")) { + return; + } + // RelativePattern expects a glob, so use forward slashes on Windows too. + const globPath = relativePath.split(path.sep).join("/"); + const watcher = workspace.createFileSystemWatcher( + new RelativePattern(folder, globPath), + true, // ignoreCreateEvents + true, // ignoreChangeEvents + false, // listen for delete + ); + const subscription = watcher.onDidDelete(() => { + cleanUpFileDiagnostics(fileUri); + }); + fileWatchers.set(key, { + dispose: () => { + subscription.dispose(); + watcher.dispose(); + }, + }); +} + +function unwatchUri(fileUri: Uri): void { + const key = fileUri.toString(); + const watcher = fileWatchers.get(key); + if (watcher) { + watcher.dispose(); + fileWatchers.delete(key); + } +} + +function setDiagnostics(fileUri: Uri, diagnostics: Diagnostic[]): void { + diagnosticCollection.set(fileUri, diagnostics); + if (diagnostics.length > 0) { + watchUriForRemoval(fileUri); + } else { + unwatchUri(fileUri); + } +} + // Tracks the in-flight scan per URI so a later save can abort an earlier one // and we can drop stale results that return after being superseded. const inFlightScans = new Map(); @@ -131,6 +189,7 @@ export function createDiagnosticCollection(context: ExtensionContext): void { */ export function cleanUpFileDiagnostics(fileUri: Uri): void { diagnosticCollection.delete(fileUri); + unwatchUri(fileUri); } /** @@ -151,6 +210,7 @@ export async function scanFile( ): Promise { if (isFileGitignored(filePath)) { updateStatusBarItem(StatusBarStatus.ignoredFile); + cleanUpFileDiagnostics(fileUri); return; } @@ -198,16 +258,18 @@ export async function scanFile( ) ) { updateStatusBarItem(StatusBarStatus.ignoredFile); + cleanUpFileDiagnostics(fileUri); return; } return undefined; } else if (proc.status === 0) { updateStatusBarItem(StatusBarStatus.noSecretFound); + cleanUpFileDiagnostics(fileUri); return; } else { updateStatusBarItem(StatusBarStatus.secretFound); } const results = JSON.parse(proc.stdout); let incidentsDiagnostics: Diagnostic[] = parseGGShieldResults(results); - diagnosticCollection.set(fileUri, incidentsDiagnostics); + setDiagnostics(fileUri, incidentsDiagnostics); } diff --git a/src/lib/ggshield-results-parser.ts b/src/lib/ggshield-results-parser.ts index 1e4c2ed..0735a1b 100644 --- a/src/lib/ggshield-results-parser.ts +++ b/src/lib/ggshield-results-parser.ts @@ -28,6 +28,7 @@ const validityDisplayName: Record = { export interface GitGuardianDiagnostic extends Diagnostic { detector: string; secretSha: string; + matchType: string; details: string; } @@ -117,6 +118,7 @@ export function parseGGShieldResults( diagnostic.source = "gitguardian"; diagnostic.detector = incident.type; diagnostic.secretSha = incident.ignore_sha; + diagnostic.matchType = occurrence.type; diagnostic.details = buildDetails( incident, occurrence,