Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 24 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -125,6 +134,12 @@
},
"views": {
"gitguardian": [
{
"type": "tree",
"id": "gitguardianFindingsView",
"name": "Findings",
"when": "isAuthenticated == true"
},
{
"type": "webview",
"id": "gitguardianView",
Expand All @@ -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": {
Expand All @@ -163,6 +182,11 @@
"command": "gitguardian.openInstanceSettings",
"group": "navigation@2",
"when": "view == gitguardianQuotaView"
},
{
"command": "gitguardian.refreshFindings",
"group": "navigation@1",
"when": "view == gitguardianFindingsView"
}
]
}
Expand Down
25 changes: 22 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -125,6 +133,9 @@ export async function activate(context: ExtensionContext) {
"gitguardian.apiUrl",
),
),
commands.registerCommand("gitguardian.refreshFindings", () =>
findingsProvider.refresh(),
),
);

context.subscriptions.push(
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down
172 changes: 172 additions & 0 deletions src/gitguardian-interface/gitguardian-findings-tree.ts
Original file line number Diff line number Diff line change
@@ -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<string, GitGuardianDiagnostic[]>();
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<FindingsNode>, 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<string>();

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();
}
}
Loading
Loading