Our Experience Writing a VSCode Extension

One of the most significant goals we have at Adversa Labs is the native integration of passive security testing within popular development tools. This is critical in facilitating the adoption of proactive security testing practices early in software development. We also love Visual Studio Code (VSCode)! It is one of the few text editors / integrated development environments that continues to feel both lightweight and responsive across the variety of languages that we either use, support or plan to support for security testing. So it was a no-brainer for us to select VSCode as our first development tool integration. This post captures our experience in writing a VSCode extension. You can download the extension as well as review the source code. We are grateful for any feedback you may be willing to share.

While we love VSCode, this is admittedly our first attempt in writing a VSCode extension. Our primary goal was to enable developers to triage vulnerabilities from assessments directly within the VSCode Explorer. The Extending Visual Studio Code article was helpful in getting us started. We opted to use the Yeoman generator to create our skeleton project which was definitely a time saver. As a part of this exercise, we learned how significant the package.json is with respect to declaring our various extension facilities. We’ll focus on one such example in this write-up, which is providing the ability to triage vulnerabilities from directly within the VSCode Explorer.

The first step we had to take was to declare a section called activationEvents. This is an array of colon delimited strings, where the left-hand side denotes the type of activation event and the right-hand side captures some arbitrary value whose meaning is dependent on the activation event type. The Activation Events – package.json article was helpful in our effort to understand the format and capability of activationEvents. For us, we created an onView activation event with a value of vulnerabilityTreeDataProvider which maps to an identifier specified later in TypeScript code.

"activationEvents": [
    "onView:vulnerabilityTreeDataProvider"
]

The next step in exposing this view was to create a TypeScript object that could be registered with the vscode.window instance. We opted to create something called a TreeDataProvider. This would allow us to create a new tree-based widget on the left-hand side of the VSCode Explorer. The following snippet illustrated how we implemented a TreeDataProvider:

export class VulnerabilityTreeDataProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
    private _onDidChangeTreeData: vscode.EventEmitter<vscode.TreeItem> = new vscode.EventEmitter<vscode.TreeItem>();
    readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem> = this._onDidChangeTreeData.event;
    private assessment: Assessment | undefined = undefined;
    
    async load(assessment: Assessment) {
        this.assessment = assessment;
        this._onDidChangeTreeData.fire();
    }

    getChildren(element?: vscode.TreeItem): vscode.TreeItem[] {
        if (this.assessment === undefined) {
            return [];
        }

        if (element === undefined) {
            return SeverityItem.make(this.assessment);
        }

        if (element instanceof SeverityItem) {
            return VulnerabilitiesItem.make(element.severity, this.assessment);
        }

        if (element instanceof VulnerabilitiesItem) {
            return VulnerabilityItem.make(element.offsets, this.assessment);
        }

        throw new Error('unsupported element: ' + typeof element);
    }

    getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
        return element;
    }
}

The API was pretty straight-forward: getChildren requested what objects should be rendered within the tree widget given some arbitrary element, and getTreeItem requested a TreeItem compatible object to facilitate rendering. We opted to extend the TreeItem object to simplify the rendering of our model. If the element provided to getChildren is undefined, then we need to provide the root(s) of the tree. For us, this required the creation of one or more SeverityItem objects representative of the severities defined within CVSSv3. When a user selects one of the SeverityItem entries (ex: Critical), the getChildren method will be called with the selected SeverityItem instance. For any given SeverityItem, we return a VulnerabilitiesItem which represents a collection of vulnerability offsets found within the selected severity; and if the user selects a VulnerabilitiesItem, we then display the actual vulnerability detail via the VulnerabilityItem object. Given that we extend TreeItem, our getTreeItem implementation is dead-simple: element already extends TreeItem so just return it.

Now that we’ve implemented our TreeDataProvider, we need to expose it to VSCode via the vscode.window.registerTreeDataProvider method. This can be seen in our extension.ts file whereby we call registerTreeDataProvider with a first argument of “vulnerabilityTreeDataProvider” and a second argument of our VulnerabilityTreeDataProvider instance. The first argument is used to map this TreeDataProvider instance with the declaration in package.json. In addition, we had to express where we wanted our VulnerabilityTreeDataProvider to be displayed. This can be achieved via the contributes section in package.json as seen below. Note that explorer is a reserved word used to indicate the main view in VSCode and that we continue to use the identifier of “vulnerabilityTreeDataProvider”. It is here that we give our view a human readable name of “Vulnerabilities”.

"contributes": {
    "views": {
        "explorer": [
            {
                "id": "vulnerabilityTreeDataProvider",
                "name": "Vulnerabilities"
            }
        ]
    }
}

The next big challenge we had to overcome was how to display vulnerability information when selected from the Vulnerabilities explorer. All our vulnerabilities are expressed through a series of backtraces that were captured at runtime. Requiring the user to drill down further within the Vulnerabilities explorer to see this information seemed like overkill. Instead, we opted to make use of the WebViewPanel to render this information via an embedded WebView. This gives us greater control on how the information is expressed to the user as we can create and render arbitrary HTML content. On the downside, the use of WebView objects is generally discouraged as it incurs a meaningful performance penalty and requires significant care to ensure that the look-and-feel of the HTML content is consistent with the look-and-feel of VSCode itself.

We leverage embedded HTML template files for all our WebView objects. Our webviews.ts module exports several static functions that can be used to create a WebView. One of the cool things about WebView objects is the ability to leverage the HTML5 postMessage API to facilitate bi-directional communication between our extension code and our HTML/JavaScript code running within the WebView. The following code snippet illustrates the creation of a WebViewPanel with an embedded WebView. In addition, this snippet makes use of the postMessage API to expose discovered vulnerabilities to the HTML for rendering as well as listening for events indicative of user interaction.

export async function renderClassSummary(klass: VulnerabilityClass, vulnerabilities: Array<Vulnerability>) {
    // create panel
    let panel = vscode.window.createWebviewPanel(
        'adversa.summary.class',
        `Vulnerability: ${klass.name}`,
        vscode.ViewColumn.Active,
        {
            enableFindWidget: true,
            enableScripts: true,
            retainContextWhenHidden: true
        }
    );
    panel.webview.html = fs.readFileSync(
        `${__dirname}/../resources/templates/class-summary.html`,
        'utf8'
    );
    panel.webview.onDidReceiveMessage(async(message: { kind: string, entity: any }) => {
        switch (message.kind) {
            case 'openVulnerability':
                vscode.commands.executeCommand(
                    'adversa.open.vulnerability',
                    message.entity
                );
                break;
            default:
                throw new Error('unsupported message kind');
        }
    });

    // render data
    panel.webview.postMessage({ kind: 'renderClass', entity: klass });
    panel.webview.postMessage({ kind: 'renderExamples', entity: vulnerabilities });
}

The last key part of VSCode extension development that we had to learn was the concept of the Command. Commands encapsulate core user-driven operations and can be referenced by various extension view elements. We made frequent use of Commands within our extension, one of which was used to express what should happen when a user selects a vulnerability. The first thing we did was register a Command in our extension.ts module with a key of “adversa.open.vulnerability”. This function accepts a Vulnerability object and invokes a static utility function called commands.Open.vulnerability.

vscode.commands.registerCommand(
    'adversa.open.vulnerability',
    async(vulnerability) => commands.Open.vulnerability(plugin, vulnerability)
);

One place where we invoke this command is from our VulnerabilityItem class. A simplified version of the relevant code snippet is found below. The TreeItem class that we extend exposes a property called “command” that allows for the expression of what command should be invoked and with what arguments when a user clicks on the TreeItem. You’ll notice that we are reusing the key of “adversa.open.vulnerability” to signify the command to be invoked.

class VulnerabilityItem extends vscode.TreeItem {
    readonly label: string;
    constructor(readonly vulnerability: Vulnerability) {
        super("", vscode.TreeItemCollapsibleState.None);

        let backtrace = vulnerability.backtraces.slice(-1)[0];
        let backsymbol = backtrace.symbols[0];
        let index = backsymbol.file.lastIndexOf(path.sep);
        let file = index >= 0 ? backsymbol.file.substr(index + 1) : backsymbol.file;
        let line = backsymbol.line;

        this.label = file + ":" + line;
        this.command = {
            arguments: [ vulnerability ],
            command: 'adversa.open.vulnerability',
            title: 'adversa open'
        };
    }
}

There were of course a number of additional issues that we had to solve, some of which were unique to our feature requirements. Overall, we found the experience of writing a VSCode extension to be fairly pleasant once we got over the initial learning curve. This is only the first iteration of our VSCode extension. In the future we plan to leverage some of the more complex and rich capabilities exposed to extension developers, such as implementing a CodeLendsProvider to allow developers to run passive security testing with a single click in the code editor window. You can download the extension as well as review the source code. We are grateful for any feedback you may be willing to share.